Files
solelog/docs/reference/legacy-lessons-and-gotchas.md

291 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_<key>`, 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 <auth.jwt>`), 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 `<PROJECT_GROUP_ID>-jwt`,
JSON-parsed), and if present sets `Authorization: Bearer <auth.jwt>`. 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 `<iframe>` and talks
to the parent window over `postMessage`. This is purely a Create-preview affordance; the
rebuild does not need any of it unless it re-targets the Create platform.
- **Entry split by extension** (`apps/mobile/metro.config.js` resolves these):
- Native: `index.tsx` → `entrypoint.ts` → `App.tsx` (`App.tsx` just wraps
`expo-router`'s qualified-entry `App` with a `ScreenViewTracker`).
- Web: `index.web.tsx` → `App.web.tsx`.
- **`apps/mobile/App.web.tsx`** — the iframe/sandbox contract:
- **Healthcheck handshake** (`useHandshakeParent`): replies to
`sandbox:mobile:healthcheck` with `sandbox:mobile:healthcheck:response {healthy:true}`,
and also posts the healthy response immediately on mount in case the healthcheck arrived
before the listener was attached.
- **Navigation sync** (both directions): listens for `sandbox:navigation {pathname}` from
the parent and `router.push`es it; posts `sandbox:mobile:navigation {pathname}` back on
every `usePathname()` change; posts `sandbox:mobile:ready` once on mount.
- **Error forwarding** (`GlobalErrorReporter` + `postErrorToParent`): global `error` and
`unhandledrejection` listeners post `sandbox:error:detected {message,name,stack}` to the
parent — but **filter out runtime/network errors** (a big `RUNTIME_ERROR_PATTERNS`
regex list: `fetch failed`, `ECONNREFUSED`, `502/503/504`, `timeout`, etc.) so only
*code* errors (TypeError/ReferenceError/SyntaxError/`MODULE_RESOLVE_FAILED`) are
surfaced to the builder. `event.preventDefault()` is called to suppress the default
overlay.
- **Faked safe-area insets on web**: `SafeAreaProvider initialMetrics` hard-codes
`{ top: 64, bottom: 34, ... }` and frame size from `window.innerWidth/Height`, so the
web preview mimics a phone notch.
- **`apps/mobile/src/__create/ErrorBoundary.tsx`** — a React error boundary that also posts
`sandbox:error:detected` on `componentDidCatch` and `sandbox:error:resolved` when the user
taps "Try again". All `postMessage` calls are guarded by `window.parent !== window`.
- **`apps/mobile/index.web.tsx`** — screenshot + Skia + font plumbing:
- Responds to `sandbox:web:screenshot:request` by rasterising `#root` with
`html-to-image` `toPng` and posting back `sandbox:web:screenshot:response {dataUrl}`
(or `...:error`).
- Before screenshotting it **inlines Google Fonts** (`inlineGoogleFonts`: fetches each
`fonts.googleapis.com` stylesheet, absolutises `url(...)` refs, injects a `<style>`,
then awaits `document.fonts.ready`) and waits for all `<img>`s (`crossOrigin =
'anonymous'`) — otherwise the screenshot renders with missing fonts/images.
- Loads Skia for web (`LoadSkiaWeb({ locateFile: f => \`/${f}\` })`) and renders the root
in `.then`/`.catch` so the app still mounts if Skia fails to load.
**Lesson:** all of this is Create-builder coupling. For the rebuild it is dead weight unless
you keep publishing to `*.created.app`. The one genuinely reusable idea is the
*code-vs-runtime* error classification in `App.web.tsx` (don't spam your error channel with
transient network failures).
---
## 5. What each `.yarn/patches/*` file patches, and why
> All are pinned by `patch:` deps + root `resolutions`/`overrides`. Bumping any of these
> packages discards the patch (per project `CLAUDE.md`). One line each:
- **`react-native+0.81.4.patch`** — re-adds a working `Slider` getter to RN's `index.js`
(forwarding to `@react-native-community/slider`) and removes RN core's "Slider has been
removed" invariant that otherwise throws on access.
- **`@expo+cli+54.0.1.patch`** — in the Expo Go manifest middleware, generates the manifest
`id` with `crypto.randomUUID({ disableEntropyCache: true })` to avoid duplicate/cached
UUIDs across rapid manifest requests.
- **`@expo+metro-runtime+6.1.2.patch`** — short-circuits the dev **error overlay**:
`LogBoxInspectorContainer` and `ErrorToast` both `return null` immediately, so RN's
red-box error UI never shows inside the Create preview.
- **`@react-native-community+netinfo+11.4.1.patch`** — makes `nativeInterface.ts` tolerate a
missing native module: removes the hard `throw` when `RNCNetInfo` is null and returns `{}`
instead, so NetInfo doesn't crash environments (web/preview) where the native module isn't
present.
- **`expo-router+6.0.11.patch`** — large patch. (a) Restyles the web `native-tabs` CSS into
a floating, blurred bottom tab bar + adds an Apple-Settings-style "More" overflow screen
when there are >5 tabs (incl. extracting `webIcon*`/`webLabel*` options for web rendering).
(b) Adds an `isAnythingApp` branch (iOS + not Expo Go) that forces `getInitialURL` to `/`
and skips the `Linking` 'url' subscription, so the Anything native shell always boots at
root instead of via deep link.
- **`expo-store-review+9.0.8.patch`** — adds `prePromptReview` / `resetReviewState` /
`hasUserRated` to the JS API and a native iOS `StoreReviewModule` (a custom "Thanks for
using Anything!" pre-prompt alert that gates the real `AppStore.requestReview`, tracking
`anything_has_rated` in `UserDefaults`); also guards `ExpoStoreReview.native.js` so it only
`requireNativeModule`s when the module is actually present (else `{}`). NB: the patch file
also accidentally committed `.orig`/`.rej` artifacts.
- **`react-native-purchases+9.6.1.patch`** — patches `isExpoGo()` to return `false` when
`!__DEV__`, and to return `true` when `globalThis.expo.modules.AnythingLauncherModule`
exists — so RevenueCat detects the Anything launcher/Expo-Go correctly instead of trying to
hit native StoreKit where it can't.
- **`react-native-purchases-ui+9.6.1.patch`** — same `isExpoGo()` fix as above, applied
across the package's `commonjs`/`module`/`src` `environment` files (and adds the
`AnythingLauncherModule` type to the `globalThis.expo.modules` declaration).
- **`react-native-web-refresh-control+1.1.2.patch`** — replaces the deprecated/removed
`findNodeHandle(containerRef.current)` with `containerRef.current?.firstChild` to read
`scrollTop` in `RefreshControl.web.js`, fixing pull-to-refresh detection on web.
- **`sonner-native+0.21.0.patch`** — on web, moves the toast `Positioner`'s
`pointerEvents: 'box-none'` from a `View` prop into the `style` array (RNW expects
`pointerEvents` in style), so toasts don't block clicks behind them on web.
---
## 6. Other gotchas worth remembering
- **Web support is Metro module aliasing, not separate code** —
`apps/mobile/metro.config.js` swaps ~25 native modules for web shims in `polyfills/web/`
via `resolver.resolveRequest` keyed on `platform === 'web'` (e.g. `expo-secure-store`,
`react-native-webview`, `react-native-safe-area-context`, `expo-haptics`,
`react-native-maps`, several `react-native-web/dist/exports/*`). **Adding a native dep that
is imported on web requires adding a web alias here or the web build breaks.**
- **Dev-only native aliases stop Expo Go from black-screening** — `DEV_ONLY_NATIVE_ALIASES`
swaps `react-native-purchases` for a stub *outside production*. The inline comment explains
why: the real module's browser-mode shims pull in DOM-only code that throws on Hermes;
expo-router then silently swallows the load error and warns *"Route is missing the required
default export"*, leaving the app on a black/splash screen. EAS production builds keep the
real module. **Lesson:** a native module that throws at import time on Hermes manifests as a
confusing "missing default export" route error, not as the real error.
- **`anything-menu` is compiled out in production** — `metro.config.js` aliases
`./src/__create/anything-menu` to `polyfills/shared/empty-component.tsx` when
`EXPO_PUBLIC_CREATE_ENV === 'PRODUCTION'`. The in-app dev menu (`anything-menu.tsx`) talks
to a native `AnythingLauncherModule` and should never ship to end users.
- **Expo Google Fonts wildcard alias** — any `@expo-google-fonts/*` import (except
`@expo-google-fonts/dev`) is redirected to `@expo-google-fonts/dev` by the resolver, so the
template can reference arbitrary font families without bundling each one.
- **Custom Metro resolver swallows unresolved-module errors in dev** —
`apps/mobile/__create/handle-resolve-request-error.js` catches resolution failures and, in
dev only (not Android, not production), writes a deterministic "throwing" virtual module so
the error surfaces in-app at runtime instead of killing the bundler. It only rewrites the
virtual file when content changed, *to avoid a Metro rebuild loop from mtime bumps*. On
Android / production it rethrows.
- **Metro reporter forwards bundling errors to a remote logging endpoint** — both
`metro.config.js` (`reportErrorToRemote` on `error`/`bundling_error`/`cache_read_error`/
`hmr_client_error`/`transformer_load_failed`) and
`apps/mobile/__create/report-error-to-remote.js` post to `EXPO_PUBLIC_LOGS_ENDPOINT` with a
`Bearer EXPO_PUBLIC_CREATE_TEMP_API_KEY`. This is Create telemetry; the rebuild should not
carry the temp API key.
- **Metro cache is wiped on production builds** — `config.transformer.getTransformOptions`
deletes the `caches/` dir when `options.dev === false`. The repo also ships a populated
`apps/mobile/caches/.metro-cache/` (build artifacts — safe to drop in the clean).
- **Analytics/Sentry/TestFlight logging are all gated the same way** — `isActive()` =
`!__DEV__ && EXPO_PUBLIC_CREATE_ENV !== 'DEVELOPMENT'` (see
`apps/mobile/src/__create/analytics.ts`). The anonymous per-install visitor id lives in
**AsyncStorage**, not the Keychain — explicitly *"not a secret"*. Analytics calls are fully
try/caught so they can never crash or block the host app.
- **better-auth dual mode** — web uses cookie sessions; mobile uses
`Authorization: Bearer <jwt>` validated by better-auth's `bearer()` plugin
(`apps/mobile/src/utils/auth/getSession.ts`, `apps/web/src/lib/auth.ts`). The JWT comes
from `/api/auth/token` (native WebView) or an `AUTH_SUCCESS` postMessage from
`/api/auth/expo-web-success` (web iframe) — see `apps/mobile/src/utils/auth/AuthWebView.tsx`.
**The new `apps/api` already uses better-auth, so this dual-mode pattern is directly
relevant.**
- **Origin check normalises with `new URL(raw).origin`** — `AuthWebView.tsx` learned (the
hard way, per its comment) that a stray trailing slash in `EXPO_PUBLIC_PROXY_BASE_URL`
silently dropped *every* postMessage; it now compares normalised origins and `console.warn`s
on a drop instead of swallowing it. Good defensive pattern for any postMessage receiver.
- **RevenueCat offerings load with retries** — `apps/mobile/src/utils/iap/useInAppPurchase.ts`
retries `getOfferings()` 3× with a 1.5s delay because a freshly-configured RevenueCat client
often returns no `current` offering on the first call.
- **The `/api/logs` route was missing from the legacy `apps/web` export** — the mobile History
tab (`GET /api/logs`) and Stop & Save (`POST /api/logs`) 404'd against the local backend
(only `/api/export` existed). Documented in the repo `CLAUDE.md`; the rebuild's `apps/api`
owns this contract now.