vercel / vercel/next.js

Turbopack browser runtime: first-wins module registration installs the wrong factory when a module id is emitted with different bodies in different chunks

Open
#98,099 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Pages Router Runtime Turbopack
Dominant language
JavaScript
Stars
142k
Forks
32.4k
Avg merge
2d 14h
Merged PRs (30d)
351

Description

Link to the code that reproduces this issue

https://github.com/Stanzilla/turbopack-first-wins-repro

To Reproduce
npm install
npm run verify

verify builds the app and runs machine-checked static, natural-runtime, and synthetic-runtime assertions. No browser or dev server is required. The scripts find generated chunk names and module IDs from .next, so they do not depend on fixed hashes.

Current vs. Expected behavior

In this Pages Router app, /dyn-a imports lib/heavy.js dynamically. /dyn-b imports the same module both statically and dynamically. Turbopack emits the same async-loader module ID in both page chunks, but with different factories:

  • /dyn-a: load the target chunk, then require the target module
  • /dyn-b: use Promise.resolve, then require the target module because its factory is assumed to be present

The production browser runtime uses the first factory registered for a module ID. npm run repro evaluates the build's real, unmodified runtime and page chunks in both orders. One order installs the chunk-loading factory. The other installs the Promise.resolve factory. The installed behavior therefore depends on script execution order.

If the Promise.resolve factory wins before the target factory is registered, the dynamic import can fail with:

Module <X> was instantiated because it was required from module <Y>,
but the module factory is not available.

The repro also includes a synthetic overlap case. It sends two compressed module groups through the real runtime. The groups share one ID but have different factories. The runtime reuses the first group's factory for every new ID in the second group and deterministically produces the error above. A control case passes without the overlap.

Expected: a module ID has one installed behavior regardless of chunk execution order. Turbopack should emit equivalent factories, use context-specific IDs, or reject a conflicting registration instead of silently selecting the first factory.

We see the same error intermittently on cold loads in a large Pages Router app on iOS WKWebView. One production build has about 3,300 chunks and 59 module IDs with divergent loader factories. On 9 pages, the Promise.resolve variant registers first in document order while the target factory is in another page chunk.

Provide environment information
Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 25.6.0
Binaries:
  Node: 24.19.0
  npm: 11.17.0
Relevant Packages:
  next: 16.3.3
  react: 19.2.8
  react-dom: 19.2.8

Also reproduced on Next.js 16.4.0-canary.12.

Which area(s) are affected? (Select all that apply)

Turbopack, Pages Router, Runtime

Which stage(s) are affected? (Select all that apply)

next build (local), Other (Deployed)

Additional context

The current group-factory reuse behavior was added in https://github.com/vercel/next.js/pull/89976.

Probable code path:

  1. AsyncLoaderModule::chunk_item_content changes the generated factory based on chunk availability.
  2. AsyncLoaderModule::asset_ident_for identifies the loader from its target module, and global module ID generation collects that identity.
  3. installCompressedModuleFactories preserves the first registered factory.

chunk_item_content_ident already adds availability information, but the build still gives the two factory bodies the same numeric module ID. A likely repair is to make the loader body availability-independent or make the emitted identity availability-specific. Simply preferring the last factory would conflict with the compressed-group consistency that https://github.com/vercel/next.js/pull/89976 preserves.

experimental.turbopackScopeHoisting: false is our current workaround. It removes merged groups from the affected production build with a 0.4% gzip size increase. It does not remove the divergent async-loader factories.

Scope boundary: the natural app proves divergent factories and order-dependent installation. The overlap crash uses synthetic registration tuples with the real runtime. We did not find non-identical overlapping groups in one clean build, and we have not proved which path causes the production failures.

Probably related: https://github.com/vercel/next.js/issues/86132

The reproduction and measurements were prepared with AI assistance (Claude Code, claude-fable-5) and reviewed by the reporting team.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Run npm install, npm run verify, and npm run repro in the linked reproduction first. Then read AsyncLoaderModule::chunk_item_content and asset_ident_for in turbopack/crates/turbopack-ecmascript/src/async_chunk/module.rs, global ID generation in turbopack/crates/turbopack/src/global_module_ids.rs, and installCompressedModuleFactories in turbopack-ecmascript-runtime/js/src/shared/runtime/runtime-utils.ts. Done means the real runtime no longer selects order-dependent behavior for conflicting module registrations, with regression coverage for the reported overlap.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, nextjs, react, rust, typescript
Domain
build-system, tooling, web-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.