Repository navigation
Make Metro serializer a pass-through in load mode - #67
Merged
Merged
Conversation
…erializer The StasisMetro serializer threw on bundle=load, telling the user to remove the plugin from their serializer config in load mode. That advice is self-defeating: metro.config.js (and the plugin wiring it carries) is itself attested at capture -- frozen in the bundle/lockfile -- so a load/frozen run must execute the exact same config unedited. There is no mode-specific config to remove the plugin from. Make the serializer a transparent pass-through under bundle=load instead: capture nothing, write nothing, delegate to the base serializer. This mirrors the companion worker transformer (the load half), which is already a pass-through in every non-load mode -- so both halves are wired permanently in one committed metro.config.js and the mode (env / stasis.config.json) picks the active one. The check sits BEFORE the one-shot capture guard: load legitimately re-serializes per rebuild under a dev server, and the stale-capture hazard that guard exists for can't arise when nothing is captured. Also fix the documented wiring: `transformerPath` is a TOP-LEVEL Metro config key (metro-config defaults, sibling of `transformer`), not `transformer.transformerPath` as the removed error message and the transformer docs claimed -- a nested key is silently ignored by Metro, which would leave a load run building from disk. Tests: the bundle=load rejection test becomes a pass-through test (base output produced twice under the new dev-server-rebuild knob, tampered disk ignored, lockfile and bundle untouched), plus a companion test pinning that capture still refuses a second serialization. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5ALMiHBj7Z1iQynnRraKM
The rationale (config is attested at capture, so load runs it unedited) was restated in full at every site; state it once in the class note and keep the other sites to one-line pointers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5ALMiHBj7Z1iQynnRraKM
exo-nikita
pushed a commit
that referenced
this pull request
Oct 2, 2026
… edges at bundle=load
Running a Metro build from a stasis bundle died in Metro's own resolver:
metro-resolver re-derives every resolution from the filesystem (Haste map +
node_modules walk + package.json redirects) in the main process, and none of
the stasis load machinery (ESM resolve hook, CJS _resolveFilename shim, --fs
readers) sits on that path. An attested-files-only tree carries the files
resolution LANDED ON at capture, not the alias layouts probing walks through
-- e.g. `@babel/runtime/helpers/interopRequireDefault` (injected by
transform-runtime into every transformed module, so the first bare specifier
any rebuild resolves) probes a `helpers/...` layout that was never loaded and
hence never attested, while the bundle carries both the resolved target's
bytes AND the exact (parent, specifier) -> file edge. Nothing consumed those
edges on the Metro side: UnableToResolveError with the answer sitting in the
bundle.
Add the missing consumer, `@exodus/stasis/metro-resolve-request` -- the RESOLUTION
half of load mode, completing the transformer (bytes) the same way webpack's
beforeResolve redirect and esbuild's onResolve already close this seam
in-process for their bundlers:
- `resolveRequest` / `createResolveRequest(base?)` implement Metro's stable
`resolver.resolveRequest` contract, which runs exactly where Metro resolves
(the main process). A recorded in-scope edge is served as
`{ type: 'sourceFile' }` via State.resolveBundled -- the same
condition-agnostic lookup the CJS require shim uses -- so resolution can't
drift from what was attested and can't fail on layouts the attested tree
doesn't carry. Everything else (out-of-scope origins, unrecorded edges --
incl. asset imports, which carry no edge) defers to `base` or Metro's
default resolver via `context.resolveRequest`; byte integrity stays with
the worker transformer / getFile (this is an availability bridge, not a
new trust gate).
- Scope mirrors the transformer and the loader's resolve-hook gate: full
scope serves every in-root origin, node_modules scope only dependency
origins. Inert outside bundle=load (a capture run must resolve naturally --
that IS what gets recorded), so all three Metro pieces stay permanently
wired in one committed metro.config.js per the #67 doctrine.
- State.resolveBundled gains a `{ platform }` option: a per-platform edge map
(`stasis bundle --metro --platforms=...`) is resolved by the platform Metro
hands each request, so one multi-platform artifact serves every platform it
was built for; platform-less callers (the native-require shim) now treat
such an edge as unserved and defer instead of crashing on a Map where a
path is expected. The reserved `.stasis/empty-module.js` target (a
browser/react-native `false` redirect; EMPTY_MODULE_PATH moves to
stasis-core's artifact-util as the shared writer/reader constant) is translated to
Metro's native `{ type: 'empty' }` so it never touches disk.
Covered by tests/metro-resolve-request.test.js (+ run helper, mirroring the
transformer suite; no Metro dependency): recorded edges served even with the
target deleted from disk, unrecorded edges deferred with arguments untouched,
out-of-root origins, node_modules-scope gating both ways, non-load
pass-through, per-platform edges from a real `--metro --platforms=ios,android`
artifact (ios/android hit; web / null platform defer; flat edge; empty-module
translation), base composition, and the no-delegate contract error.
Named metro-resolve-request (not metro-resolver) so it can't be confused
with `stasis bundle --metro --metro-resolver`, which statically resolves
through the project's own metro-resolver package (stasis/src/metro-resolver.js).
Cross-referenced from the metro serializer/transformer docs; exported from
both packages and pinned in public-exports.test.js.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hgm1DakWvP1hDUBAeZmuKo
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.
Summary
Refactors the StasisMetro serializer plugin to be a transparent pass-through when
bundle=loadis active, rather than throwing an error. This enables a singlemetro.config.jsto wire both the serializer and transformer halves permanently, with the mode (capture vs. load) determining which is active.Key Changes
Serializer behavior in load mode: Changed from rejecting
bundle=loadwith an error to silently passing through (returning early without capturing or writing anything). This allows the base serializer to produce output unmodified.Permanent wiring pattern: Updated documentation and examples to show that both
StasisMetroserializer and the companionmetro-transformershould be wired inmetro.config.jspermanently. The mode selection (viaEXODUS_STASIS_BUNDLEorstasis.config.json) determines which half is active, rather than requiring separate configurations.Load-bearing symmetry: Clarified that this design is necessary because
metro.config.jsitself is attested at capture time (frozen in the bundle/lockfile). A load/frozen run must execute the exact same config byte-for-byte, so neither the serializer nor transformer can demand mode-specific edits.Test restructuring:
STASIS_TEST_METRO_SERIALIZE_TWICEenvironment variable to model dev-server rebuild scenariosDocumentation updates: Expanded comments in both
metro.jsandmetro-transformer.jsto explain the symmetric pass-through design and why permanent wiring is required.Implementation Details
loadBundleearly and returns immediately, before the one-shot guard that prevents re-serialization during capturehttps://claude.ai/code/session_01L5ALMiHBj7Z1iQynnRraKM