Skip to content

Eagerly prewarm the Metro bundle during platform init#149

Merged
V3RON merged 13 commits into
mainfrom
feat/eager-prewarm
Jul 11, 2026
Merged

Eagerly prewarm the Metro bundle during platform init#149
V3RON merged 13 commits into
mainfrom
feat/eager-prewarm

Conversation

@V3RON

@V3RON V3RON commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

What is this?

This PR removes the biggest serialized cost in cold-start test runs: the first Metro bundle build (30–120 s) used to start only after the emulator or simulator had finished booting, because metro.prewarm() was invoked exclusively inside waitForMetroBackedAppReady. The bundle now starts building the moment the Metro server is up, so it compiles concurrently with platform boot (which takes minutes on cold CI) instead of after it.

Along the way it replaces the sawPrewarmRequest bookkeeping — whose event-listener path had become dead code — with prewarm state tracked directly on the MetroInstance, and prepares the startup ready-timer for the upcoming change that will stop blocking app launch on prewarm completion.

How does it work?

The change lands in three self-contained commits:

  1. refactor(bundler-metro): prewarm state on the instance. The only thing that ever issues a prewarm-kind bundle request is the harness's own memoized fetch, and it completed before any observeBundleRequest listener attached — so the requestKind === 'prewarm' branch and the cross-attempt sawPrewarmRequest threading re-derived a value that was already in scope. The factory now tracks the lifecycle next to the memoized promise and exposes getPrewarmState(): 'idle' | 'pending' | 'succeeded' | 'failed'. StartupStallError carries this richer prewarmState instead of a boolean, with distinct message suffixes: prewarm succeeded but the app never fetched (device-side problem), still building (host CPU / cold cache), or failed to build (the bundle itself is broken). The requestKind === 'app' filter stays — it prevents the harness's own fetch from being mistaken for the app's bundle request.

  2. feat(jest): eager prewarm. Inside the existing Promise.all that runs Metro init and platform init concurrently, the session now fires instance.prewarm(...) as soon as the Metro instance resolves — without awaiting it, so metro.init resolution timing is unchanged. The later await metro.prewarm(...) in waitForMetroBackedAppReady joins the memoized in-flight (or settled) promise, so downstream retry/observer semantics are untouched. The prewarm runs under its own metro.prewarm diagnostics span, ended when the promise settles, so the Chrome trace shows it overlapping platform.init. Because prewarm memoizes its first call, the eager call deliberately binds the session-lifetime setup signal; teardown aborts are swallowed so they can't become unhandled rejections. A new eagerPrewarm config option (default true, documented on the website) provides an escape hatch for constrained runners where emulator boot and bundling might contend for CPU.

  3. feat(bundler-metro): in-flight build tracking. The instance counts bundle_build_started/done/failed reporter events and exposes isBuildInFlight(). waitForReadyAfterBundleRequest accepts an initialBundlingInProgress seed fed from it, keeping the ready timer paused for builds that started before the wait began. This is inert today (prewarm is awaited before launch, so the seed is always false), but it is the prerequisite for removing that await: once app launch overlaps bundling, the app's bundle request coalesces into the still-in-flight prewarm build and Metro emits no fresh bundle_build_started — without the seed, the ready timer would fire spuriously mid-build.

Why is this useful?

  • Cold-start wall clock drops by up to the full first-bundle time. On CI, emulator boot (minutes) now hides the 30–120 s initial bundle build almost entirely instead of paying for it afterwards.
  • Startup stall errors become actionable. prewarmState distinguishes device-side connectivity problems from slow or broken bundle builds, which the old boolean could not.
  • Less machinery, same behavior. The dead prewarm-event branch, the cross-attempt threading, and the BundleRequestTimeoutError payload are gone; prewarm state has a single source of truth on the instance.
  • The next optimization is a small diff. Removing the pre-launch await metro.prewarm(...) (overlapping app launch with the bundling tail) now only requires dropping the await — the ready-timer coalescing hazard is already handled.
  • Observable by construction. The dedicated metro.prewarm span makes the overlap (or contention, on small runners) directly visible in the diagnostics trace, so the eagerPrewarm default can be validated with real CI data.

V3RON added 10 commits July 10, 2026 21:26
Adds createDiagnostics()/instrumented() in packages/tools/src/diagnostics.ts:
spans (start/end/fail), measure(), mark(), a low-level record() for
recording already-completed durations (needed later for device-clock
events), child() namespacing/attr inheritance, and a zero-overhead
disabled mode. instrumented() wraps an object's methods in a Proxy that
times sync/async calls, caching wrapped functions per property so
method-reference identity (add/removeListener) is preserved.

Deviation: packages/tools had no vite.config.ts, so `tools:test` had no
nx target despite existing __tests__. Added vite.config.ts (mirrors
packages/platform-android's) plus ignoredDependencies for vite/vitest
in eslint.config.mjs so @nx/dependency-checks passes.
Adds an optional `diagnostics` boolean to ConfigSchema (default false),
following the same pattern as disableViewFlattening/forwardClientLogs,
and exports isDiagnosticsEnabled(config, env?) which resolves the
gating rule: enabled when config.diagnostics === true OR
RN_HARNESS_DIAGNOSTICS is set to anything other than '', '0', 'false'.
Consumed by harness-session.ts in a later commit.

Also updates existing Config test fixtures in packages/cli and
packages/jest that construct full Config literals, adding the new
required field so they keep typechecking.

Deviation: packages/config has no existing test suite or vite.config.ts
(unlike packages/tools), so no config:test target exists to run;
typecheck and lint both pass for config, cli, and jest.
Adds packages/jest/src/diagnostics/derivers.ts, the single place that
maps existing domain event streams to diagnostics spans/marks:

- observeMetroEvents(): correlates bundle_build_started/done/failed by
  buildID into a `metro.bundle.build` span (attrs: buildID, platform,
  entryFile, dev), and records bundle_request_observed as a
  `metro.bundle.request` mark (attrs: requestKind, platform).
- observeBridgeEvents(): marks `bridge.connected`/`bridge.disconnected`,
  and records completed spans (via diagnostics.record(), since the
  device clock isn't synced with the host) for module/setup-file
  bundling, collection, suite, and test finished/failed events per the
  label taxonomy (client.bundle.module, client.setup-file,
  client.collect, client.suite, client.test).

Both derivers no-op and skip subscribing entirely when diagnostics is
disabled, and their dispose() unsubscribes every listener they added.
Not wired into harness-session.ts yet - that's the orchestration commit.
Wires a root Diagnostics instance into createHarnessSession, gated via
isDiagnosticsEnabled(harnessConfig) (config option or env var). As
orchestrator code, this commit adds explicit spans directly rather than
deriving them, per the design's three-mechanism precedence:

- session.config.load / session.setup: recorded via diagnostics.record()
  since a Diagnostics instance doesn't exist until after config load,
  anchored at a Date.now() captured at function entry.
- session.lock.wait (attr lockKey): wraps lockManager.acquire.
- session.port.resolve (attrs port, didFallback): start()/end() around
  resolveHarnessMetroPort, since the attrs come from the result.
- session.bridge.init, metro.init, platform.init (attrs runner,
  platformId): wrap the existing Promise.all legs in place without
  restructuring it.
- app.session.create: wraps platformInstance.createAppSession inside
  restartAppSession.
- app.ready.total (attrs file, reused) and app.restart (attr reason):
  wrap ensureAppReady/restartApp bodies.
- dispose.total (attr reason): wraps disposeOnce's body.
- plugin.hook (attr hook): wraps the callHook choke point on the
  returned session object.

Also wires observeMetroEvents/observeBridgeEvents where the existing
bridge/Metro listeners are registered, storing their dispose fns and
calling them in disposeOnce alongside the existing listener removals.

HarnessPlatformInitOptions (packages/platforms) gains an optional
`diagnostics` field so platform runners can instrument their subsystems
in a later commit; platforms now depends on @react-native-harness/tools
for the Diagnostics type (tsconfig project references updated via
`nx sync`). HarnessSession now exposes `diagnostics` for execute-run.ts
and downstream reporting.

Updated Config test fixtures in packages/jest to satisfy the new
required HarnessSession.diagnostics field.
Adds run.total (attrs runId, status) and run.file (attrs file, status)
spans in execute-run.ts, using session.diagnostics.child({ runId }) so
every run-level entry carries runId. run.total is started right after
runId is generated and ended in the existing finally block, after
run:finished fires, with the same status computed for the hook payload.

run.file wraps the existing per-file try/catch (both the
platform-skip branch and the normal execution branch) in a
try/finally so the span always ends with the resulting status
(passed/failed/skipped/todo), regardless of which exit path is taken -
no behavioral changes to the existing control flow, only the
added span bookkeeping.
Wraps the adb/simctl/devicectl subsystems with instrumented() at the
platform factory composition roots, with zero changes to the command
implementations themselves:

- packages/platform-android/src/instance.ts: renames the module
  namespace import to adbModule and creates a local `adb =
  instrumented(adbModule, 'platform.android.adb', init.diagnostics ??
  noopDiagnostics)` at the top of both getAndroidEmulatorPlatformInstance
  and getAndroidPhysicalDevicePlatformInstance, so every adb.* call made
  directly in those factory bodies (install/uninstall/start/stop app,
  getAppPid, logcat, dropbox/exit-info reads) is timed. Calls made from
  module-level helper functions used during emulator boot/AVD management
  (configureAndroidRuntime, startAndWaitForBoot, recreateAvd,
  prepareCachedAvd) intentionally keep using the unwrapped adbModule -
  threading diagnostics through those helpers would have meant changing
  every one of their signatures for comparatively low-value (mostly
  once-per-session boot/setup) spans.
- packages/platform-ios/src/instance.ts: same pattern for
  getAppleSimulatorPlatformInstance (platform.ios.simctl) and
  getApplePhysicalDevicePlatformInstance (platform.ios.devicectl). Here
  every simctl/devicectl call already lives directly in the factory
  body, so coverage is complete with no helper-function gap.

Both packages already depended on @react-native-harness/tools; no new
dependency needed. instrumented() is a no-op when diagnostics is
disabled (the default noopDiagnostics), so existing instance tests -
which mock the adb/simctl/devicectl modules directly - pass unchanged
(verified via `vitest run` for platform-ios, which has no test target
wired into nx like platform-android does).
Adds packages/jest/src/diagnostics/report.ts:

- writeTraceFile(entries, { projectRoot, runId }): converts flushed
  DiagnosticEntry[] into Chrome Trace Event format (spans as ph:'X'
  with ts/dur in microseconds, marks as ph:'i' with s:'g') and writes
  it to .harness/diagnostics/<runId>.json (mkdir -p'd via
  getHarnessRootPath from tools).
- printSummary(entries, log): groups entries by top-level namespace,
  one line per label with count/total/max, sorted by total desc within
  each group, using human-readable duration formatting (ms below 1s,
  s above).

Wired into executeRun's existing finally block, right after
run:finished and the run.total span end, guarded by
session.diagnostics.enabled: flushes the root diagnostics, prints the
summary and writes the trace file via the jest package's own logger
(scoped 'diagnostics'), logging the trace path. Report failures are
caught and logged as a warning rather than failing the run.

Tests mock packages/jest/src/diagnostics/index.ts in
execute-run.test.ts so the diagnostics describe block can assert on
what would have been reported without depending on flush() ordering
or touching the real filesystem; report.test.ts covers the trace
format conversion and summary formatting/grouping directly.
…ro.getPrewarmState()

The requestKind === 'prewarm' branch in observeBundleRequest was dead
code: prewarm is memoized, fully awaited before the observer is ever
created, and only the harness's own fetch ever sends a prewarm-kind
request. Track prewarm lifecycle directly on the MetroInstance instead
of re-deriving it from bundle-request events, and thread it through
StartupStallError as a richer prewarmState ('idle' | 'pending' |
'succeeded' | 'failed') instead of a boolean.
Fire metro.prewarm() as soon as the Metro instance is up, without
awaiting it, so the first bundle build (30-120s) overlaps emulator or
simulator boot instead of being serialized after it. The await in
waitForMetroBackedAppReady stays and joins the memoized in-flight
promise. Controlled by a new eagerPrewarm config option (default true),
and traced as its own metro.prewarm diagnostics span so it shows up
overlapping platform.init.
Maintain an in-flight build counter on the MetroInstance from Metro's
bundle_build_started/done/failed reporter events and expose it as
isBuildInFlight(). waitForReadyAfterBundleRequest now accepts an
initialBundlingInProgress seed, fed from isBuildInFlight() at the call
site, so a build that started before the ready wait keeps the ready
timer paused until it settles. Inert today (prewarm is awaited before
app launch, so the seed is always false); prepares for removing that
await, where the app's bundle request can coalesce into the
still-in-flight prewarm build without a fresh bundle_build_started.
@vercel

vercel Bot commented Jul 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-native-harness Ready Ready Preview, Comment Jul 11, 2026 4:15pm

Request Review

The metro.bundle.build/metro.bundle.request deriver was attached only
after the metro.init/platform.init Promise.all resolved, so events from
the eager prewarm firing during platform boot were never captured and
the first (most expensive) bundle build was missing from the trace.
Attach the deriver inside the metro.init branch, right before the
prewarm fires, and dispose it on the init failure path too.
Base automatically changed from feat/diagnostics-tracing to main July 11, 2026 16:09
# Conflicts:
#	packages/jest/src/harness-session.ts
@V3RON V3RON changed the title feat: eagerly prewarm the Metro bundle during platform init Eagerly prewarm the Metro bundle during platform init Jul 11, 2026
@V3RON V3RON merged commit 4352671 into main Jul 11, 2026
6 checks passed
@V3RON V3RON deleted the feat/eager-prewarm branch July 11, 2026 16:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant