Eagerly prewarm the Metro bundle during platform init#149
Merged
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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.
# Conflicts: # packages/jest/src/harness-session.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 insidewaitForMetroBackedAppReady. 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
sawPrewarmRequestbookkeeping — whose event-listener path had become dead code — with prewarm state tracked directly on theMetroInstance, 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:
refactor(bundler-metro): prewarm state on the instance. The only thing that ever issues aprewarm-kind bundle request is the harness's own memoized fetch, and it completed before anyobserveBundleRequestlistener attached — so therequestKind === 'prewarm'branch and the cross-attemptsawPrewarmRequestthreading re-derived a value that was already in scope. The factory now tracks the lifecycle next to the memoized promise and exposesgetPrewarmState(): 'idle' | 'pending' | 'succeeded' | 'failed'.StartupStallErrorcarries this richerprewarmStateinstead 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). TherequestKind === 'app'filter stays — it prevents the harness's own fetch from being mistaken for the app's bundle request.feat(jest): eager prewarm. Inside the existingPromise.allthat runs Metro init and platform init concurrently, the session now firesinstance.prewarm(...)as soon as the Metro instance resolves — without awaiting it, sometro.initresolution timing is unchanged. The laterawait metro.prewarm(...)inwaitForMetroBackedAppReadyjoins the memoized in-flight (or settled) promise, so downstream retry/observer semantics are untouched. The prewarm runs under its ownmetro.prewarmdiagnostics span, ended when the promise settles, so the Chrome trace shows it overlappingplatform.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 neweagerPrewarmconfig option (defaulttrue, documented on the website) provides an escape hatch for constrained runners where emulator boot and bundling might contend for CPU.feat(bundler-metro): in-flight build tracking. The instance countsbundle_build_started/done/failedreporter events and exposesisBuildInFlight().waitForReadyAfterBundleRequestaccepts aninitialBundlingInProgressseed 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 alwaysfalse), 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 freshbundle_build_started— without the seed, the ready timer would fire spuriously mid-build.Why is this useful?
prewarmStatedistinguishes device-side connectivity problems from slow or broken bundle builds, which the old boolean could not.BundleRequestTimeoutErrorpayload are gone; prewarm state has a single source of truth on the instance.await metro.prewarm(...)(overlapping app launch with the bundling tail) now only requires dropping the await — the ready-timer coalescing hazard is already handled.metro.prewarmspan makes the overlap (or contention, on small runners) directly visible in the diagnostics trace, so theeagerPrewarmdefault can be validated with real CI data.