docs: extract port-worthy reference from legacy code before full cleanup

This commit is contained in:
Bas van Rossem
2026-06-17 14:35:00 +02:00
parent 3f2c5f0179
commit c72086550d
3 changed files with 1384 additions and 0 deletions

View File

@@ -0,0 +1,290 @@
# 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.