The app starts in one fixed order: the entry loads the polyfills and then the router; the root layout installs the error net, holds the native splash and decides the direction before anything opens; the app root opens storage once, connects the error log and hydrates the stores synchronously, mounts the providers in dependency order and releases the splash once the first screen is real. Buffered writes flush from one root hook when the app leaves the foreground. Optional kits (crash reporting, analytics, notifications, ...) plug into typed slots from one registry, so the root files stay canonical when a kit is added. One zero-dependency checker proves it.
Rules that must hold
- The entry imports the polyfills first and
expo-router/entrylast. Nothing follows the router import. Why: ES imports evaluate in order; a module after the router entry loads after the root component was registered. (entry-router-last) - The root layout's first imports are side effects: the error net installer, then the splash
hold. Every import with bindings comes after them. Why: a module that throws while loading must
find the net, and the hold must run before any other app module. (
root-import-order) - The native splash is held once, at module scope, in
hold-splash.ts. Why: Expo Router hides an unheld splash at "navigation ready", over a direction restart's empty screen or a blocking wait; called in a component or effect, the hold comes after native content appeared, when iOS and Android may already have hidden it. (splash-hold) - The splash is released once, in an effect, below the direction gate, when the startup gate is
ready (
useStartupReady(gate, hideSplash)in the app root). A startup component rendersnullonly while the splash is held. Why: a hold with no release keeps a release build on the splash forever; a release in render or above the gate uncovers a frame that is not the first screen. (splash-release,blank-frame) - The root layout's first render creates adapters, reads config and resolves the language, and
opens nothing;
DirectionGateis its outermost element (directly inside web-support'sWebStorageGatewhen the app ships the web); everything with effects lives in its childAppRoot. Why: a direction restart re-runs every module and initializer, so work done before the decision happens twice (rtl-and-direction'sdirection-before-renderenforces the gate). - The data layer opens once, synchronously, in the app root's startup initializer, then the error
net's sink connects, then the query client, settings and stores hydrate, then the optional kits are
created, in that order (
startApp, run asuseState(() => startApp(services, language))). Why: the first frame has the real rows with no loading state, the buffered errors of the first moments reach the log before any provider renders, and a second open runs migrations again. (storage-open-once,sink-before-providers) - The startup path awaits nothing. The root layout and the app root contain no
await,.then,.catchor.finally; async work the first screen truly needs is a non-interactive blocking task ofcreateStartupGatewith a literaldeadlineMsat or under 3000. A session restore follows auth-and-secure-storage: its read is async and non-interactive, so it is a blocking task under the deadline, and a miss starts the session as unknown or unavailable; a biometric unlock is never a gate task but a lock screen after the splash. Why: a promise that never settles keeps a held splash up forever, and a prompt that waits for the person outlasts any deadline; the gate always opens by its deadline and reports what it missed. (startup-await,startup-deadline) - Fonts are embedded with the expo-font config plugin, never loaded at runtime. Why:
useFontseither holds the first frame or swaps the font after it. (runtime-font-load) - Providers nest in one dependency order and each is mounted once on the root path: services (or
AppProviders), data, query, settings store, stores, preferences (i18n, theme, text locale), root stack (gesture root, keyboard,RootMotion(the one MotionConfig), navigation theme, session, feedback, ads, games, thenAppStack, whoseRootKitsslot wraps theStackin the kits' providers). Why: each provider reads only those outside it, a misplaced one throws only when its path renders on a device, and a second copy silently overrides the first. (provider-order,provider-once) - Deferred work starts after the splash release, in the component or service that owns it; never
through
InteractionManager. Why: consent, entitlements, OTA checks and prefetches must not delay the first frame, andInteractionManageris a deprecated stub in React Native 0.86 that runs on the next tick. (interaction-manager) - Buffered writes flush from one root hook when the app leaves the foreground:
useFlushOnBackground(flushers, errorLog)once, in the app root, beforeuseCheckpointOnBackground; features hand it aFlusherand never write from their ownAppStatelistener. Why: an iOS swipe-kill from the app switcher sendsinactiveand nobackground, and listeners run in subscription order, so the drafts must land before the WAL is folded. (flush-root,lifecycle-write) - Optional kits join through the slots, never through the root files. A kit returns a
RootKit(forward,blocking,flushers,motion,opensDebugRoutes,provide,effects; every part optional) from the app's one registry,src/services/create-root-kits.ts;startAppfirst calls the registry'sprepareRootKits(the ports every reader uses: a test build's simulated clock and network), thencreateRootKitsonce, after the stores, attaches the kits to the error sink (which replays the reports it kept) and hands their tasks to the gate and their flushers to the flush;RootMotionrenders the first kit's motion slot inMotionConfig's place;AppStackopens its(debug)group only while a kit says so and renders<RootKits kits>around theStack; a kit that needs Metro lists aMetroKitinscripts/metro/metro-kits.ts, which the canonicalmetro.config.jscomposes. A slot no kit fills does nothing, and a test build's kits are reached only through the test-only gate. Why: the root skeleton stays the canonical file every skill cites, a kit is added or removed in one line, and every kit's work still runs at its place in the startup order (E1, E2, E6, E7, F5, F6, I1). (root-kits) - Prove the launch on a release build, on both platforms. Expo Go shows the app icon instead of the splash, and a development build does not apply every splash config property (Expo's SDK 57 splash-screen docs); only a release build shows the hold, the release and a direction restart as people see them.
Workflow
- Read the order: references/startup-order.md (section 1 is the table of every step, its file and its owner skill). Find where the change belongs before writing it.
- New app or no root yet: copy the templates (line 1 names each repo path):
templates/src/app/_layout.tsx,templates/src/features/root-navigator/(app-root.tsx,root-stack.tsx,app-stack.tsx,hold-splash.ts,startup-gate.ts,use-startup-ready.ts,background-flush.ts,use-flush-on-background.ts,root-kits.tsx, the helpers each with its test),templates/src/services/root-kits/root-kit.ts(+ test, the slot types and helpers),templates/src/services/create-root-kits.ts(the app's registry, empty) andtemplates/contracts/startup.json. These files are the canonical root skeleton that other skills cite; each of them owns one piece inside it. They expect the composition root (architecture-and-boundaries), the error net (error-handling-typed-results),DirectionGate(rtl-and-direction), the data layer (persistence-and-migrations),watchSystemA11yandMotionConfig(accessibility-as-code),useScreenAnimation(motion-and-animation) and the navigation theme (design-system-structure); set up a missing one with its skill first. Route groups andStack.ProtectedinsideAppStackare navigation-and-routing's. - Adding a piece of startup. An optional kit from another skill (crash reporting, analytics,
push or local notifications): one entry in
src/services/create-root-kits.ts, as that skill's example shows; the root files stay unchanged (read references/startup-order.md section 15, the slot contract). An app piece (a store, a client, the session): its creation instartAppat its row of the order table, its provider at its place in the tree, and its name inappRootOrderincontracts/startup.jsonif it is new; pass what a kit reads (the settings, the session) on tocreateRootKits. Imitate examples/app-root-with-session.tsx. - Work that is async: decide with references/startup-gate-and-deferred-work.md
(section 1) whether it blocks the first screen. Deferred is the default; a blocking task returns a
Result, goes into
createStartupGate({ blocking })and gets a test that drives it resolving, failing and hanging. - Splash problems (hangs, flashes, empty frame): references/splash-screen.md (sections 3-4 and the pitfalls table). The splash art itself is icons-app-icon-and-splash's.
- A buffered writer (debounced draft, batch queue): read references/lifecycle-flush.md,
imitate examples/task-draft-buffer.ts, return its flusher from
startApp. - Test:
npx jest src/features/root-navigator --ci --maxWorkers=2, thennpm run typecheckandnpx eslint . --max-warnings 0. - Run the checker from the repo root:
node ${CLAUDE_SKILL_DIR}/scripts/check-startup.mjs .; look up a FAIL line's rule in references/checker-rules.md; fix and rerun untilRESULT: PASS. - On devices (by hand, once per release candidate, a step for the developer): cold start a release build on a named simulator and a named emulator, once per layout direction of the app's languages: the frame after the splash is the first screen; type a draft, swipe the app away, relaunch: the draft is there.
Definition of done
-
index.tsimports the polyfills first andexpo-router/entrylast (ormainisexpo-router/entry). -
src/app/_layout.tsximports the error net installer, thenhold-splash; rendersDirectionGateoutermost (insideWebStorageGatewhen the app ships the web) andAppRootinside it; opens nothing. -
AppRootrunsstartApponce in auseStateinitializer: the registry's prepared ports, open, sink, client, settings, stores, kits, gate; noawaitor promise chain;ServicesProvidergets the prepared services. - The splash is held once at module scope and released once through
useStartupReady; every blocking task returns a Result and the gate's deadline is at most 3000 ms. - Providers follow
appRootOrder/rootStackOrder, each mounted once; fonts are embedded; noInteractionManager. - Buffered writers hand a
Flusherto the oneuseFlushOnBackground, called beforeuseCheckpointOnBackground. - Optional kits are entries of
createRootKits, created once instartAppafter the stores and handed to every slot (errors.attach,kitBlocking,kitFlushers,RootMotion,AppStack's(debug)guard,<RootKits kits>);prepareRootKitsruns before the data layer opens; no root file imports a kit; Metro kits live inscripts/metro/metro-kits.ts. -
npx jest src/features/root-navigator --ci --maxWorkers=2,npm run typecheckandnpx eslint . --max-warnings 0pass. - The release-build launch of step 9 was done on iOS and Android, or listed for the developer.
-
node ${CLAUDE_SKILL_DIR}/scripts/check-startup.mjs .printsRESULT: PASS(no SKIP lines are expected: every file it reads is this skill's).
Anti-patterns
await openDatabaseAsync()in the root layout behind a loading spinner - a second startup path with nothing on screen. Open synchronously instartApp.SplashScreen.preventAutoHideAsync()insideuseEffect- by then the splash may be gone. Hold it at module scope inhold-splash.ts.hide()in the root layout's effect - it also runs in the JS run that is about to restart for the direction. Release in the app root.- A hold and no release "because Expo Router hides it" - the router leaves a held splash alone.
useFontsin the root layout withif (!loaded) return null- an empty frame or a font swap. Embed the fonts.InteractionManager.runAfterInteractions(prefetch)- runs on the next tick, mid-startup. Start it from the owner after the release.appErrorNet.connectinuseEffect- errors of the first render wait or are lost. Connect instartApp.- A screen's own
AppStatelistener that saves its draft - gone when the screen unmounts, and unordered against the checkpoint. Hand aFlusherto the root. - Flushing only on
background- an iOS swipe-kill from the switcher never sends it. - A
MotionConfiginPreferencesRootbeside the root stack'sRootMotion, or a secondI18nProvider- the inner one overrides the person's setting (and two MotionConfigs fight over Reanimated's global mode).PreferencesRootprovidesReduceMotionPreferenceContextinstead. <StrictMode>aroundAppRoot- React runs the initializer twice in development: two database opens. Put it aroundRootStackif wanted.- A blocking task without a deadline, or a deadline from a variable - a splash that can wait forever. A literal or module constant up to 3000 ms.
- A biometric read (
requireAuthentication) as a startup-gate task - the deadline expires under the Face ID or BiometricPrompt sheet and the answer arrives after the app opened. Show a lock screen after the splash release. reloadAppAsyncin a module body - crashed a Release build while the bundle was still evaluating. Restart from a mounted component's effect.- A kit wired into
app-root.tsx,app-stack.tsxormetro.config.js(a Sentry sink instartApp, a tap hook inAppStack,getSentryExpoConfigin Metro) - forks the canonical skeleton every skill cites. Register the kit increateRootKits; list a Metro kit inmetro-kits.ts.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/startup-order.md | The canonical order step by step (entry, imports, first render, gate, startApp, hooks, providers, splash, deferred, flush), owners, reloads, Strict Mode, React Navigation and multi-package notes | Workflow steps 1-3 |
| references/splash-screen.md | Verified splash behaviour of expo-splash-screen and Expo Router on iOS and Android, hold and release, crashes, Expo Go, pitfalls, tests | Workflow step 5 |
| references/startup-gate-and-deferred-work.md | Synchronous vs blocking vs deferred work, the gate, the deadline, where deferred work starts, fonts, InteractionManager, cold start hand-off | Workflow step 4 |
| references/lifecycle-flush.md | AppState sequences per platform, what must flush, one root hook, flush before checkpoint, synchronous flushers, tests | Workflow step 6 |
| references/checker-rules.md | Every check-startup rule, the contract keys, SKIP and NOT APPLICABLE, limits | Workflow step 8, on any FAIL line |
templates/src/app/_layout.tsx |
The root layout: net, hold, ErrorBoundary, adapters and language, DirectionGate around AppRoot |
Workflow step 2 |
templates/src/features/root-navigator/app-root.tsx |
The app root: startApp, lifecycle hooks, splash release, providers |
Workflow steps 2-3 |
templates/src/features/root-navigator/root-stack.tsx |
The root stack: gesture root, KeyboardProvider, RootMotion, navigation theme around AppStack |
Workflow steps 2-3 |
templates/src/features/root-navigator/root-motion.tsx |
RootMotion: the one MotionConfig with the preference above it, or the first kit's motion slot |
Workflow steps 2-3 |
templates/src/features/root-navigator/root-motion.test.tsx |
Its tests: "system" by default, the provided preference, the first kit's motion slot | Workflow step 2 |
templates/src/features/root-navigator/app-stack.tsx |
The root Stack with useScreenAnimation(), the test-build (debug) group behind Stack.Protected (opensDebugRoutes), inside the RootKits slot; route groups go inside it |
Workflow step 2 |
templates/src/features/root-navigator/root-kits.tsx |
RootKits: every kit's provider around the Stack and its effects beside it |
Workflow steps 2-3 |
templates/src/features/root-navigator/root-kits.test.tsx |
Its tests: no kits, provider nesting in registry order, effects mounted once | Workflow step 2 |
templates/src/services/root-kits/root-kit.ts |
The slot contract: RootKit, createKitErrorSink (records, then forwards; replays the first moments), kitBlocking, kitFlushers, kitMotion, kitsOpenDebugRoutes |
Workflow steps 2-3 |
templates/src/services/root-kits/root-kit.test.ts |
Its tests: records always, replay, limit, attach once, a throwing kit, shared names | Workflow step 2 |
templates/src/services/create-root-kits.ts |
The app's one kit registry: prepareRootKits (returns the services unchanged) and createRootKits (empty); part of the composition root |
Workflow steps 2-3 |
templates/src/features/root-navigator/hold-splash.ts |
The module-scope splash hold | Workflow step 2 |
templates/src/features/root-navigator/hold-splash.test.ts |
Its test: once per registry, rejection to the net | Workflow step 2 |
templates/src/features/root-navigator/startup-gate.ts |
createStartupGate: blocking tasks under one deadline, never rejects |
Workflow steps 2 and 4 |
templates/src/features/root-navigator/startup-gate.test.ts |
Its tests: no task, all tasks, failure, hang, throw, start twice, unsubscribe, a throwing listener | Workflow step 4 |
templates/src/features/root-navigator/use-startup-ready.ts |
Reads the gate and releases the splash after the commit | Workflow step 2 |
templates/src/features/root-navigator/use-startup-ready.test.ts |
Its tests with a real gate | Workflow step 2 |
templates/src/features/root-navigator/background-flush.ts |
Flusher, isLeavingForeground, runFlushers |
Workflow step 6 |
templates/src/features/root-navigator/background-flush.test.ts |
The transition table and the throwing flusher | Workflow step 6 |
templates/src/features/root-navigator/use-flush-on-background.ts |
The one root flush hook | Workflow step 6 |
templates/src/features/root-navigator/use-flush-on-background.test.ts |
iOS and Android sequences, unmount | Workflow step 6 |
templates/contracts/startup.json |
The app's startup facts for the checker (defaults filled in) | Workflow steps 2-3 |
templates/contracts/startup.schema.json |
Its JSON schema | When editing the contract |
| examples/app-root-with-session.tsx | Shape: the app root with the session restore (auth-and-secure-storage) as its one blocking task, settings, stores, query and a draft buffer | Workflow step 3 |
| examples/task-draft-buffer.ts | Shape: a debounced writer that hands a Flusher to the root |
Workflow step 6 |
scripts/check-startup.mjs |
The checker (16 rules) | Workflow step 8, definition of done |
scripts/selftest.mjs |
Proves the checker on the fixtures | After changing the checker |
scripts/check-lib.mjs |
Shared checker library (synced; do not edit here) | Never by hand |
scripts/lib/source-scan.mjs |
Shared source lexer and import resolver (synced; do not edit here) | Never by hand |
scripts/lib/startup-scan.mjs |
Scope, function and JSX-span analysis for the checker | When changing the checker |
assets/shared.json |
Which shared files this skill syncs | Maintainers |
tests/ |
bases/app/ (stand-ins for the error net and the entry) and fixtures/check-startup/ (good, bad-, pass-, error-* cases on the templates) |
After changing the checker or a template |
Related skills
navigation-and-routing- the routes, route groups, guards andStackoptions insideAppStack.rtl-and-direction-DirectionGateand the direction restart this order is built around.error-handling-typed-results- the error net and the rootErrorBoundary.persistence-and-migrations- whatopenDeviceDataopens, migrations and the WAL checkpoint.architecture-and-boundaries- the composition root and "nothing at import time".settings-and-preferences,state-management-zustand,networking-and-server-state- the stores,PreferencesRootandQueryProviderthat joinstartApp.i18n-strings-and-catalogs- the Intl polyfills and language resolution.icons-app-icon-and-splash- the splash image and colours.performance-budgets- measuring cold start.ota-updates,admob-ads-and-consent,in-app-purchases- deferred startup work.deep-links-and-universal-links,push-notifications,local-notifications- the incoming-link gate and the tap hooks that run below the navigator (startup-order step F6; the notification kits plug into the slots).crash-reporting-and-observability,analytics-and-consent- root kits: error forwarding, consent after the splash, screen views, the Metro kit.app-variants-and-environment- the canonicalmetro.config.jsthat composes the Metro slot.