19 KiB
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 returnsnullonly 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
fontErroris truthy it falls back tofontFamily: undefined(system font) instead of the named Inter family:const regular = fontError ? undefined : 'Inter_400Regular'. - Lesson for rebuild: never block the whole tree on
fontsLoadedalone; always treatfontErroras "render with system font". A hard wait on fonts is the classic way to get a permanently blank/frozen screen on Android.
- Uses
Splash screen has a hard timeout so a stuck session check can't freeze launch
apps/mobile/src/app/_layout.tsxSplashScreen.preventAutoHideAsync()is called, then the root is gated onuseAuth().initiate()+isReady. To stop a hung session restore from freezing on the splash forever, there is aSPLASH_TIMEOUT_MS = 10_000fallback: after 10s it setstimedOutand hides the splash / renders regardless ofisReady.- 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 replacesreact-native/Libraries/Core/ExceptionsManager.handleExceptionwith a no-op, and callsLogBox.ignoreAllLogs()+LogBox.uninstall()(also gated onEXPO_PUBLIC_CREATE_ENV === 'DEVELOPMENT'). It also installs an emptyAppRegistry.setWrapperComponentProvider. This keeps the Create in-builder preview from getting stuck behind RN's error overlay.
- In
New Architecture is on
apps/mobile/app.json:"newArchEnabled": true. iOS usesexpo-build-propertieswithuseFrameworks: "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 readsNSFaceIDUsageDescriptionor builds a biometry access-control object. Both can throw anNSExceptionand trip iOS 26's unhandled async-void TurboModule rethrow, crashing the app.
Other token-storage lessons:
- Keychain writes are wrapped in
.catch(() => {})(insetAuth). 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-storeis aliased to alocalStorageshim 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, andcanUseBiometricAuthentication()returnsfalse. 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:
- Bails out unchanged if
EXPO_PUBLIC_BASE_URL(first-party) orEXPO_PUBLIC_PROXY_BASE_URL(second-party) is unset — falls back toexpo/fetch. - Extracts the URL from whatever form the call used — string,
Request, orURL(via agetURLFromArgshelper, because theURLtype isn't always in the fetch TS signature). - Passes through static/file URLs untouched via the original native
fetch(notexpo/fetch):file://,data:, and static assets matched by extension (.wasm/.png/.jpg/.svg/.woff2/.ttf/...). These must not get auth headers or rewriting. - Leaves external (third-party) URLs alone — headers/JWT are only ever added to
first-party requests. "First-party" = URL starts with
/or withEXPO_PUBLIC_BASE_URL. - 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 toexpo/fetch. - Injects Create routing headers on first-party calls:
x-createxyz-project-group-id(=EXPO_PUBLIC_PROJECT_GROUP_ID), plushost,x-forwarded-host, andx-createxyz-host(all =EXPO_PUBLIC_HOST). Only set when the env value is truthy. - Attaches auth: reads the JWT from SecureStore (key
<PROJECT_GROUP_ID>-jwt, JSON-parsed), and if present setsAuthorization: Bearer <auth.jwt>. Read errors are swallowed (→ null), so the request still goes out unauthenticated. - 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.jsresolves these):- Native:
index.tsx→entrypoint.ts→App.tsx(App.tsxjust wrapsexpo-router's qualified-entryAppwith aScreenViewTracker). - Web:
index.web.tsx→App.web.tsx.
- Native:
apps/mobile/App.web.tsx— the iframe/sandbox contract:- Healthcheck handshake (
useHandshakeParent): replies tosandbox:mobile:healthcheckwithsandbox: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 androuter.pushes it; postssandbox:mobile:navigation {pathname}back on everyusePathname()change; postssandbox:mobile:readyonce on mount. - Error forwarding (
GlobalErrorReporter+postErrorToParent): globalerrorandunhandledrejectionlisteners postsandbox:error:detected {message,name,stack}to the parent — but filter out runtime/network errors (a bigRUNTIME_ERROR_PATTERNSregex 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 initialMetricshard-codes{ top: 64, bottom: 34, ... }and frame size fromwindow.innerWidth/Height, so the web preview mimics a phone notch.
- Healthcheck handshake (
apps/mobile/src/__create/ErrorBoundary.tsx— a React error boundary that also postssandbox:error:detectedoncomponentDidCatchandsandbox:error:resolvedwhen the user taps "Try again". AllpostMessagecalls are guarded bywindow.parent !== window.apps/mobile/index.web.tsx— screenshot + Skia + font plumbing:- Responds to
sandbox:web:screenshot:requestby rasterising#rootwithhtml-to-imagetoPngand posting backsandbox:web:screenshot:response {dataUrl}(or...:error). - Before screenshotting it inlines Google Fonts (
inlineGoogleFonts: fetches eachfonts.googleapis.comstylesheet, absolutisesurl(...)refs, injects a<style>, then awaitsdocument.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.
- Responds to
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 + rootresolutions/overrides. Bumping any of these packages discards the patch (per projectCLAUDE.md). One line each:
react-native+0.81.4.patch— re-adds a workingSlidergetter to RN'sindex.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 manifestidwithcrypto.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:LogBoxInspectorContainerandErrorToastbothreturn nullimmediately, so RN's red-box error UI never shows inside the Create preview.@react-native-community+netinfo+11.4.1.patch— makesnativeInterface.tstolerate a missing native module: removes the hardthrowwhenRNCNetInfois 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 webnative-tabsCSS into a floating, blurred bottom tab bar + adds an Apple-Settings-style "More" overflow screen when there are >5 tabs (incl. extractingwebIcon*/webLabel*options for web rendering). (b) Adds anisAnythingAppbranch (iOS + not Expo Go) that forcesgetInitialURLto/and skips theLinking'url' subscription, so the Anything native shell always boots at root instead of via deep link.expo-store-review+9.0.8.patch— addsprePromptReview/resetReviewState/hasUserRatedto the JS API and a native iOSStoreReviewModule(a custom "Thanks for using Anything!" pre-prompt alert that gates the realAppStore.requestReview, trackinganything_has_ratedinUserDefaults); also guardsExpoStoreReview.native.jsso it onlyrequireNativeModules when the module is actually present (else{}). NB: the patch file also accidentally committed.orig/.rejartifacts.react-native-purchases+9.6.1.patch— patchesisExpoGo()to returnfalsewhen!__DEV__, and to returntruewhenglobalThis.expo.modules.AnythingLauncherModuleexists — 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— sameisExpoGo()fix as above, applied across the package'scommonjs/module/srcenvironmentfiles (and adds theAnythingLauncherModuletype to theglobalThis.expo.modulesdeclaration).react-native-web-refresh-control+1.1.2.patch— replaces the deprecated/removedfindNodeHandle(containerRef.current)withcontainerRef.current?.firstChildto readscrollTopinRefreshControl.web.js, fixing pull-to-refresh detection on web.sonner-native+0.21.0.patch— on web, moves the toastPositioner'spointerEvents: 'box-none'from aViewprop into thestylearray (RNW expectspointerEventsin 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.jsswaps ~25 native modules for web shims inpolyfills/web/viaresolver.resolveRequestkeyed onplatform === 'web'(e.g.expo-secure-store,react-native-webview,react-native-safe-area-context,expo-haptics,react-native-maps, severalreact-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_ALIASESswapsreact-native-purchasesfor 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-menuis compiled out in production —metro.config.jsaliases./src/__create/anything-menutopolyfills/shared/empty-component.tsxwhenEXPO_PUBLIC_CREATE_ENV === 'PRODUCTION'. The in-app dev menu (anything-menu.tsx) talks to a nativeAnythingLauncherModuleand 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/devby 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.jscatches 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(reportErrorToRemoteonerror/bundling_error/cache_read_error/hmr_client_error/transformer_load_failed) andapps/mobile/__create/report-error-to-remote.jspost toEXPO_PUBLIC_LOGS_ENDPOINTwith aBearer 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.getTransformOptionsdeletes thecaches/dir whenoptions.dev === false. The repo also ships a populatedapps/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'(seeapps/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'sbearer()plugin (apps/mobile/src/utils/auth/getSession.ts,apps/web/src/lib/auth.ts). The JWT comes from/api/auth/token(native WebView) or anAUTH_SUCCESSpostMessage from/api/auth/expo-web-success(web iframe) — seeapps/mobile/src/utils/auth/AuthWebView.tsx. The newapps/apialready uses better-auth, so this dual-mode pattern is directly relevant. - Origin check normalises with
new URL(raw).origin—AuthWebView.tsxlearned (the hard way, per its comment) that a stray trailing slash inEXPO_PUBLIC_PROXY_BASE_URLsilently dropped every postMessage; it now compares normalised origins andconsole.warns 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.tsretriesgetOfferings()3× with a 1.5s delay because a freshly-configured RevenueCat client often returns nocurrentoffering on the first call. - The
/api/logsroute was missing from the legacyapps/webexport — the mobile History tab (GET /api/logs) and Stop & Save (POST /api/logs) 404'd against the local backend (only/api/exportexisted). Documented in the repoCLAUDE.md; the rebuild'sapps/apiowns this contract now.