Data & Auth
Convex subscriptions, Better Auth Expo, the sign-in sheet, and the account stage.
Subscriptions, not fetching
No React Query. All server state is useQuery/useMutation/useAction from convex/react. Conditional reads gate on the Convex-side auth state:
const convexUserId = useConvexUserId(); // null until BOTH auth layers are ready
const profile = useQuery(
api.auth.queries.mobileGetCurrentUser,
convexUserId ? {} : 'skip'
);useConvexUserId() (src/integrations/convex/hooks.ts) returns the id only when both the Better Auth session and useConvexAuth() are authenticated — queries never run inside the JWT "null-window".
The sign-in sheet
Guests browse freely. A control that needs an account calls requireSignIn(reason, onSignedIn?) from src/shared/hooks/use-require-sign-in.ts. The auth group opens as a modal sheet over the current screen, titled from the reason (SIGN_IN_COPY in src/shared/constants/sign-in-reasons.ts). Close, or a swipe down, returns to that screen. After sign-in the sheet closes onto it and onSignedIn runs, so a heart a guest tapped ends up saved. A screen that cannot show anything to a guest (My Wishlist, Order History, Beauty Profile, the beauty quiz, Edit Profile) renders SignInRequired instead of leaving, so the sheet always has a screen to close back onto. Sign-in is therefore always a sheet.
The sheet holds three pages: login, the code, and account details for a new account. One screen asks for name, email, and an optional birthday and gender. A phone sign-up is not asked for its number again, and the country comes from the number. Account details cannot be swiped away and has no back button, because the account is already signed in. Its Close asks "Stop creating your account?" and offers Browse as guest, which signs out and closes the sheet; the next sign-in comes back to account details.
The account stage
deriveAccountStage (src/features/auth/model/account-stage.ts) turns the session and the profile subscription into one value, and every post-sign-in decision reads it:
guest: no session. Guests may browse.loading: the session or the profile has not answered.unknown: the bounded 5 s profile wait expired. Nothing routes on it; consumers show a retry (retryProfile).closed:isDeleted/isSuspended, the only flags that force sign-out.needs-details: no profile, or one missing a required field.ready: the account can use the app.
useContinueAfterSignIn moves the sheet on from login, the code page and account details: needs-details goes to account details, ready closes the sheet. usePostAuthRedirect reports the stage to (app)/_layout. For a needs-details account outside the sheet (the app restarted halfway through sign-up) it opens account details as a sheet over the app, so that path looks the same as signing in. It never replaces the app stack, which the sheet closes back onto.
Sign-in has no other stops. The beauty quiz is offered by a Home card, and the notification pre-prompt follows an order confirmation. Notifications has the pre-prompt rule.
The transient-null rule
profile === null can be transient while Better Auth/Convex sync catches up — useCurrentUserProfile treats a missing profile as loading for a bounded wait (exposing isTimeout + retry()), and consumers must render a retryable state rather than guessing onboarding. Only the explicit isDeleted/isSuspended backend flags force sign-out.
Sign-out hygiene
performSignOut (auth-client.ts) removes the push token from the backend before destroying the session, runs all registered store cleanups (store-lifecycle registry), then calls signOut().
Session storage
@better-auth/expo persists via expo-secure-store through secure-storage-proxy.ts — a telemetry proxy that logs (and Sentry-reports) the first storage failure per session instead of letting the plugin swallow it silently.