Skip to content

fix(metro): drop Metro's transform cache under bundle=load too - #163

Merged
ChALkeR merged 1 commit into
mainfrom
claude/metro-load-transform-cache
Jul 28, 2026
Merged

fix(metro): drop Metro's transform cache under bundle=load too#163
ChALkeR merged 1 commit into
mainfrom
claude/metro-load-transform-cache

Conversation

@exo-nikita

Copy link
Copy Markdown
Collaborator

The error

Failed to construct transformer:  Error: Cannot find module 'metro-transform-worker'
  imported from node_modules/metro/src/DeltaBundler/getTransformCacheKey.js
    at State.getImport (stasis-core/src/state.js)
    at resolve (stasis-core/src/hooks.js)
    at Module._load (stasis-core/src/hooks.js)

The file is in the bundle and on disk. Only the edge is missing — nothing ever recorded that getTransformCacheKey.js imports metro-transform-worker, so load mode fails closed on it.

Why the edge can't exist

withStasis dropped cacheStores for a capture or a frozen verify but left them alone under bundle=load, reasoning that a hit there costs no attestation because the transform derives from the bundle's own bytes. It costs something else: the transform cache decides which modules Metro itself loads.

// metro/src/DeltaBundler/Transformer.js
this._cache = new Cache(config.cacheStores);
...
const globalCacheKey = this._cache.isDisabled ? '' : getTransformCacheKey({...});

Cache.isDisabled is exactly stores.length === 0. So getTransformCacheKey() — and its whole call tree: require(transformerPath), then Transformer.getCacheKey() reaching metro-transform-plugins.getTransformPluginCacheKeyFiles() and its require.resolve() calls — runs only when the cache is enabled. A capture has it disabled, so it never executes that tree and cannot record its edges. The replay has it enabled, executes it for real, and getImport rejects an edge no capture could have produced.

Instrumented at that exact branch on the repro (real Metro 0.87.0, 4 workers, --child-process, withStasis):

run stores isDisabled getTransformCacheKey result
capture --lock=add --bundle=add 0 true never called ok
replay --lock=frozen --bundle=load (before) 1 false CALLED ERR_MODULE_NOT_FOUND
replay --lock=frozen --bundle=load (after) 0 true never called ok, 3/3

Metro swallows the throw into Failed to construct transformer: and then dies in Bundler.end on the half-built instance (Cannot read properties of undefined (reading 'end')).

Why it's intermittent: which module in that tree throws first depends on which resolver the resolution reaches — the fail-closed getImport path, or the Module._resolveFilename shim, which falls through to disk for an edge the bundle lacks. My repro first failed on require.resolve('./index.js') from metro-transform-plugins/src/index.js; the report above failed one frame earlier on require(transformerPath). Same cause, and one run out of several passed outright.

The fix

needsTransforms no longer excludes loadBundle, so the stores are dropped in every active mode. The loaded set then can't diverge between record and replay — the same reason a capture drops them.

Capture behaviour is untouched: the attested set is identical before and after (105 buckets, 639 files, 1243 formats, same file/format hashes), and the replay now exits 0 and writes its output bundle.

The bundle=load test asserted the old behaviour, with the reasoning this disproves, so it's inverted rather than deleted: a replay must drop the stores, and replacing a caller's own stores must still be announced.

Cost, and the follow-up

A load build no longer reuses transforms. For --lock=frozen that's a gain — #152 already notes a verify over a warm cache "passes vacuously, it verifies transforms nobody performed", and the loadBundle exclusion quietly re-admitted that for frozen+load, which is the standard verify invocation. But a long-lived --bundle=load dev server now re-transforms on every start.

Keeping the cache under load requires the capture to run the cache-key path so those edges get recorded: leave the stores in place and force a full miss with a per-run cacheVersion nonce — the mechanism already in #156 for hand-wired configs. That's strictly better on speed and stops overriding the user's cacheStores at all, but it's a bigger change; this PR fixes the broken build first.


Generated by Claude Code

`withStasis` dropped `cacheStores` for a capture or a frozen verify but left
them alone under `bundle=load`, on the theory that a cache hit there costs no
attestation because the transform derives from the bundle's own bytes. It costs
something else: the transform cache decides which modules METRO ITSELF loads.

`new Transformer` computes the global cache key only when the cache is enabled:

    const globalCacheKey = this._cache.isDisabled
      ? ''
      : getTransformCacheKey({...})   // metro/src/DeltaBundler/Transformer.js

and `Cache.isDisabled` is just `stores.length === 0`. So that call tree --
`require(transformerPath)`, then `Transformer.getCacheKey()` reaching
`metro-transform-plugins.getTransformPluginCacheKeyFiles()` and its
`require.resolve()` calls -- runs in load mode and never during a capture,
whose cache we had dropped. The capture cannot record edges it never resolved,
so the replay fails closed on them:

    Failed to construct transformer:  Error: Cannot find module 'metro-transform-worker'
      imported from node_modules/metro/src/DeltaBundler/getTransformCacheKey.js
        at State.getImport (stasis-core/src/state.js)
        at resolve (stasis-core/src/hooks.js)

Metro swallows that into "Failed to construct transformer" and then dies in
`Bundler.end` on the half-built instance. Whether a given resolution throws
depends on which resolver it reaches -- the fail-closed getImport path, or the
Module._resolveFilename shim, which falls through to disk for an edge the bundle
lacks -- so it presents intermittently.

Dropping the stores in every active mode makes the loaded set independent of
cache state, which is the same reason a capture drops them. Measured against
real Metro 0.87.0 (4 workers, `--child-process`, withStasis):

  * before: capture runs with stores=0, isDisabled=true, getTransformCacheKey
    never called; the `--lock=frozen --bundle=load` replay runs with stores=1,
    isDisabled=false, getTransformCacheKey CALLED -> ERR_MODULE_NOT_FOUND.
  * after: both run with stores=0 and never call it; the replay exits 0 and
    writes its bundle, 3 for 3. The capture's attested set is unchanged
    (105 buckets, 639 files, 1243 formats, same hashes).

The bundle=load test asserted the old behaviour, with the reasoning this
disproves, so it is inverted rather than deleted: a replay must drop the stores,
and a caller's own stores being replaced must still be announced.

Cost: a load build no longer reuses transforms. For `--lock=frozen` that is a
gain -- #152 already notes a verify over a warm cache "passes vacuously, it
verifies transforms nobody performed" -- but a long-lived `--bundle=load` dev
server now re-transforms on every start. Keeping the cache there needs the
capture to run the cache-key path instead, i.e. a per-run `cacheVersion` nonce
with the stores left in place (the mechanism in #156), which is a follow-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TMPW6hy7z3BTnJTJjS8k7k
@ChALkeR
ChALkeR merged commit d6f4bf9 into main Jul 28, 2026
5 checks passed
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.

3 participants