291 lines
19 KiB
Markdown
291 lines
19 KiB
Markdown
# 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.
|