name: coding-standards description: Load coding standards and conventions for this React Native / Expo project. Use when you need guidance on TypeScript patterns, Tamagui tokens, Zustand stores, Lingui i18n, Doppler env vars, or Zustand state ownership rules.
Apply the following standards to all code in this project.
If this project uses
@ksairi-org/*libraries, run/expo-rn-plugin:libsbefore writing any utility, hook, or layout code — those packages replace many standard alternatives.
Reference implementation — check this FIRST
Before debugging a build error, adding a package, or configuring anything native, check the reference apps — production apps built on this exact stack:
- ksairi-org/reflect — journaling/content focus; OTA updates, push reminders, streaks, i18n
- ksairi-org/virtual-wallet — fintech/payments focus; Stripe, RevenueCat IAP, multi-currency
Rules:
- Build error or native config question → check both apps'
app.config.ts,package.json, andeas.jsonbefore any other investigation - Adding an Expo SDK package → check if either reference app already uses it and copy its config exactly
- Tempted to add a config plugin workaround → check if the reference apps need it; if they don't, you probably don't either
The reference apps encode hard-won lessons. Reverse-engineering Gradle/Xcode internals when the answer is already in a reference app wastes time and risks introducing hacks that the SDK would have handled correctly.
Managed workflow — never touch native folders
This is a managed Expo app. The android/ and ios/ folders are gitignored and fully regenerated by EAS on every build. Never commit them, never hand-edit files inside them.
All native configuration goes through config plugins in app.config.ts:
- Gradle properties →
withGradlePropertiesfrom@expo/config-plugins - Info.plist / entitlements →
withInfoPlist,withEntitlementsPlist - AndroidManifest →
withAndroidManifest - Raw file injection →
withDangerousMod(last resort only)
If a third-party package requires native changes and has no config plugin, check whether an Expo-maintained fork or wrapper exists before reaching for bare workflow.
Package Installation
Always install Expo SDK packages with yarn expo install, never yarn add.
# Correct
yarn expo install expo-speech-recognition
yarn expo install react-native-pager-view @react-navigation/material-top-tabs
# Wrong — will resolve the latest npm version, which may be incompatible with the current Expo SDK
yarn add expo-speech-recognition
yarn expo install resolves the version compatible with the project's current Expo SDK (expo version in package.json). Using yarn add fetches the latest npm version, which may target a different SDK. The mismatch produces native module crashes at runtime (e.g. Cannot find native module 'ExpoSpeechRecognition') that survive multiple clean rebuilds — because the native binary is baked at build time, the only recovery is yarn expo install + a fresh dev client build.
This rule applies to any package in the Expo ecosystem: expo-*, react-native-* packages listed in the Expo SDK, and any package that ships a native module bundled with Expo.
TypeScript
- Never use
any— use proper types, generics, or type guards - Never use
asassertions — fix types at the source - After any code change, run
tsc --noEmitand fix all errors (zero errors is a baseline) - Named exports only — no
export default(exception: Expo Routerapp/files require it). One file = one component/hook; the file name and export name match 1:1 - Always export at the bottom of the file — declare with
const, export in a singleexport { }statement at the end; never inlineexport const - No
React.FC— type props inline or with a separatetype Props = {…} - Never import React as a default —
import React from 'react'is not needed with the new JSX transform; import only what you use:import { useState, useEffect, useRef } from 'react' - Never use
React.useState,React.useEffect,React.useCallbacketc. — always use the named import directly - Arrow functions everywhere —
const MyComponent = () => …andexport const MyScreen = () => …; neverfunctiondeclarations for components, hooks, or helpers - Prefer union types over multiple boolean flags:
type Status = "idle" | "loading" | "error"instead ofisLoading + hasError - Type/interface names: no
TorIprefix; self-documenting plain English; no abbreviations (except universally known ones likeAPI,URL) - Helper function naming:
get*/set*/create*for synchronous;fetch*/post*/patch*/delete*for API calls;is*/are*for type guards and predicates
tsconfig — baseUrl deprecation
TypeScript 6.x warns that baseUrl is deprecated (removed in 7.0). Do not remove baseUrl to silence it — Expo's Metro bundler reads baseUrl from tsconfig.json to resolve the project's path aliases (@/*, @screens, @atoms, …) at runtime. Dropping it can break the bundle even though tsc still passes (TS 4.1+ resolves paths without baseUrl, Metro does not).
Silence the warning by bumping ignoreDeprecations to match the version named in the warning:
{
"compilerOptions": {
"baseUrl": ".",
"ignoreDeprecations": "6.0"
}
}
This is a deferral, not a fix. baseUrl is fully removed in TS 7.0 — before adopting it, drop baseUrl, make every paths entry resolve relative to the tsconfig location, and confirm the Expo SDK in use no longer needs baseUrl for alias resolution.
Formatting
Prettier owns formatting — never hand-adjust quotes, semicolons, or wrapping. Run format-on-save (or prettier --write) and let it win. The project .prettierrc enforces:
- Single quotes —
'foo', never"foo"(singleQuote: true). Prettier still emits double quotes inside JSX attributes; that is expected, leave it. - Always semicolons — terminate every statement (
semi: true). - Trailing commas everywhere (
trailingComma: "all"), 2-space indent (tabWidth: 2), 100-char width (printWidth: 100). - Ship a
.prettierignore— exclude generated Lingui catalogs (src/i18n/locales/compiled/,src/i18n/locales/exported/), the Dopplerenv.template.yaml(its{{ .VAR }}syntax is invalid YAML), anddist/. Without it,prettier --checkfails on files it cannot (or should not) format. - Formatting must be gated, not just configured.
format:check(prettier --check .) runs in the pre-push hook next tocheck:tscandlint, and again in thequality-checksCI workflow. A.prettierrcwith no gate is exactly how a repo drifts from its own config.
CI / GitHub Actions
- Run a quality-gate workflow (
quality-checks.yml) on every PR and on pushes to long-lived branches (stg,main):check:tsc+lint+format:check, on source only (no Doppler/.env). It mirrors the pre-push hook so drift cannot slip through when hooks are bypassed (--no-verify) or uninstalled. - Corepack / Yarn-4 +
setup-nodetrap:actions/setup-node@v5defaultspackage-manager-cache: true, which runsyarn cache dirusing the runner's global Yarn 1.x beforecorepack enable. Becausepackage.jsonpinsyarn@4.xviapackageManager, global Yarn 1 refuses and the step fails. Everysetup-nodestep in a Corepack/Yarn-4 project must setpackage-manager-cache: false. - When major-bumping a GitHub Action (or the Expo SDK), check the changed defaults, not just the inputs you pass — and verify with a real CI/build run before trusting it.
React / Components
- Follow React best practices (hooks, memoization, clean component structure)
- Never use
eslint-disable-next-line react-hooks/exhaustive-deps— fix the dependency issue - React Compiler is on (
reactCompiler: true) — write straightforward code and let it memoize. See the React Compiler section below before adding manualuseRef/useCallbackmemoization or disabling areact-hooksrule - Keep files under 500 lines — split into sub-components, hooks, or utils proactively
- Conditional rendering: use ternary (
condition ? <X /> : null), not&&— the&&form renders0when condition is a falsy number - No margin on custom components — margins create invisible coupling between sibling layout. Use
gapon the parentYStack/XStack, orpaddingon a container instead
React Compiler (reactCompiler: true)
The compiler auto-memoizes — prefer plain, direct code and let it optimize:
- Drop manual memoization for closures. Don't mirror state into
useRefor wrap callbacks inuseCallbackjust to keep them stable or to read the latest value — the compiler memoizes the closure and it always sees current state. Write a plain function. - No
forwardRef(React 19). Passrefas an ordinary prop:({ ref, ...props }: Props & { ref?: Ref<Handle> }), and drop thedisplayNameboilerplate. - Prefer a clean fix over disabling a rule. When
react-hooks/immutabilityflags a ref mutated during render, move the mutation into auseEffectkeyed on its dependency. Only when there is genuinely no clean fix — Reanimated.valuewrites (stable shared refs mutated outside React's data flow), or one-time-init / command-signal effects — use a justified// eslint-disable-next-line react-hooks/immutability(orreact-hooks/set-state-in-effect) with a comment explaining why.exhaustive-depsis still never disabled.
UI component import priority
Always resolve UI needs from the highest available source before reaching for lower ones:
- project-local — atoms, molecules, organisms in this project's own codebase (e.g.
@atoms,@molecules,@organisms) @ksairi-org/— shared org packages; covers buttons, touchables, images, screen containers, forms, auth- tamagui — layout and text primitives:
XStack,YStack,Text,Spinner,Stack, … react-native— only when no Tamagui or@ksairi-org/equivalent exists- Third-party libraries — last resort
Never import View, Text, TouchableOpacity, Pressable, or Image from react-native when a Tamagui or @ksairi-org/ wrapper covers the use case.
When to push a component to @ksairi-org/
Before adding a new component to the project-local layers, ask: would any other app on this stack benefit from this? If yes, and it has no app-specific tokens, data, or business logic, push it to @ksairi-org/libs instead and consume it remotely. Examples that belong upstream: generic wrappers around third-party primitives (KeyboardScrollView), shared layout primitives, utility hooks. This rule only applies when you are a member of the ksairi-org GitHub org and the consuming project already uses @ksairi-org/* packages.
Project-local component layers
Organize project-local shared components into three layers and place new components in the correct one:
- atoms — single-purpose UI primitives with no business logic: typography wrappers, icon wrappers, basic input primitives, status badges, dividers, simple wrappers
- molecules — multi-part units with a single concern, composed from atoms: form fields (label + input + error), card items, list rows, empty-state views, search bars, notification banners
- organisms — full UI sections composing multiple molecules, may hold local state: forms, lists with loading/empty/data states, complex modals, navigation bars, onboarding steps
Screen containers (@ksairi-org/ui-containers)
Every screen must use Containers.Screen as its root element. It handles safe area insets automatically (via useSafeAreaInsets) so you never need SafeAreaView directly. react-navigation adjusts the inset context per navigator, so the all-edges default is self-correcting — no double-padding inside a Stack with a header or inside a Tabs screen.
import { Containers } from '@ksairi-org/ui-containers'
// Screen with its own ScrollView/KeyboardScrollView
<Containers.Screen shouldAutoResize={false}>
<KeyboardScrollView>…</KeyboardScrollView>
</Containers.Screen>
// Screen without scroll — auto-resize switches to ScrollView if content overflows
<Containers.Screen>
<Containers.SubY>…</Containers.SubY>
</Containers.Screen>
Containers.Screen— outermost; handles safe area, auto-resize toScrollViewwhen content overflowsContainers.SubY— vertical sub-section with standard horizontal padding ($md)Containers.SubX— horizontal sub-section with standard horizontal padding ($md)edgesprop (default all four) — override only when you need to exclude specific edgesshouldAutoResize={false}— required when the screen already contains its ownScrollView
Buttons specifically — @ksairi-org/ui-button takes priority over Tamagui's Button. Never use Tamagui's raw Button or react-native touchables for interactive buttons:
- Primary full-width action with Tamagui theme tokens →
BaseTouchablefrom@ksairi-org/ui-touchableswithbg="$token"— notCTAButton.CTAButtonusesunstyled={true}on Tamagui'sButtonand thebackgroundprop does not reliably resolve theme tokens;BaseTouchablewithbgdoes. Useopacity={disabled ? 0.4 : 1}for the disabled visual state and an inline{loading ? <Spinner /> : children}guard. - Primary full-width action with explicit hex/rgba colors →
CTAButton(passbackgroundColoras a literal color string, not a$token; hasloadingprop andspinnerColor) - Secondary action →
BasicButton(fullButtonPropspass-through,opacity=0.4when disabled) - Text-only / link-style →
GhostButton(transparent background,opacity=0.4when disabled; passcolorfrom your theme) - Icon-only →
IconButton(circular, requiresicon: ReactNode, fullButtonPropspass-through) - Custom layout or icons →
BaseButton(acceptsleftIcon/rightIcon) - Spring-animated with auto-width →
SizingAnimatedButtonfrom@ksairi-org/ui-button-animated(backgroundColorrequired; measures its own width internally) - Spring-animated with explicit width →
AnimatedButtonfrom@ksairi-org/ui-button-animated(backgroundColorandwidth: numberboth required; preferSizingAnimatedButtonunless you need explicit width control)
i18n (Lingui)
- Use
Trans+tfor every hardcoded user-visible string - Use
t`…`for prop strings (placeholders, aria labels, alert titles) - Import
Trans, useLinguifrom@lingui/react/macro - Always commit
src/i18n/locales/compiled/*.tsafter runningyarn i18n— thepre-buildscript reruns compilation; if the committed file differs from what the installed lingui version emits (e.g. lingui 6 adds/*eslint-disable*/), every build will produce a dirty tree - After upgrading
@lingui/cli, runyarn i18n:compileand commit the result before the next build - Never use
<Plural>,<Select>, or<SelectOrdinal>in Lingui v6 + Expo/Metro — these crash at runtime (TypeError: Cannot read property 'prototype' of undefined). Use<Trans>with a ternary:{count === 1 ? <Trans>1 item</Trans> : <Trans>{count} items</Trans>}. Seedocs/solutions/runtime-errors/lingui-v6-plural-macro-metro.md. - Never install
@lingui/macroin a v6 project — it max-publishes at v5.9.5 and pulls duplicate v5core/reactpackages. Use@lingui/react/macrowhich ships with v6.
Adding a new locale
Four files to touch — missing any one of them silently falls back to English:
src/i18n/config/constants.ts— add{ code: 'Display name' }entry tolocalessrc/i18n/locales/exported/<code>/screens.po— create the PO file with translations (copy structure from an existing locale)src/i18n/locales/compiled/<code>.ts— create the compiled catalog (same hashed message-ID keys as every other locale; copy from an existing compiled file and replace the translation strings)src/i18n/utils.ts— import the compiled messages and add tomessagesByLocale
iOS second-launch crash (-[NSTaggedPointerString count])
Settings.set({ AppleLanguages: locale }) writes a bare string to NSUserDefaults. On the next launch, UIKit's _CFLocaleCreateLocaleIdentifierForAvailableLocalizations reads AppleLanguages expecting an NSArray and calls count on the string — crashing at ~100ms before any JS initializes. The stack points entirely at system frameworks, making the culprit invisible without reading the crash JSON.
Fix: always wrap in an array — Settings.set({ AppleLanguages: [locale] }).
The crash only manifests on the second launch because the bad value is written on the first. Clearing it requires deleting the app (wipes NSUserDefaults), then reinstalling.
RTL languages (Arabic, Hebrew, …)
When locale is detected from the device language at startup (the standard pattern using expo-localization), RTL is free: React Native reads I18nManager.isRTL from the OS before the first render and mirrors all flex layouts automatically. You do not need to call I18nManager.forceRTL.
forceRTL is only needed when the app has an in-app language switcher that can flip direction mid-session. In that case, the call must be followed by a full app restart (RNRestart or Updates.reloadAsync) because isRTL is read once at process start.
Before enabling an RTL locale, audit layouts for direction-unsafe styles:
grep -r "left:\|right:\|paddingLeft\|paddingRight\|marginLeft\|marginRight\|textAlign.*left\|textAlign.*right" src/screens --include="*.tsx"
hitSlop left/right are safe (touch area, not layout). Anything else must be replaced with start/end equivalents or removed.
General
- No over-engineering; no magic numbers — extract to named constants
- One
importstatement per module path (preventsimport/no-duplicates)
Tamagui
- Main config:
src/theme/tamagui.config.ts; themes:src/theme/themes.ts - Color tokens use semantic kebab-case names — always check
themes.tsbefore using. Standard scale:$surface-app,$surface-card,$surface-subtle,$surface-hover,$surface-pressed,$border-subtle,$border-default,$text-disabled,$text-placeholder,$text-tertiary,$text-secondary,$text-emphasis allowedStyleValues: "strict"— only token values; raw hex/rgba will error at compile time- Spacing/sizing:
$sm,$md,$lgfromsizesSpaces; radius fromradiustokens - Use
gaponXStack/YStackfor whitespace between elements — notSeparator.Separatorrenders a visible divider line and should only be used when that line is intentional UI. UsingSeparatorpurely for spacing conflates layout with visual chrome. - Never use Tamagui color props with raw strings
- Typography: use components from
src/components/— no raw<Text>with style props - Never use
StyleSheet.create()— use Tamaguistyled(). If a third-party component's API forces a plain style object (e.g. astyleprop that only acceptsStyleSheetoutput), add a// NOTE: StyleSheet required — <reason>comment and surface it to the user so they can decide whether to accept it. - Never add inline style props to non-Tamagui components — wrap with
styled()from@tamagui/corefirst, then use token-based style props on the wrapper - Never use absolute positioning for layout — it breaks safe area handling and adaptive sizing. Only use
position: absolutefor overlays (badges, toasts, FABs) that genuinely need to float above the document flow
Tamagui theme config rules (avoid these two mistakes)
Never put theme values in createTokens — color: themes.light in createTokens locks every $color* reference to the light value regardless of active theme, breaking dark mode. Color tokens belong only in the themes object; createTokens should only contain spacing, sizing, radius, and font tokens.
Never use as type assertions on theme objects — } as typeof defaultConfig.themes.light erases your custom token names from TypeScript's view, so $surface-card etc. won't typecheck. Remove the assertion and let TypeScript infer the full type — createTamagui propagates it automatically to GetThemeValueForKey.
Zustand — state ownership
| Layer | Owner |
|---|---|
| Server state | react-query / orval hooks |
| Client/UI state | Zustand |
If data comes from the backend it belongs in react-query. Zustand stores should be thin.
Client-driven-by-remote pattern (edit flows)
When the user edits remote data (profile, settings, checkout form), the handoff pattern is Fetch → Hydrate local state → Edit → Save:
Use react-query to fetch, then seed a local Zustand slice or useState with the result. The user edits the local copy; on save, call the mutation and invalidate the query. Never mutate the react-query cache directly for optimistic edits unless you have a specific reason — local state is simpler and easier to reason about.
Store pattern
type MyStore = MyStoreState & MyStoreFunctions;
const INITIAL_STATE: MyStoreState = { ... };
const useMyStore = create<MyStore>()(
persist(
(set) => ({
...INITIAL_STATE,
setKeyValue: (key, value) => set((state) => ({ ...state, [key]: value })),
}),
{ name: "my-storage", storage: createJSONStorage(() => createZustandMmkvStorage({ id: "my-storage" })) },
),
);
Selectors — always select minimally
// Good
const firstName = useUserStore((state) => state.firstName);
// Bad — re-renders on any store change
const store = useUserStore();
Tab Navigation Setup
Every app with a tab navigator must install react-native-pager-view and @react-navigation/material-top-tabs before the first native build (the pager and the precise types still come from these packages):
yarn expo install react-native-pager-view @react-navigation/material-top-tabs
SDK 56+: never import @react-navigation/* values directly — expo-router forbids it and Metro throws at bundle time. Import the navigator value from expo-router's re-export and keep precise types via a type-only import (erased at build, so it doesn't trip the runtime check):
import { createMaterialTopTabNavigator } from 'expo-router/js-top-tabs';
import { withLayoutContext } from 'expo-router';
import type { MaterialTopTabBarProps } from '@react-navigation/material-top-tabs';
const { Navigator } = createMaterialTopTabNavigator();
const MaterialTopTabs = withLayoutContext(Navigator);
Full re-export mapping: @react-navigation/native → expo-router/react-navigation (ThemeProvider, DefaultTheme, DarkTheme); @react-navigation/bottom-tabs → expo-router/js-tabs; @react-navigation/elements → expo-router/react-navigation (PlatformPressable).
Use MaterialTopTabs in app/(tabs)/_layout.tsx for animated carousel swipe between tabs. Installing after the first build requires a full EAS rebuild — do it upfront.
Resetting UI state on tab blur
Any open or expanded UI state — swipeables, accordions, bottom sheets, inline menus — must be reset when a tab loses focus. With a pager navigator, the screen stays mounted, so stale open state is visible when the user swipes back.
Reset in the useFocusEffect cleanup, and use an instant reset (not animated) to avoid a visible flash during the pager's return transition:
import { useCallback, useEffect, useState } from 'react'
const [closeKey, setCloseKey] = useState(0)
useFocusEffect(
useCallback(() => {
return () => setCloseKey(k => k + 1) // fires on blur, not on focus
}, [])
)
// In the child component:
useEffect(() => {
if (closeKey > 0) ref.current?.reset() // reset(), not close() — instant, no animation
}, [closeKey])
close() runs an animation that can still be seen as the pager slides the screen back in. reset() snaps instantly while the screen is off-screen.
List entrance animations (pager screens)
With a pager navigator, screens stay mounted — a useEffect([]) inside a list item fires once at app start for all tabs simultaneously, not when the user first visits the tab. Gate the animation on first focus instead:
const [animKey, setAnimKey] = useState(0)
const hasAnimated = useRef(false)
useFocusEffect(
useCallback(() => {
if (!hasAnimated.current) {
hasAnimated.current = true
setAnimKey(1) // triggers animation exactly once
}
}, [])
)
// Pass animKey down to each animated list item:
const AnimatedEntry = ({ index, animKey, children }) => {
const tx = useSharedValue(index % 2 === 0 ? -40 : 40)
const opacity = useSharedValue(0)
useEffect(() => {
cancelAnimation(tx)
cancelAnimation(opacity)
tx.value = index % 2 === 0 ? -40 : 40
opacity.value = 0
tx.value = withDelay(index * 100, withTiming(0, { duration: 500 }))
opacity.value = withDelay(index * 100, withTiming(1, { duration: 500 }))
}, [animKey])
// ...
}
cancelAnimation before resetting the shared value is required — without it, a re-trigger while the previous animation is still running produces a jump.
Settings Tab
Every app with a tab navigator must include a Settings tab. It is the standard home for account-level and device-level controls that don't belong in content screens.
Required items (always present):
- Subscription — shows current plan (Free / Pro Monthly / Pro Annual) with spinner while RC loads; "Upgrade to Pro ✦" button when free →
presentPaywall(); "Manage subscription" when pro →Purchases.showManageSubscriptions(). See/expo-rn-plugin:iapfor the full pattern. - Push notification permission — shows
Granted/Deniedstatus; when denied, tapping opensLinking.openSettings()(app-specific settings page on both platforms); uses anAppStatelistener +openedSettingsref to re-check the permission when the user returns. See/notificationsfor the full pattern. - Sign out — always confirm with
Alert.alertbefore callingsupabase.auth.signOut(); usestyle: 'destructive'on the confirm button; placed at the bottom of the screen so it doesn't get tapped by accident
Growing list — as new general-purpose controls are identified (e.g. language preference, theme toggle, account deletion), add them here before adding to any content screen.
Do not put permission or account controls in content screens (journal, feed, reflections, etc.) — they belong in Settings.
Environment Badge
Every app must render an EnvBadge in the root layout. It reads EXPO_PUBLIC_ENV and shows an amber "STAGING" pill overlay in the top-right corner when the env is not prd. It renders nothing in production, so there is no cost to keeping it in the tree.
Mount it as a sibling of <SplashView /> in app/_layout.tsx:
import { EnvBadge } from "@atoms";
// Inside RootLayout return — EnvBadge must come before SplashView so it renders on top once the splash fades:
<EnvBadge />
<SplashView ... />
The component lives at src/components/atoms/EnvBadge/index.tsx and is exported from @atoms.
Env Vars / Doppler
- Secrets via Doppler — project
mobile, configsdev/stg/prd - Adding a new secret requires three steps:
- Add to
env.template.yaml:EXPO_PUBLIC_FOO={{ .FOO }}(left = shell var, right = Doppler key) - Set in relevant configs:
doppler secrets set FOO="value" --project mobile --config stg - Sync:
yarn sync-env-vars
- Add to
- Doppler key naming: never put
EXPO_PUBLIC_on the Doppler key — that prefix belongs only on the left side ofenv.template.yaml. Example: Doppler key isSUPABASE_API_KEY; template maps it asEXPO_PUBLIC_SUPABASE_API_KEY={{ .SUPABASE_API_KEY }}.
HTTP / API
- Use orval-generated hooks for all API calls — never
axiosdirectly in components axiosonly for non-REST endpoints or one-off authenticated file uploads- Debugging stale queries: open the React Query panel in Expo dev client (
@dev-plugins/react-query) before touching code
Lists
- Always use
FlashListfrom@shopify/flash-list— neverFlatList estimatedItemSizeis required — omitting it causes a warning and degrades performance
User Feedback (toasts)
- Use
burntfor all success and error toasts — neverAlert.alertfor transient feedback - Success:
Burnt.toast({ title: '…', preset: 'done' })— auto-dismisses after ~2s - Error:
Burnt.toast({ title: '…', preset: 'error' })— auto-dismisses after ~4s - Destructive / irreversible actions → confirmation
Alert.alertdialog, not a toast - Form validation errors → inline under the field, not a toast
Foreground push notification UI
When a push arrives while the app is open, show a top banner with a bell icon — not Burnt.alert (centered modal with heart). The notification helper in useToast encapsulates this:
// useToast.ts
function notification({ title, message, duration = 4 }) {
Burnt.toast({
title, message, preset: 'custom',
icon: { ios: { name: 'bell.fill', color: '#007AFF' } },
duration, from: 'top',
})
}
Wire it up in the root layout:
const { notification } = useToast()
useEffect(() => {
return subscribeToForegroundMessages((title, body) => {
notification({ title, message: body })
})
}, [])
Burnt.alert with preset: 'heart' is for app-initiated alerts (achievements, confirmations). The top-banner notification pattern matches what users expect from a real push notification landing while they're in the app.
Dates
- Format dates with
date-fns— always pass the locale fromexpo-localizationfor locale-aware output - Never use
Date.toLocaleDateString()— output varies by device locale settings
OTA Updates
- OTA updates only work if no native code changed since the last full EAS build. The most common cause: a dependency update that includes native modules. When in doubt, do a full build.
- Control update urgency via the
['expo-updates'].typefield inapp.config.ts:'mandatory'— shows an alert on launch requiring the user to update before continuing'optional'— silently applies the update on the next cold start (app fully closed and reopened)
- OTA updates do not work on debug builds — test against a release (internal) build on a simulator or device.
Assets
- Compress all PNG/JPG/MP4 assets with ImageOptim before committing — typically saves 40–80% with no perceptible quality loss
- To find all images in the project:
find . -name "*.png" | grep -v node_modules | xargs -I {} cp {} tmp-images
Supabase custom schema setup
When using a custom schema (e.g. api instead of public), two steps are required before the app can query it:
1. Expose the schema in PostgREST — Supabase only exposes public and graphql_public by default. Add your schema via the Management API or the dashboard:
# Via Management API (scriptable — use in setup automation)
curl -X PATCH "https://api.supabase.com/v1/projects/{project_ref}/postgrest" \
-H "Authorization: Bearer {supabase_access_token}" \
-H "Content-Type: application/json" \
-d '{"db_schema":"public,graphql_public,api"}'
# Or: Supabase dashboard → Settings → API → Exposed schemas → add "api"
Without this, every query returns PGRST106: Invalid schema.
2. Grant privileges to roles — Supabase no longer auto-grants privileges to anon/authenticated/service_role on new tables (effective 2026-05-30 for new projects, 2026-10-30 for existing). Every migration must include explicit grants:
GRANT SELECT, INSERT, UPDATE, DELETE ON api.your_table TO authenticated;
GRANT ALL ON api.your_table TO service_role;
-- anon typically gets no access to app tables (requires auth)
Without this, authenticated users get 42501: permission denied for table.
The Supabase client must also declare the schema:
createClient(url, key, { db: { schema: 'api' } })
Push Notifications
Simulator-only guard for notification UI
Features that require a physical device (FCM tokens, permission requests, local notification scheduling) should not be visually disabled on the simulator. Instead, keep the UI fully enabled and show a toast when tapped. This gives a realistic preview of the UI without misleading "Denied" states.
import * as Device from 'expo-device'
const isSimulator = !Device.isDevice
function showSimulatorToast() {
alert({ title: 'Physical device only', message: 'This feature is not available on the simulator.', preset: 'error', duration: 3 })
}
// In handlers:
async function handleReminderToggle() {
if (isSimulator) { showSimulatorToast(); return }
// ... real logic
}
In JSX, replace disabled={!notifPermission} with disabled={isSimulator ? false : !notifPermission} and the same pattern for opacity and backgroundColor. Skip "Enable notifications in Settings" hint copy on simulator ({!isSimulator && !notifPermission && ...}).
The service layer (requestNotificationPermission, getFCMToken) already returns false/null for !Device.isDevice — no changes needed there.
Device token registration
Register the FCM token whenever the auth state changes to signed-in. Fire-and-forget — do not await in the auth listener:
// in useAuthSession — onAuthStateChange handler
if (s?.user) {
identifyRevenueCatUser(s.user.id)
upsertDeviceToken(s.user.id) // fire and forget
}
device_tokens table schema
One row per user — unique(user_id). If the user reinstalls the app they get a new FCM token; upserting on user_id updates the row in place rather than accumulating stale tokens.
create table api.device_tokens (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
fcm_token text not null,
reminder_hour int, -- local time (null = no reminder)
reminder_minute int,
timezone text, -- IANA string e.g. "America/New_York"
updated_at timestamptz not null default now(),
unique (user_id)
);
alter table api.device_tokens enable row level security;
grant select, insert, update, delete on api.device_tokens to authenticated;
grant select, insert, update, delete on api.device_tokens to service_role;
create policy "Users can manage their own device tokens"
on api.device_tokens for all
using (auth.uid() = user_id) with check (auth.uid() = user_id);
Permission status vs permission request
Never call requestPermissionsAsync() (or requestNotificationPermission()) on screen mount. Tab screens are pre-mounted by the tab navigator — a useEffect on the settings tab fires at app startup, which would show the OS permission dialog before the user has even opened settings. That is too aggressive.
Instead, keep two separate functions:
getNotificationPermissionStatus()— read-only, never shows a dialogrequestNotificationPermission()— shows the OS dialog (call only on explicit user action)
export type NotificationPermissionStatus = 'undetermined' | 'granted' | 'denied'
export async function getNotificationPermissionStatus(): Promise<NotificationPermissionStatus> {
if (!Device.isDevice) return 'denied'
const { status } = await ExpoNotifications.getPermissionsAsync()
if (status === 'granted') return 'granted'
if (status === 'undetermined') return 'undetermined'
return 'denied'
}
In the settings screen, track NotificationPermissionStatus | null (null = loading):
const [notifPermission, setNotifPermission] = useState<NotificationPermissionStatus | null>(null)
async function refreshPermissionStatus() {
if (isSimulator) return
const status = await getNotificationPermissionStatus()
setNotifPermission(status)
if (status === 'granted') {
const token = await getFCMToken()
const { data: { user } } = await supabase.auth.getUser()
if (user) upsertDeviceToken(user.id)
}
}
The permission row tap handler covers all three states:
async function handlePermissionPress() {
if (notifPermission === 'granted') return
if (notifPermission === 'undetermined') {
// First time — show in-app OS dialog
const granted = await requestNotificationPermission()
setNotifPermission(granted ? 'granted' : 'denied')
if (granted) { /* fetch token + upsert */ }
return
}
// Denied — must go to Settings
openedSettings.current = true
await Linking.openSettings()
}
Show label and color per state:
'undetermined'→ accent color, label "Enable"'granted'→ green, label "Granted"'denied'→ red, label "Denied — tap to open Settings"
Re-enabling notifications after denial
When a user denies permission then later re-enables it from iOS/Android Settings, the app must detect the return and register the FCM token immediately. Pattern: AppState listener + openedSettings ref:
const openedSettings = useRef(false)
useEffect(() => {
refreshPermissionStatus()
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active' && openedSettings.current) {
openedSettings.current = false
refreshPermissionStatus() // re-check + upsert if newly granted
}
})
return () => sub.remove()
}, [])
Notification permissions do NOT belong in the backend
The OS silently drops notifications to users who have denied permission — the backend never needs to know. Store only fcm_token, reminder_hour, reminder_minute, and timezone. When the user disables a reminder in-app, set those three fields to null; the edge function skips rows with no reminder set.
Syncing reminder preferences
Always include timezone (IANA string) when syncing reminder time so the server can fire at the correct local time regardless of UTC offset. Use Intl.DateTimeFormat().resolvedOptions().timeZone on the device:
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
await supabase.from('device_tokens').upsert(
{ user_id, fcm_token, reminder_hour: enabled ? hour : null,
reminder_minute: enabled ? minute : null,
timezone: enabled ? timezone : null, updated_at: new Date().toISOString() },
{ onConflict: 'user_id' },
)
Supabase deployment rule
Always apply every server-side fix — edge functions, migrations, RLS policies, DB schema changes — to both stg AND prd. Client code is shared and deployed once, but server-side changes are per-project. Never leave one Supabase project behind. Use the project's npm scripts:
yarn functions:deploy:stg # or the equivalent for migrations
yarn functions:deploy:prd
If only stg is fixed, bugs will be impossible to reproduce in production and users will be affected while staging appears healthy.
Supabase Edge Functions
tsconfig exclusion
Edge functions are Deno — exclude them from the React Native tsconfig or tsc will fail on Deno globals and https://esm.sh/ imports:
"exclude": ["node_modules", "supabase/functions"]
Excluding functions from the OpenAPI spec
The @ksairi-org/react-query-sdk spec generator scans every subdirectory of supabase/functions/ and generates OpenAPI 3.0 entries for each. When merged with the Supabase REST spec (Swagger 2.0), the version mismatch causes orval validation to fail.
Every edge function must start with // @openapi-internal unless it is explicitly a typed client API endpoint that should appear in the generated React Query SDK. In practice, all functions in this project use // @openapi-internal — cron jobs, webhooks, admin tools, push senders, device-facing custom protocols (e.g. OTA manifest), and dev helpers all qualify.
// @openapi-internal — <one-line description of what this function does>
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
The generator skips any index.ts that contains // @openapi-internal. Forgetting this line causes yarn start to fail with an orval OpenAPI validation error.
Deploying without Docker
supabase functions deploy requires Docker only for local serving. Deploying to the hosted project works without it:
supabase login --token $SUPABASE_ACCESS_TOKEN
supabase link --project-ref <ref>
supabase functions deploy send-reminders --project-ref <ref>
pg_cron for scheduled push
Set up via SQL in the Supabase dashboard (requires pg_cron and pg_net extensions, both enabled by default):
select cron.schedule(
'send-reminders',
'* * * * *',
$$select net.http_post(
url := 'https://<ref>.supabase.co/functions/v1/send-reminders',
headers := '{"Authorization": "Bearer <service_role_key>"}'::jsonb
) as request_id;$$
);
Firebase service account key
The FCM v1 API requires a service account private key to send push notifications programmatically. This is different from what the Firebase Console UI uses — the console authenticates you as a human via Google login, but a backend function needs its own credential to call the FCM HTTP API on your behalf.
To obtain it: Firebase Console → Project Settings → Service Accounts → Generate new private key. This downloads a JSON file containing client_email and private_key.
Store those two values as Supabase secrets (FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY) and delete the JSON from your filesystem immediately. Do not revoke the key once it is in Supabase secrets — the edge function depends on it. If you suspect it is compromised, rotate it: generate a new key, update the Supabase secrets, then revoke the old one.
One service account JSON covers all Supabase environments (stg, prd) that point to the same Firebase project.
FCM v1 auth from Deno
Use jose to sign the JWT assertion and exchange for an OAuth2 access token. Store FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, and FIREBASE_PRIVATE_KEY as Supabase secrets:
import * as jose from 'https://esm.sh/jose@5'
const privateKey = await jose.importPKCS8(FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'), 'RS256')
const jwt = await new jose.SignJWT({ scope: 'https://www.googleapis.com/auth/firebase.messaging' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt().setExpirationTime(Math.floor(Date.now() / 1000) + 3600)
.setIssuer(FIREBASE_CLIENT_EMAIL)
.setAudience('https://oauth2.googleapis.com/token')
.sign(privateKey)
const { access_token } = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
}).then(r => r.json())
Then POST to https://fcm.googleapis.com/v1/projects/${FIREBASE_PROJECT_ID}/messages:send with Authorization: Bearer <access_token>.
JWT grant type — oauth not oauth2
The correct grant type for a service account JWT assertion is urn:ietf:params:oauth:grant-type:jwt-bearer. Using oauth2 (with the 2) causes Google's token endpoint to return unsupported_grant_type. This applies to both Deno (URLSearchParams) and Node (fetch).
APNs credentials required for iOS delivery
Even with a valid service account and OAuth token, FCM returns 401 UNAUTHENTICATED / THIRD_PARTY_AUTH_ERROR if the APNs Auth Key is not configured. This has nothing to do with your service account — it means Firebase can't forward the notification to Apple.
Fix: Firebase Console → Project Settings → Cloud Messaging → Apple app configuration → upload your .p8 APNs Auth Key (from Apple Developer → Keys). One key works for both development and production.
THIRD_PARTY_AUTH_ERROR in the FCM response details array is the diagnostic signal — the generic 401 message says nothing useful.
send_test_push MCP tool — prerequisites checklist
Before calling send_test_push:
firebase-service-account.jsonexists in the project root (path set inmcp.config.json→firebase.serviceAccountPath)firebase-service-account.jsonis in.gitignore— the default pattern*firebase-adminsdk*.jsondoes not match a file namedfirebase-service-account.json; add an explicit entry- APNs Auth Key is uploaded in Firebase Console (see above)
inspect_push_tokens MCP tool — schema flexibility
The tool introspects information_schema.columns before querying so it handles non-standard schemas:
- Token column:
tokenorfcm_token - Timestamp column:
created_atorupdated_at platformcolumn may be absent (omitted from SELECT if missing)
If you extend the device_tokens table, no changes to the tool are needed as long as id, user_id, and at least one of the token column names above are present.
Timezone-aware reminder matching in edge functions
Use Intl.DateTimeFormat to convert UTC now to the user's local time — do not store UTC offsets (they break across DST transitions):
function matchesReminderTime(now: Date, timezone: string, hour: number, minute: number): boolean {
const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour: 'numeric', minute: 'numeric', hour12: false })
.formatToParts(now)
const localHour = parseInt(parts.find(p => p.type === 'hour')!.value)
const localMinute = parseInt(parts.find(p => p.type === 'minute')!.value)
return localHour === hour && localMinute === minute
}
Streaks
Anchor the streak count to yesterday if today has no entry yet, so users see their active streak before writing each day — not 0:
const cursor = new Date()
if (!daysWithEntry.has(dayKey(cursor))) {
cursor.setDate(cursor.getDate() - 1) // still within today's window
}
while (daysWithEntry.has(dayKey(cursor))) {
streak++
cursor.setDate(cursor.getDate() - 1)
}
Only reset to 0 if neither today nor yesterday has an entry.
Daily rotating content
For prompts, tips, or any content that should change daily but stay stable throughout the day:
export function getDailyPromptIndex(promptCount: number): number {
return Math.floor(Date.now() / 86400000) % promptCount
}
Place the translatable prompt strings in the screen file (not in the hook) so Lingui's extractor picks them up for the correct catalog.
Dev-only tools (__DEV__)
__DEV__ is true only when Metro bundler is running (dev client / yarn start). It is false in all EAS builds — including staging and production. Use it to gate developer-only UI that should never ship to users:
{__DEV__ && fcmToken ? (
<SizingAnimatedButton onPress={handleFcmTest} ...>
<LabelLg>Send real FCM push (dev)</LabelLg>
</SizingAnimatedButton>
) : null}
Typical uses: FCM pipeline test buttons, RC customer ID display, internal debug panels. Never use __DEV__ to gate features that should exist in staging but not production — use EXPO_PUBLIC_ENV for that.
Dev-only edge functions
For dev-only backend tools (e.g. send-test-push), still deploy to all environments (stg + prd). The app only calls them from __DEV__ builds, so they're harmless in production but don't need special environment checks.
Mark them // @openapi-internal (required for all edge functions — see above).
i18n — dev-only strings
Do not wrap dev-only strings in a t template literal or <Trans> — Lingui will extract them and warn about missing translations in every language. Use a plain string literal instead:
// ✅ plain string — Lingui ignores it, no translations needed
'Not available on simulator'
// ❌ extracted by Lingui — requires translations in all 5 languages
t`Not available on simulator`
The exception is strings that appear in __DEV__ blocks but are also shown to real users in some flows — those still need t\``.
For truly unavoidable cases where a dev string ends up extracted (e.g. inside a shared component), copy the English value as-is into all non-English .po files to silence the warning.
Build scripts — naming convention
| Script | Platform | Profile | Target |
|---|---|---|---|
dev-client-ios |
iOS | development |
Simulator |
dev-client-android |
Android | development |
Emulator |
dev-client-ios-device |
iOS | development-device |
Physical device |
dev-client-android-device |
Android | development-device |
Physical device |
dev-client-ios:prd / dev-client-android:prd etc. |
— | — | Same, prd env |
Required eas.json profiles:
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": { "simulator": true }
},
"development-device": {
"developmentClient": true,
"distribution": "internal",
"ios": { "simulator": false }
},
"preview": {
"distribution": "internal",
"ios": { "simulator": false }
}
development/development-device→developmentClient: true, shows Expo dev launcher, connects to Metro over Wi-Fi or USBpreview→ standalone build, JS bundled at build time, no Metro — use for distribution testing (TestFlight-like), not for iterative development on device- Do not use
previewfor device dev clients — the app will open once (from the embedded bundle) but has no Metro fallback and will crash on subsequent opens if the bundle has any issue
For Android, the ios.simulator flag is ignored — development would work for physical Android devices too, but use development-device for consistency.
Required eas.json submit profiles:
"submit": {
"stg": {
"ios": {
"appleId": "you@example.com",
"ascAppId": "<stg App Store Connect app ID>"
},
"android": {
"serviceAccountKeyPath": "./eas-service-account.json",
"track": "internal",
"releaseStatus": "completed"
}
},
"prd": {
"ios": {
"appleId": "you@example.com",
"ascAppId": "<prd App Store Connect app ID>"
},
"android": {
"serviceAccountKeyPath": "./eas-service-account.json",
"track": "production",
"releaseStatus": "draft"
}
}
}
stgusesreleaseStatus: "completed"— without it, the AAB is uploaded to the artifact library but no release is created, requiring a manual step in the Play Console.prdusesreleaseStatus: "draft"— production releases should be manually reviewed in the Play Console before going live (set rollout %, check release notes, confirm everything).
@ksairi-org library publishing
@ksairi-org/* packages live in the ksairi-libs monorepo. CI publishes automatically on push to main — never publish manually unless the CI workflow is broken.
Workflow for fixing a library bug:
- Patch
node_moduleslocally to validate the fix in the consuming app - Apply the same fix to the library source in
ksairi-libs - Bump the version in
package.json - Push to
main— CI publishes and the fix is live
Do not use yarn patch (Yarn Berry's patch mechanism) for @ksairi-org/* packages — that's for third-party libraries you don't control. Fix the source instead.
npm auth token — store in ~/.yarnrc.yml (global, never committed):
npmScopes:
"@ksairi-org":
npmRegistryServer: "https://registry.npmjs.org"
npmAuthToken: npm_xxx
Also add to ~/.npmrc for plain npm publish fallback:
//registry.npmjs.org/:_authToken=npm_xxx
Never put the token in the repo's .yarnrc.yml.
.gitignore
Always include these entries for Expo/EAS projects:
# EAS local build artifacts
*.ipa
*.aab
*.apk
*.app
build-*.tar.gz
# Native credential files
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Firebase service account keys (admin privileges — never commit)
*firebase-adminsdk*.json
# Google Play service account key
eas-service-account.json
Build artifacts (.ipa, .aab, .apk) are produced by eas build --local and can be several hundred MB. They must never be committed.
Unit / Component Tests
- Test runner:
jest-expo; render helper:@testing-library/react-native - Always wrap renders in a
renderWithProvidershelper that includes Tamagui, query client, and i18n providers - Assert on what the user sees (
screen.getByText,screen.getByRole) — never on internal state - Run
/expo-rn-plugin:testingfor canonical test patterns and provider setup