Skip to content

Rework skip-already-included-modules serializer#158

Merged
V3RON merged 7 commits into
mainfrom
feat/skip-included-modules-v2
Jul 13, 2026
Merged

Rework skip-already-included-modules serializer#158
V3RON merged 7 commits into
mainfrom
feat/skip-included-modules-v2

Conversation

@V3RON

@V3RON V3RON commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

What is this?

This PR reworks the experimental unstable__skipAlreadyIncludedModules Metro serializer feature and graduates it to a stable, default-on skipAlreadyIncludedModules config option.

The feature exists because of how the harness serves tests: the device first loads the main app bundle, then fetches each test file as a separate ?modulesOnly=true bundle. Cross-bundle requires work because Metro's Server uses a single createModuleId counter across all graphs, and metro-runtime's define() ignores redefinition of an existing module ID — so re-sending modules the device already has is pure waste. The harness serializer skips them to keep per-test bundles small and cheap to evaluate.

The previous implementation was fragile in ways that either crashed the device or silently corrupted symbolication:

  1. Set clobbering: the serializer kept one closure-level Set<string> and overwrote it on every non-modulesOnly serialization. Any full-bundle build for a different entry point or bundle URL (a debugger, a browser tab, another client) replaced the set with the wrong module list, and subsequent test bundles excluded modules the device never received — crashing with "Requiring unknown module". Now capture only happens when the serialized entry is positively identified as the harness main entry (matched against the resolved @react-native-harness/runtime/entry-point path, which both a project's real entryPoint and Expo's .expo/.virtual-metro-entry resolve to via the harness resolver).
  2. No keying: the single flat set was shared across platforms, even though .ios.ts/.android.ts resolve to different paths. An iOS capture could poison an Android run. Sets are now stored in a Map keyed by graph identity (see "Expo consideration" below), and a modulesOnly request with no captured set for its key fails open — it serializes unfiltered rather than guessing.
  3. getAllFiles asset mismatch: the set was built with Metro's getAllFiles helper, which expands asset modules to their physical variant files on disk (icon@2x.png) instead of the module path Metro uses in the graph — so asset modules never matched and were always re-sent, plus the helper does pointless async fs work. The set is now built directly from preModules + graph.dependencies, filtered the same way Metro's own processModules filters (isJsModule + the user's config-level processModuleFilter, so a module the user's filter kept out of the main bundle is never wrongly treated as already-delivered).
  4. Latent .default require bug (found by the new tests): metro/private/.../baseJSBundle and metro/private/lib/bundleToString are CJS modules compiled with exports.default = fn, so require() returns {default: fn}, not a callable. The old code called the require() result directly and would have thrown TypeError the first time the modulesOnly branch actually ran.

The serializer also now delegates non-modulesOnly serializations to the user's own customSerializer when one exists (e.g. Expo's) instead of discarding it — capture is a read-only side effect that never changes what gets served.

The centerpiece: blanking instead of omission, for correct source maps

The old implementation excluded modules via a per-call processModuleFilter override. That breaks symbolication: Metro's .map endpoint (Server._processSourceMapRequest) and POST /symbolicate (Server._explodedSourceMapForBundleOptions) compute line offsets from the full graph using only the config-level processModuleFilter — a custom serializer's per-call override is invisible to them. Omitting a module shifts the line offset of every module after it, so the code frames the harness runtime gets back from /symbolicate for test failures point at the wrong lines.

The fix: serialize the full bundle via baseJSBundle with the user's processModuleFilter passed through untouched, then post-process the returned {pre, post, modules} — for each module whose path is in the captured set, replace its wrapped code with '\n'.repeat(lineCount - 1) before bundleToString. Line offsets stay byte-for-byte identical to the unfiltered bundle Metro's map endpoints describe, so symbolication is correct with zero server or middleware changes. The skipped __d() definitions still disappear (that's the size/eval win); the blank lines cost almost nothing. One subtlety verified against metro 0.83.3: bundleToString drops a zero-length module entry entirely (no trailing newline), so a single-line module blanks to ' ' rather than '' to preserve its one line.

Everything follows one failure-direction rule: under-exclusion (a fatter test bundle) is always safe; over-exclusion crashes the device. Every ambiguous path fails open toward inclusion, and both the capture and the lookup-miss paths emit debug logs so a silent degradation leaves a breadcrumb.

Config graduation

  • skipAlreadyIncludedModules?: boolean — new stable flag, defaults to true; false is the escape hatch.
  • unstable__skipAlreadyIncludedModules keeps working as a deprecated alias (logs a deprecation warning); the explicit new flag wins when both are set.
  • The default is applied in resolveSkipAlreadyIncludedModules rather than the zod schema so "unset" stays distinguishable from "explicitly set" — that distinction is what keeps the alias's escape-hatch semantics working.

Expo consideration: graph keying

Captured sets are keyed by platform/dev/minify only — deliberately not by unstable_transformProfile/customTransformOptions. Expo main-bundle requests carry transform.engine=hermes, transform.bytecode=1, transform.routerRoot=app, transform.reactCompiler=true, and unstable_transformProfile=hermes-stable, while the runtime's ?modulesOnly=true requests send only modulesOnly + platform — keying on the transform dimensions would make the lookup miss on every Expo test bundle and silently turn the feature into a permanent no-op. Excluding them is still crash-safe: blanking a path only requires that the device holds a __d() definition for it, module IDs are path-based via the server-wide createModuleId counter, and exclusion only applies to paths captured from a main bundle actually served to the device — transform options change module content, not the presence of a definition. Platform keying stays because it is load-bearing (.ios.ts/.android.ts are different path sets).

Test plan

Unit tests (packages/bundler-metro/src/__tests__/getHarnessSerializer.test.ts, 9 tests, built on fake graphs/modules matching Metro's shapes and exercising the real baseJSBundle/bundleToString):

  • capture happens only when the entry matches the harness entry point (including via the Expo virtual entry / graph.entryPoints)
  • a non-matching full-bundle serialization (debugger/browser) does not clobber a previously captured set
  • graph-identity keying: an iOS capture does not affect an Android modulesOnly request (fails open)
  • Expo regression: a main bundle captured with hermes-stable + Expo customTransformOptions still blanks a default-transform-options test bundle (verified this test fails against the old keying)
  • blanking preserves the bundle's exact total line count vs. unfiltered output; skipped module code is gone; non-skipped modules stay byte-identical
  • captured set is built from graph.dependencies paths (asset module paths match directly, no variant-file expansion)
  • non-modulesOnly serializations delegate to a user-supplied customSerializer, with capture still occurring
  • modulesOnly with no captured set serializes unfiltered (fail-open)

Config tests (packages/config/src/__tests__/resolveSkipAlreadyIncludedModules.test.ts, 5 tests): default true, alias resolution in both directions, deprecation warning emitted only when the alias is set, explicit new flag wins over the alias. The config package previously had no test target; this adds its vite.config.ts.

nx typecheck, lint, and unit tests pass for all affected packages. The runtime package is untouched.

Playground validation

The playground app (apps/playground) previously ran with the escape hatch pinned off (unstable__skipAlreadyIncludedModules: false). This PR removes that override, so the playground now exercises the feature at its new stable default (true) on every CI and local run, alongside the existing unstable__enableMetroCache: true setting, which is left untouched.

To confirm the graduated serializer is actually a win and not just crash-safe, I ran the playground's full normal suite (18 test files) on a real Android emulator with RN_HARNESS_DIAGNOSTICS=true, comparing a trace captured with the old pinned-off config against one captured after removing the override, with Metro cache held constant across both runs:

stage before (skip off) after (skip on) delta
run.total 91.3s 53.6s -41%
app.reset (between test files) 41.1s 22.5s -55%
client.collect (device test discovery) 10.5s 2.0s -81%
client.bundle.module (device receive+eval) 11.9s 5.8s -51%
client.setup-file 4.6s 2.4s -48%
client.suite 20.7s 16.2s -22%
metro.bundle.build (server) 4.5s 3.8s -15%

Every stage improved, with the largest gains on the device side (client.collect, client.bundle.module, app.reset) — exactly where smaller per-test bundles should pay off, since the device has less already-known code to receive, parse, and evaluate per test file. This closes the "no device/e2e coverage" gap noted above: the feature is now both correctness-verified (unit tests) and performance-verified (real device run) before graduating to default-on.

V3RON added 5 commits July 13, 2026 16:25
The harness serializer used to overwrite its module set on ANY full-bundle
serialization (debugger, browser, other entry points), causing over-exclusion
and "Requiring unknown module" crashes on device, and it kept a single flat
set shared across platforms/dev-prod variants even though their module sets
differ. It also built the set with Metro's getAllFiles helper, which expands
asset modules to physical variant files on disk instead of the module paths
processModuleFilter/createModuleId actually operate on.

Only capture when the serialized entry is positively identified as the
harness main entry point (matched against the runtime's resolved
entry-point module, which both a project's real entryPoint and Expo's
virtual entry resolve to), key the captured sets by graph identity
(platform/dev/minify/transform profile/custom transform options), and
build the set from preModules + graph.dependencies keys. Delegate
non-modulesOnly serialization to the user's own customSerializer when
present so this stays non-invasive for consumers like Expo. modulesOnly
requests fail open (no exclusion) whenever no set was captured for their
graph key.

Still uses filter-based exclusion for modulesOnly bundles; phase 2 replaces
that with blanking to keep source maps and /symbolicate correct.
Metro's .map endpoint and /symbolicate compute line offsets from the full,
unfiltered graph using only the config-level processModuleFilter -- they
never see a custom serializer's per-call filter override. Omitting
already-included modules from the modulesOnly bundle shifted every
subsequent module's line offset, producing wrong code frames for failing
tests.

Instead, serialize the full graph via baseJSBundle with the user's
processModuleFilter passed through untouched, then replace each
already-included module's code with the same number of blank lines. This
keeps the bundle's line count byte-for-byte identical to an unfiltered
bundle (so Metro's separately-computed source maps stay correct) while
still dropping the __d() definitions that make the test bundle smaller.

Also tightens what counts as "already included": the capture pass now
mirrors processModules' own isJsModule + config-level processModuleFilter
checks, so a module the user's filter rejected from the main bundle (and
so never reached the device) is not later blanked out of a test bundle
that legitimately needs it.
…hase 3)

Add `skipAlreadyIncludedModules`, defaulting to true, as the stable
replacement for `unstable__skipAlreadyIncludedModules`. The deprecated alias
keeps working (with a deprecation warning) and still serves as the escape
hatch (`unstable__skipAlreadyIncludedModules: false`), but the new flag wins
whenever both are set.

Neither field carries a zod `.default()`: doing so would make "not set" and
"explicitly set to the default" indistinguishable, which is exactly the
information `resolveSkipAlreadyIncludedModules` needs to apply the new
default without breaking the alias's escape-hatch semantics. The default is
applied in that resolver instead, which withRnHarness.ts now calls in place
of reading the raw config field directly.
…e 4)

Covers: capture only on a matching main entry (including the Expo virtual
entry, resolved via graph.entryPoints); a non-matching full bundle
serialization never clobbers a previously captured set; graph-identity
keying (an iOS capture doesn't leak into an Android modulesOnly request,
which fails open); blanking preserves the bundle's exact total line count
while removing a skipped module's code and leaving other modules'
wrapped code byte-identical; the captured set is built from
graph.dependencies (so an asset module path is captured directly, without
getAllFiles-style expansion); delegation to a user-supplied
customSerializer for main-bundle requests; and the modulesOnly fail-open
path when no set was ever captured for a graph key.

Also fixes a real bug the new tests caught: `metro/private/DeltaBundler/
Serializers/baseJSBundle` and `metro/private/lib/bundleToString` are CJS
modules compiled with `exports.default = ...`, so `require()` returns
`{default: fn}`, not a callable -- the serializer was calling the require()
result directly and would have thrown at runtime the first time the
feature actually ran. Unwrap `.default` for both.

Adds packages/config/vite.config.ts and an `ignoredDependencies` entry in
its eslint config (matching bundler-metro's) so the config package gets a
`test` target (it had none), plus
packages/config/src/__tests__/resolveSkipAlreadyIncludedModules.test.ts
covering the default-true, alias resolution, deprecation warning, and
explicit-flag-wins-over-alias cases.
…ogging

Keying the captured "already included" sets by unstable_transformProfile
and customTransformOptions broke the feature on Expo: the Expo client's
main-bundle request carries transform.engine=hermes, transform.bytecode=1,
transform.routerRoot=app, transform.reactCompiler=true, and
unstable_transformProfile=hermes-stable (see bundle-url.ts), while the
runtime's ?modulesOnly=true test-bundle requests send only modulesOnly +
platform, so their graph has default/empty transform options. The lookup
therefore always missed, failed open, and silently turned the feature into
a permanent no-op on Expo -- a regression vs. the old flat-set behavior.

Key by platform, dev, and minify only. Platform keying stays because it is
load-bearing (.ios.ts/.android.ts resolve to different paths). Dropping
the transform dimensions is still crash-safe: blanking a path only
requires that the device holds *a* __d() definition for it, module IDs
are path-based via Metro's single server-wide createModuleId counter, and
exclusion only applies to paths captured from a main bundle actually
served to the device -- transform options change module content, not the
presence of a definition. Regression-tested with a hermes-stable/Expo
customTransformOptions main graph against a default-options test graph
(verified the test fails against the old key).

Also add debug logging on capture (graph key + module count) and on a
modulesOnly lookup miss (requested key + held keys) so a future
entry-point or keying regression degrades with a diagnostic breadcrumb
instead of silently serving fat test bundles, and document the capture
boundary when serialization is delegated to a user customSerializer
(a tree-shaking serializer could invalidate the captured set, but the
harness always serves dev-mode bundles, which don't tree-shake).
@vercel

vercel Bot commented Jul 13, 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 13, 2026 5:59pm

Request Review

Removes the escape-hatch override now that the serializer rework (PR #158) graduated the feature to stable, default-on.
@V3RON V3RON changed the title Rework skip-already-included-modules serializer: fix capture bugs, preserve source maps, enable by default Rework skip-already-included-modules serializer Jul 13, 2026
@V3RON V3RON merged commit 1f37141 into main Jul 13, 2026
6 checks passed
@V3RON V3RON deleted the feat/skip-included-modules-v2 branch July 13, 2026 18:01
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