# Legacy lessons & gotchas (Create/Anything export) Hard-won knowledge mined from the **legacy** code (`apps/mobile`, `apps/web`) and `.yarn/patches/` before the full clean. The legacy apps are being removed, but the *reasons* behind these workarounds are worth keeping for the rebuild (`apps/api`, `packages/shared`, and any future mobile client). Everything below is reconstructed from real source — each lesson cites the file path where it lived. None of this code is part of the new stack; it is reference only. --- ## 1. Android font / freeze fixes & platform workarounds ### Font loading must never block render on Android - **`apps/mobile/src/app/(tabs)/index.tsx`** (Stopwatch screen) - Uses `const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold })`. - Gate is `if (!fontsLoaded && !fontError) return null;` — i.e. it returns `null` only while fonts are *still loading*. If font loading **errors**, it renders anyway. The inline comments call this out explicitly: *"if fonts fail to load on Android we still render (no freeze)"* and *"Wait for fonts — but if font loading errored, render anyway (prevents Android freeze)"*. - When `fontError` is truthy it falls back to `fontFamily: undefined` (system font) instead of the named Inter family: `const regular = fontError ? undefined : 'Inter_400Regular'`. - **Lesson for rebuild:** never block the whole tree on `fontsLoaded` alone; always treat `fontError` as "render with system font". A hard wait on fonts is the classic way to get a permanently blank/frozen screen on Android. ### Splash screen has a hard timeout so a stuck session check can't freeze launch - **`apps/mobile/src/app/_layout.tsx`** - `SplashScreen.preventAutoHideAsync()` is called, then the root is gated on `useAuth().initiate()` + `isReady`. To stop a hung session restore from freezing on the splash forever, there is a `SPLASH_TIMEOUT_MS = 10_000` fallback: after 10s it sets `timedOut` and hides the splash / renders regardless of `isReady`. - **Lesson:** any "load persisted session before first render" gate needs a timeout escape hatch. ### Crash reporter is silenced in dev to avoid the RN red-box/freeze loop - **`apps/mobile/index.tsx`** (native entry) - In `__DEV__` it replaces `react-native/Libraries/Core/ExceptionsManager.handleException` with a no-op, and calls `LogBox.ignoreAllLogs()` + `LogBox.uninstall()` (also gated on `EXPO_PUBLIC_CREATE_ENV === 'DEVELOPMENT'`). It also installs an empty `AppRegistry.setWrapperComponentProvider`. This keeps the Create in-builder preview from getting stuck behind RN's error overlay. ### New Architecture is on - **`apps/mobile/app.json`**: `"newArchEnabled": true`. iOS uses `expo-build-properties` with `useFrameworks: "static"`. Worth noting for any native module compatibility decisions in the rebuild. --- ## 2. expo-secure-store / SecureStore quirks (token storage) The richest source of SecureStore wisdom is **`apps/mobile/src/utils/auth/store.ts`** (a platform-managed `⚠ DO NOT REWRITE` file). Token is the better-auth JWT, stored under key `` `${EXPO_PUBLIC_PROJECT_GROUP_ID}-jwt` ``. Explicit `secureStoreOptions` are passed on **every** SecureStore call, and each option exists for a concrete failure it prevents: - **`keychainService: 'anything-auth'`** — pinned to a stable, hard-coded service name. Without it, SecureStore derives the service name from the bundle, which can **drift between Classic and EAS builds**, so reads silently miss writes made by a previous build (token "disappears" after a rebuild). - **`keychainAccessible: AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY`** — lets the token be read on every cold launch after the device has been unlocked once since boot. The default (`WHEN_UNLOCKED`) refuses access during the first-unlock window — described as *"the most common TestFlight failure mode"* (user opens app from a notification before unlocking → auth read fails → appears signed out). - **`requireAuthentication: false`** — keeps SecureStore on its non-biometric code path, so it never reads `NSFaceIDUsageDescription` or builds a biometry access-control object. Both can throw an `NSException` and trip **iOS 26's unhandled async-void TurboModule rethrow**, crashing the app. Other token-storage lessons: - **Keychain writes are wrapped in `.catch(() => {})`** (in `setAuth`). A failed Keychain write must NOT throw — throwing propagates into the unhandled-rejection / TurboModule rethrow path and crashes on iOS 26.x. On write failure the app stays *in-memory authed* for the session and re-auths via the WebView next launch. - **Web has no Keychain** — `expo-secure-store` is aliased to a `localStorage` shim on web (`apps/mobile/polyfills/web/secureStore.web.ts`). It namespaces keys with `_create_secure_store_`, warns (but does not fail) when a value exceeds a 2048-byte limit, `isAvailableAsync()` probes localStorage, and `canUseBiometricAuthentication()` returns `false`. **Lesson:** if the rebuild stores tokens on a web client, plan a non-Keychain fallback — localStorage is not secure storage. - **Where the token gets read for requests:** `apps/mobile/src/__create/fetch.ts` (`SecureStore.getItemAsync(authKey)` → `JSON.parse` → `Bearer `), with a `.catch(() => null)` so a read failure just sends an unauthenticated request rather than crashing. --- ## 3. The global `fetch` monkey-patch (`src/__create/fetch.ts`) Installed by **`apps/mobile/src/__create/polyfills.ts`**, which does `global.fetch = updatedFetch` (the default export of `fetch.ts`). App code everywhere just calls `fetch('/api/...')` and relies on this rewrite. What `fetchToWeb` (in `apps/mobile/src/__create/fetch.ts`) actually does, in order: 1. **Bails out unchanged** if `EXPO_PUBLIC_BASE_URL` (first-party) or `EXPO_PUBLIC_PROXY_BASE_URL` (second-party) is unset — falls back to `expo/fetch`. 2. **Extracts the URL** from whatever form the call used — string, `Request`, or `URL` (via a `getURLFromArgs` helper, because the `URL` type isn't always in the fetch TS signature). 3. **Passes through static/file URLs untouched** via the *original* native `fetch` (not `expo/fetch`): `file://`, `data:`, and static assets matched by extension (`.wasm/.png/.jpg/.svg/.woff2/.ttf/...`). These must not get auth headers or rewriting. 4. **Leaves external (third-party) URLs alone** — headers/JWT are only ever added to first-party requests. "First-party" = URL starts with `/` **or** with `EXPO_PUBLIC_BASE_URL`. 5. **Rewrites first-party URLs onto a base.** A path starting with `/` is prefixed: `/_create/...` paths use the **second-party** base (`EXPO_PUBLIC_PROXY_BASE_URL`), everything else uses the **first-party** base (`EXPO_PUBLIC_BASE_URL`). Non-string inputs (`Request`/`URL`) are *not* rewritten — they fall through to `expo/fetch`. 6. **Injects Create routing headers** on first-party calls: `x-createxyz-project-group-id` (= `EXPO_PUBLIC_PROJECT_GROUP_ID`), plus `host`, `x-forwarded-host`, and `x-createxyz-host` (all = `EXPO_PUBLIC_HOST`). Only set when the env value is truthy. 7. **Attaches auth**: reads the JWT from SecureStore (key `-jwt`, JSON-parsed), and if present sets `Authorization: Bearer `. Read errors are swallowed (`→ null`), so the request still goes out unauthenticated. 8. Dispatches the rewritten request through **`expo/fetch`** (Expo's fetch, for streaming/RN compatibility), not the global one (which it has replaced). **Lessons for rebuild:** the new mobile client should centralise (a) base-URL rewriting and (b) auth-header attachment in one place. The legacy approach monkey-patches the global `fetch`, which is invisible/magic to app code — a typed API client (the `packages/shared` zod contracts make this easy) is a cleaner replacement. Note the alternative `authFetch` in `apps/mobile/src/utils/auth/getSession.ts` does the Bearer-header part explicitly and is the documented "use this instead of bare fetch" helper. --- ## 4. Web sandbox / iframe plumbing The mobile app's **web** target runs inside the Create builder as an `