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

19 KiB
Raw Permalink Blame History

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 Keychainexpo-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.parseBearer <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.tsxentrypoint.tsApp.tsx (App.tsx just wraps expo-router's qualified-entry App with a ScreenViewTracker).
    • Web: index.web.tsxApp.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.pushes 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 requireNativeModules 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 codeapps/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-screeningDEV_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 productionmetro.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 devapps/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 buildsconfig.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 wayisActive() = !__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).originAuthWebView.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.warns on a drop instead of swallowing it. Good defensive pattern for any postMessage receiver.
  • RevenueCat offerings load with retriesapps/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.