Repository navigation
Turbopack: compact re-export registration - #98617
Merged
Merged
Conversation
sokra
force-pushed
the
fleet/turbopack-compact-reexport-registration
branch
from
September 13, 2026 19:37
af0f0e3 to
7b42ee6
Compare
Contributor
Tests PassedCommit: 99b6050 |
sokra
added this pull request to stack #98618
September 13, 2026 19:42
sokra
commented
Sep 14, 2026
sokra
force-pushed
the
fleet/turbopack-compact-reexport-registration
branch
from
September 14, 2026 06:31
7b42ee6 to
aa36f2c
Compare
sokra
commented
Sep 14, 2026
sokra
commented
Sep 14, 2026
sokra
force-pushed
the
fleet/turbopack-compact-reexport-registration
branch
from
September 14, 2026 10:06
aa36f2c to
d81514c
Compare
Base automatically changed from
codex/turbopack-strict-chunk-factories
to
canary
September 14, 2026 10:34
sokra
force-pushed
the
fleet/turbopack-compact-reexport-registration
branch
2 times, most recently
from
September 15, 2026 07:35
6603f83 to
9ee8121
Compare
sokra
marked this pull request as ready for review
September 15, 2026 09:20
sokra
force-pushed
the
fleet/turbopack-compact-reexport-registration
branch
3 times, most recently
from
September 16, 2026 13:34
064eca0 to
879129c
Compare
mischnic
reviewed
Sep 17, 2026
sokra
removed this pull request from stack #98618
September 17, 2026 19:19
sokra
added this pull request to stack #98839
September 17, 2026 19:27
sokra
force-pushed
the
fleet/turbopack-compact-reexport-registration
branch
from
September 17, 2026 20:36
20f3e67 to
3084b04
Compare
lukesandberg
left a comment
Contributor
There was a problem hiding this comment.
this very much overlaps with #98603
which doesn't address this particular kind of re-export (but supporting it is a minor change)
if they did then this would become
var ns = e.i(...)
e.s(['foo', 0,ns.foo]
which is more compact than the original from the PR description.
(see #98898 which adds this optimization)
Of course the approach here is even more compact, but i do wonder how many re-exports survive in production
Can you measure impact on something like vercel-site? next-site?
Member
Author
|
It would be good to include some benchmark (bundle size) result in the PR description. Maybe bench/basic-app, heavy-npm-deps and module-cost. |
lukesandberg
approved these changes
Sep 22, 2026
Record each import reference's top-level declaration ordinal in a side table, preserving reference identity and deduplication. Use that order to classify compact registrations when transforms insert DUMMY_SP imports. Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
sokra
force-pushed
the
fleet/turbopack-compact-reexport-registration
branch
from
September 22, 2026 07:00
515126c to
57a2d42
Compare
sokra
commented
Sep 22, 2026
sokra
commented
Sep 22, 2026
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
sokra
added a commit
that referenced
this pull request
Sep 23, 2026
…#97770) ### What? Allows Turbopack to keep mangling a module's internal export keys when its namespace object escapes. A facade preserves the original public names while forwarding each one to the mangled key in the locals module, matching webpack's materialized-namespace behavior. This PR is stacked on #98617. That lower PR now owns the generic synthetic-facade compact registration, including empty evaluation groups, descriptor reuse, scope-hoisted factories, pruned references, and cycle/async fallbacks. This PR creates those facades for escaping namespaces, so the generic path is exercised at scale here. ### Why? A namespace import or dynamic import whose reads cannot be enumerated currently disables mangling for the entire target module. That makes one broad consumer lose the size benefit for every otherwise-static consumer of the module. Materializing the original namespace surface keeps ESM semantics intact — including live values and `Object.keys()` — while allowing internal consumers to use shorter names. ### How? The facade's synthesized per-export references participate in the module graph, so named usage propagates to the locals module. Its structural locals edge carries evaluation usage only, while each forwarded name carries its own export usage. Part-reference code generation also honors references pruned by the graph, matching ordinary ESM references. Modules with no exports are not split, async modules retain the existing path, and real export-cycle circuit breakers avoid compact registration because they require their getters to be visible before imports run. Namespace grouping includes the SWC syntax context so scope-hoisted modules cannot accidentally share the wrong generated binding. ### Testing - `cargo test -p turbopack-ecmascript -p turbopack-tests` — **590 unit, 303 execution, 3 auxiliary, and 131 snapshot tests passed** on the current stack - `cargo clippy -p turbopack-ecmascript -p turbopack-core -- -D warnings` - `cargo fmt --all -- --check` - `pnpm test-dev-experimental-turbo test/development/app-dir/lazy-dynamic-imports/lazy-dynamic-imports.test.ts` — 9/9 passed - `pnpm test-dev test/e2e/app-dir/instant-validation/suspense-boundaries.test.ts` — 38/38 passed - `pnpm test-start-turbo test/production/app-dir/barrel-optimization/mui/index.test.ts` — 1/1 passed - `pnpm test-start-turbo test/e2e/app-dir/cache-components-allow-otel-spans/cache-components-allow-otel-spans.test.ts` — 4/4 passed - `pnpm test-start-turbo test/e2e/404-page-custom-error/404-page-custom-error.test.ts test/e2e/app-dir/app-esm-js/index.test.ts test/e2e/app-dir/app-esm-js/standalone.test.ts` — 9/9 passed - Existing materialized-namespace fixtures verify namespace values, key enumeration, dynamic imports, CommonJS consumers, and liveness through the locals module - A callable CommonJS namespace fixture regression-tests the compact registration path after facade materialization - The full execution suite specifically caught and now covers pruned locals references and merged evaluation-only locals references in the generic #98617 implementation ### Bundle-size measurements Incremental effect of enabling `turbopackMangleExportNames` on this stack. Both sides use the same native binary and retain #98617's compact re-export registration; only mangling is toggled. Client reports raw and deterministic gzip (`gzip -9 -n`, sorted files); server reports raw only because Node.js consumes server code uncompressed. JavaScript files only; source maps, CSS, and license sidecars are excluded. #### Scope hoisting disabled | benchmark | client raw | client gzip | server raw | | --- | ---: | ---: | ---: | | `basic-app` | −17,380 B | −1,131 B | −61,155 B | | `heavy-npm-deps` *(three-dependency variant: lodash + Mantine + Mermaid)* | −47,537 B | −6,720 B | −82,326 B | | `module-cost` | −223,816 B | −2,113 B | −596,419 B | #### Scope hoisting enabled | benchmark | client raw | client gzip | server raw | | --- | ---: | ---: | ---: | | `basic-app` | −22,658 B | −7,634 B | −32,805 B | | `heavy-npm-deps` *(three-dependency variant: lodash + Mantine + Mermaid)* | −14,118 B | +1,291 B | −20,956 B | | `module-cost` | +30 B | −785 B | −7,265 B | The `heavy-npm-deps` rows use a modified benchmark fixture with Mantine and Mermaid enabled alongside lodash; the committed fixture enables lodash only. Positive/near-zero configurations were repeated and reproduced byte-for-byte for every reported metric. <!-- NEXT_JS_LLM --> <!-- fleet ecdfa248-cd54-41ac-b4a2-c9d49e2a67ee --> --------- Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
mischnic
added a commit
that referenced
this pull request
Sep 23, 2026
mischnic
added a commit
that referenced
this pull request
Sep 24, 2026
sokra
added a commit
that referenced
this pull request
Sep 24, 2026
### What? Generate **both RSC client-reference proxies and WebAssembly loaders** under their graph-backed wrapper module identities, using one shared, higher-level content helper. Remove both generated-path exceptions from `BindingUsageInfo::used_exports` and the earlier proxy-specific export filter. ### Why? The wrapper is the module in Turbopack's graph, but its chunk item previously delegated code generation to a separately processed virtual JS module. Export-usage lookup for that proxy/loader missed the graph and fell back to “all exports used.” In a `sideEffects: false` barrel this could register a pruned client export and request a missing `<locals>` module ID. The WebAssembly loader had the same graph-identity mismatch, hidden by a separate filename-based fallback. Canary's #99102 now guards against the client-reference missing-ID symptom by checking pruned references. This PR fixes the upstream identity mismatch instead so `expand_exports()` sees real usage; #99102's independent safety checks remain in place. ### How? `chunk_item_content_with_code_from(graph_module, code_source, ...)` takes the generated source's analyzed program, references, exports, source map and async behavior while using the wrapper for the *whole code-generation module context*. Both client-reference and WASM wrappers call it. This replaces the public low-level `with_module_context` setter; it does not change module IDs, WASM output assets, React Flight registration, or the existing single-chunk WASM rejection. sokra's `ResolvedVc` suggestions are also applied. #99090's pruned-reexport-source guard remains necessary: removing it independently still fails its execution fixture. #99082 proposed reverting all of #98617 and closed unmerged; it was not a separate fix to delete. The neutral production proxy regression and two supplemental pruned-source execution fixtures remain in the PR. ### Verification - **WASM can-fail/can-pass:** all 3 existing WASM execution fixtures pass before removing the fallback; with the fallback removed but old loader delegation retained, all 3 fail `export usage not found ...wasm_.loader.mjs`; with the shared helper, all 3 pass (instantiation, imported functions/memory, and `?module` compilation). - **Proxy can-fail/can-pass:** without the wrapper-context path and proxy fallback, the production fixture fails `export usage not found .../__nextjs-internal-proxy.mjs`; with the fix it passes. - **Rust/build:** 1,282 tests pass across affected crates; clippy (`-D warnings`), rustfmt and `pnpm build-all` (18/18 tasks) pass. - **Next.js integration:** Turbopack production 6 suites/12 tests (proxy, used client references, #99102 regression, edge WASM and WASM output tracing); development 3 suites/8 tests (proxy, used client references and edge WASM). Client-reference chunking 1/1. After rebasing onto current canary, `pnpm build-all` and focused proxy/edge WASM production tests (3 suites/7 tests) pass again. <!-- NEXT_JS_LLM --> <!-- fleet ecdfa248-cd54-41ac-b4a2-c9d49e2a67ee --> --------- Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
eps1lon
added a commit
that referenced
this pull request
Sep 26, 2026
…d singletons This adds two fixtures documenting the regression introduced by #97770 ("Turbopack: mangle exports behind an escaping namespace via the facade"), the first bad change in `16.4.0-canary.40` (canary.38 and canary.39 produce a single module ID for the package; canary.40 and later split it). #98617 in canary.39 added the compact `e.S([innerId, ...])` re-export encoding the split uses. Starting with canary.40, a dynamic import of a package entry resolves to a re-export facade module while static imports resolve to the inner module, so an instance installed at the manifest-resolved ID never reaches consumers and the singleton silently duplicates. This is what broke the `vercel-docs` remote-components footer theme toggle in production previews. The first fixture, `test/e2e/app-dir/turbopack-facade-module-split`, distills the mechanism into two Next.js apps behind one proxy: a host page installs its copy of a singleton package at the module ID that the remote page's shared manifest resolves, then executes the remote's consumer module through a slim scope require extracted from remote-components' source, and reads which singleton instance each side sees. The extracted machinery mirrors the package's actual source: the module-ID regexes from `patterns.ts`, the manifest resolution and install logic from `shared-modules.ts`, the scope require from `module.ts`, and the chunk loading from `chunk-loader.ts`. The test asserts the current, unintended behavior so CI pins it: production Turbopack builds are expected to resolve the manifest and the consumer import to different module IDs and to show two distinct singleton instances with no state propagation, while development and all webpack modes are expected to share one instance with propagation. These assertions should be flipped to the intended behavior once dynamic-import resolution and static imports agree on a single module ID again. The fixture's dynamic data access sits behind `Suspense` so it also builds with cacheComponents enabled, and the server startup budget is raised because the fixture spawns Next.js child processes. Deploy runs are skipped because the proxy fixture cannot deploy as a single Next.js application. The second fixture, `test/e2e/app-dir/remote-components-facade-module-split`, drives the same scenario through the `remote-components` package itself (no implementation details) and is skipped for now: remote-components 0.4.15 cannot mount a remote component in this minimal fixture at any Next.js version, because its chunk interceptor never flattens the nested push format Turbopack emits for entry chunks with mixed ESM/CJS content, so the shared-module initializer is never found and the remoteShared fallback cannot serve Turbopack's numeric module IDs. If the breakage is to be fixed downstream rather than in Turbopack, the fix belongs in the package's `extractSharedModuleIds`/`extractInlineSharedModuleIds`, which must chase re-export facades to their source module before installing the shared instance, and in `getSharedModule`, which must resolve consumers through the same chase. Co-Authored-By: Claude Code (kimi-k3[1m]) <noreply@anthropic.com>
eps1lon
added a commit
that referenced
this pull request
Sep 26, 2026
…d singletons This adds two fixtures documenting the regression introduced by #97770 ("Turbopack: mangle exports behind an escaping namespace via the facade"), the first bad change in `16.4.0-canary.40` (canary.38 and canary.39 produce a single module ID for the package; canary.40 and later split it). #98617 in canary.39 added the compact `e.S([innerId, ...])` re-export encoding the split uses. Starting with canary.40, a dynamic import of a package entry resolves to a re-export facade module while static imports resolve to the inner module, so an instance installed at the manifest-resolved ID never reaches consumers and the singleton silently duplicates. This is what broke the `vercel-docs` remote-components footer theme toggle in production previews. The first fixture, `test/e2e/app-dir/turbopack-facade-module-split`, distills the mechanism into two Next.js apps behind one proxy: a host page installs its copy of a singleton package at the module ID that the remote page's shared manifest resolves, then executes the remote's consumer module through a slim scope require extracted from remote-components' source, and reads which singleton instance each side sees. The extracted machinery mirrors the package's actual source: the module-ID regexes from `patterns.ts`, the manifest resolution and install logic from `shared-modules.ts`, the scope require from `module.ts`, and the chunk loading from `chunk-loader.ts`. The test asserts the current, unintended behavior so CI pins it: production Turbopack builds are expected to resolve the manifest and the consumer import to different module IDs and to show two distinct singleton instances with no state propagation, while development and all webpack modes are expected to share one instance with propagation. These assertions should be flipped to the intended behavior once dynamic-import resolution and static imports agree on a single module ID again. The fixture's dynamic data access sits behind `Suspense` so it also builds with cacheComponents enabled, and the server startup budget is raised because the fixture spawns Next.js child processes. Deploy runs are skipped because the proxy fixture cannot deploy as a single Next.js application. The second fixture, `test/e2e/app-dir/remote-components-facade-module-split`, drives the same scenario through the `remote-components` package itself (no implementation details) and is skipped for now: remote-components 0.4.15 cannot mount a remote component in this minimal fixture at any Next.js version, because its chunk interceptor never flattens the nested push format Turbopack emits for entry chunks with mixed ESM/CJS content, so the shared-module initializer is never found and the remoteShared fallback cannot serve Turbopack's numeric module IDs. If the breakage is to be fixed downstream rather than in Turbopack, the fix belongs in the package's `extractSharedModuleIds`/`extractInlineSharedModuleIds`, which must chase re-export facades to their source module before installing the shared instance, and in `getSharedModule`, which must resolve consumers through the same chase. Co-Authored-By: Claude Code (kimi-k3[1m]) <noreply@anthropic.com>
eps1lon
added a commit
that referenced
this pull request
Sep 26, 2026
…d singletons This adds two fixtures documenting the regression introduced by #97770 ("Turbopack: mangle exports behind an escaping namespace via the facade"), the first bad change in `16.4.0-canary.40` (canary.38 and canary.39 produce a single module ID for the package; canary.40 and later split it). #98617 in canary.39 added the compact `e.S([innerId, ...])` re-export encoding the split uses. Starting with canary.40, a dynamic import of a package entry resolves to a re-export facade module while static imports resolve to the inner module, so an instance installed at the manifest-resolved ID never reaches consumers and the singleton silently duplicates. This is what broke the `vercel-docs` remote-components footer theme toggle in production previews. The first fixture, `test/e2e/app-dir/turbopack-facade-module-split`, distills the mechanism into two Next.js apps behind one proxy: a host page installs its copy of a singleton package at the module ID that the remote page's shared manifest resolves, then executes the remote's consumer module through a slim scope require extracted from remote-components' source, and reads which singleton instance each side sees. The extracted machinery mirrors the package's actual source: the module-ID regexes from `patterns.ts`, the manifest resolution and install logic from `shared-modules.ts`, the scope require from `module.ts`, and the chunk loading from `chunk-loader.ts`. The test asserts the current, unintended behavior so CI pins it: production Turbopack builds are expected to resolve the manifest and the consumer import to different module IDs and to show two distinct singleton instances with no state propagation, while development and all webpack modes are expected to share one instance with propagation. These assertions should be flipped to the intended behavior once dynamic-import resolution and static imports agree on a single module ID again. The fixture's dynamic data access sits behind `Suspense` so it also builds with cacheComponents enabled, and the server startup budget is raised because the fixture spawns Next.js child processes. Deploy runs are skipped because the proxy fixture cannot deploy as a single Next.js application. The second fixture, `test/e2e/app-dir/remote-components-facade-module-split`, drives the same scenario through the `remote-components` package itself with no reliance on Next.js or package internals: a host page consumes a remote component that statically imports the same singleton package, and the test reads which instance each side sees and whether a write through the remote's instance reaches the host. Two fixture details matter for fidelity. The remote app bundles its own copy of the package from a vendored path, so the two builds assign distinct module IDs the way two separately deployed apps do; with a single shared copy, Turbopack's specifier-derived IDs line up by coincidence and the split is masked. The remote page also lazy-loads its consumer component, because the package's initializer scan only inspects top-level chunk push entries, and with the consumer in the entry chunk Turbopack emits that chunk in the nested factory format that hides the initializer, a separate package limitation unrelated to the facade split. On current canary the package fixture pins: production Turbopack shows the duplicate singleton with no propagation (the regression), development Turbopack shares the instance with propagation, and both webpack modes pin that remote-components 0.4.15's webpack runtime cannot mount the component in this fixture at all (development cannot resolve the remote's modules, and production renders it against the remote's own bundled React copy). At `16.4.0-canary.39` the production Turbopack leg shares the instance with propagation, so the fixture fails there and discriminates the same commit as the distilled fixture. If the breakage is to be fixed downstream rather than in Turbopack, the fix belongs in the package's `extractSharedModuleIds`/`extractInlineSharedModuleIds`, which must chase re-export facades to their source module before installing the shared instance, and in `getSharedModule`, which must resolve consumers through the same chase. Co-Authored-By: Claude Code (kimi-k3[1m]) <noreply@anthropic.com>
eps1lon
added a commit
that referenced
this pull request
Sep 26, 2026
…d singletons This adds two fixtures documenting the regression introduced by #97770 ("Turbopack: mangle exports behind an escaping namespace via the facade"), the first bad change in `16.4.0-canary.40` (canary.38 and canary.39 produce a single module ID for the package; canary.40 and later split it). #98617 in canary.39 added the compact `e.S([innerId, ...])` re-export encoding the split uses. Starting with canary.40, a dynamic import of a package entry resolves to a re-export facade module while static imports resolve to the inner module, so an instance installed at the manifest-resolved ID never reaches consumers and the singleton silently duplicates. This is what broke the `vercel-docs` remote-components footer theme toggle in production previews. The first fixture, `test/e2e/app-dir/turbopack-facade-module-split`, distills the mechanism into two Next.js apps behind one proxy: a host page installs its copy of a singleton package at the module ID that the remote page's shared manifest resolves, then executes the remote's consumer module through a slim scope require extracted from remote-components' source, and reads which singleton instance each side sees. The extracted machinery mirrors the package's actual source: the module-ID regexes from `patterns.ts`, the manifest resolution and install logic from `shared-modules.ts`, the scope require from `module.ts`, and the chunk loading from `chunk-loader.ts`. The test asserts the current, unintended behavior so CI pins it: production Turbopack builds are expected to resolve the manifest and the consumer import to different module IDs and to show two distinct singleton instances with no state propagation, while development and all webpack modes are expected to share one instance with propagation. These assertions should be flipped to the intended behavior once dynamic-import resolution and static imports agree on a single module ID again. The fixture's dynamic data access sits behind `Suspense` so it also builds with cacheComponents enabled, and the server startup budget is raised because the fixture spawns Next.js child processes. Deploy runs are skipped because the proxy fixture cannot deploy as a single Next.js application. The second fixture, `test/e2e/app-dir/remote-components-facade-module-split`, drives the same scenario through the `remote-components` package itself with no reliance on Next.js or package internals: a host page consumes a remote component that statically imports the same singleton package, and the test reads which instance each side sees and whether a write through the remote's instance reaches the host. Two fixture details matter for fidelity. The remote app bundles its own copy of the package from a vendored path, so the two builds assign distinct module IDs the way two separately deployed apps do; with a single shared copy, Turbopack's specifier-derived IDs line up by coincidence and the split is masked. The fixture pins find-up to the last CommonJS line because remote-components' CommonJS config helper requires it and find-up 6+ is ESM-only, which the Node version CI runs cannot require. The remote page also lazy-loads its consumer component, because the package's initializer scan only inspects top-level chunk push entries, and with the consumer in the entry chunk Turbopack emits that chunk in the nested factory format that hides the initializer, a separate package limitation unrelated to the facade split. On current canary the package fixture pins: production Turbopack shows the duplicate singleton with no propagation (the regression), development Turbopack shares the instance with propagation, and both webpack modes pin that remote-components 0.4.15's webpack runtime cannot mount the component in this fixture at all (development cannot resolve the remote's modules, and production renders it against the remote's own bundled React copy). At `16.4.0-canary.39` the production Turbopack leg shares the instance with propagation, so the fixture fails there and discriminates the same commit as the distilled fixture. If the breakage is to be fixed downstream rather than in Turbopack, the fix belongs in the package's `extractSharedModuleIds`/`extractInlineSharedModuleIds`, which must chase re-export facades to their source module before installing the shared instance, and in `getSharedModule`, which must resolve consumers through the same chase. Co-Authored-By: Claude Code (kimi-k3[1m]) <noreply@anthropic.com>
eps1lon
added a commit
that referenced
this pull request
Sep 26, 2026
…d singletons This adds two fixtures documenting the regression introduced by #97770 ("Turbopack: mangle exports behind an escaping namespace via the facade"), the first bad change in `16.4.0-canary.40` (canary.38 and canary.39 produce a single module ID for the package; canary.40 and later split it). #98617 in canary.39 added the compact `e.S([innerId, ...])` re-export encoding the split uses. Starting with canary.40, a dynamic import of a package entry resolves to a re-export facade module while static imports resolve to the inner module, so an instance installed at the manifest-resolved ID never reaches consumers and the singleton silently duplicates. This is what broke the `vercel-docs` remote-components footer theme toggle in production previews. The first fixture, `test/e2e/app-dir/turbopack-facade-module-split`, distills the mechanism into two Next.js apps behind one proxy: a host page installs its copy of a singleton package at the module ID that the remote page's shared manifest resolves, then executes the remote's consumer module through a slim scope require extracted from remote-components' source, and reads which singleton instance each side sees. The extracted machinery mirrors the package's actual source: the module-ID regexes from `patterns.ts`, the manifest resolution and install logic from `shared-modules.ts`, the scope require from `module.ts`, and the chunk loading from `chunk-loader.ts`. The test asserts the current, unintended behavior so CI pins it: production Turbopack builds are expected to resolve the manifest and the consumer import to different module IDs and to show two distinct singleton instances with no state propagation, while development and all webpack modes are expected to share one instance with propagation. These assertions should be flipped to the intended behavior once dynamic-import resolution and static imports agree on a single module ID again. The fixture's dynamic data access sits behind `Suspense` so it also builds with cacheComponents enabled, and the server startup budget is raised because the fixture spawns Next.js child processes. Deploy runs are skipped because the proxy fixture cannot deploy as a single Next.js application. The second fixture, `test/e2e/app-dir/remote-components-facade-module-split`, drives the same scenario through the `remote-components` package itself with no reliance on Next.js or package internals: a host page consumes a remote component that statically imports the same singleton package, and the test reads which instance each side sees and whether a write through the remote's instance reaches the host. Two fixture details matter for fidelity. The remote app bundles its own copy of the package from a vendored path, so the two builds assign distinct module IDs the way two separately deployed apps do; with a single shared copy, Turbopack's specifier-derived IDs line up by coincidence and the split is masked. The fixture pins find-up to the last CommonJS line because remote-components' CommonJS config helper requires it and find-up 6+ is ESM-only, which the Node version CI runs cannot require. The remote page also lazy-loads its consumer component, because the package's initializer scan only inspects top-level chunk push entries, and with the consumer in the entry chunk Turbopack emits that chunk in the nested factory format that hides the initializer, a separate package limitation unrelated to the facade split. On current canary the package fixture pins: production Turbopack shows the duplicate singleton with no propagation (the regression), development Turbopack shares the instance with propagation, and both webpack modes pin that remote-components 0.4.15's webpack runtime cannot mount the component reliably in this fixture, with the failure mode varying by environment (the development dispatcher cannot resolve the remote's modules, production either renders it against the remote's own bundled React copy or leaves the island inert without an error); when the component does mount, webpack's flat module registry shares the singleton correctly. At `16.4.0-canary.39` the production Turbopack leg shares the instance with propagation, so the fixture fails there and discriminates the same commit as the distilled fixture. If the breakage is to be fixed downstream rather than in Turbopack, the fix belongs in the package's `extractSharedModuleIds`/`extractInlineSharedModuleIds`, which must chase re-export facades to their source module before installing the shared instance, and in `getSharedModule`, which must resolve consumers through the same chase. Co-Authored-By: Claude Code (kimi-k3[1m]) <noreply@anthropic.com>
eps1lon
added a commit
that referenced
this pull request
Sep 26, 2026
…d singletons This adds two fixtures documenting the regression introduced by #97770 ("Turbopack: mangle exports behind an escaping namespace via the facade"), the first bad change in `16.4.0-canary.40` (canary.38 and canary.39 produce a single module ID for the package; canary.40 and later split it). #98617 in canary.39 added the compact `e.S([innerId, ...])` re-export encoding the split uses. Starting with canary.40, a dynamic import of a package entry resolves to a re-export facade module while static imports resolve to the inner module, so an instance installed at the manifest-resolved ID never reaches consumers and the singleton silently duplicates. This is what broke the `vercel-docs` remote-components footer theme toggle in production previews. The first fixture, `test/e2e/app-dir/turbopack-facade-module-split`, distills the mechanism into two Next.js apps behind one proxy: a host page installs its copy of a singleton package at the module ID that the remote page's shared manifest resolves, then executes the remote's consumer module through a slim scope require extracted from remote-components' source, and reads which singleton instance each side sees. The extracted machinery mirrors the package's actual source: the module-ID regexes from `patterns.ts`, the manifest resolution and install logic from `shared-modules.ts`, the scope require from `module.ts`, and the chunk loading from `chunk-loader.ts`. The test asserts the current, unintended behavior so CI pins it: production Turbopack builds are expected to resolve the manifest and the consumer import to different module IDs and to show two distinct singleton instances with no state propagation, while development and all webpack modes are expected to share one instance with propagation. These assertions should be flipped to the intended behavior once dynamic-import resolution and static imports agree on a single module ID again. The fixture's dynamic data access sits behind `Suspense` so it also builds with cacheComponents enabled, and the server startup budget is raised because the fixture spawns Next.js child processes. Deploy runs are skipped because the proxy fixture cannot deploy as a single Next.js application. The second fixture, `test/e2e/app-dir/remote-components-facade-module-split`, drives the same scenario through the `remote-components` package itself with no reliance on Next.js or package internals: a host page consumes a remote component that statically imports the same singleton package, and the test reads which instance each side sees and whether a write through the remote's instance reaches the host. Two fixture details matter for fidelity. The remote app bundles its own copy of the package from a vendored path, so the two builds assign distinct module IDs the way two separately deployed apps do; with a single shared copy, Turbopack's specifier-derived IDs line up by coincidence and the split is masked. The fixture pins find-up to the last CommonJS line because remote-components' CommonJS config helper requires it and find-up 6+ is ESM-only, which the Node version CI runs cannot require. The remote page also lazy-loads its consumer component, because the package's initializer scan only inspects top-level chunk push entries, and with the consumer in the entry chunk Turbopack emits that chunk in the nested factory format that hides the initializer, a separate package limitation unrelated to the facade split. On current canary the package fixture pins: production Turbopack shows the duplicate singleton with no propagation (the regression), development Turbopack shares the instance with propagation, and both webpack modes pin that remote-components 0.4.15's webpack runtime cannot mount the component reliably in this fixture, with the failure mode varying by environment (the development dispatcher cannot resolve the remote's modules, and production either crashes the component against the remote's own bundled React copy, mounts it against a duplicate of the singleton, or leaves the island inert without an error); writes through the remote's instance never reach the host in any of them. At `16.4.0-canary.39` the production Turbopack leg shares the instance with propagation, so the fixture fails there and discriminates the same commit as the distilled fixture. If the breakage is to be fixed downstream rather than in Turbopack, the fix belongs in the package's `extractSharedModuleIds`/`extractInlineSharedModuleIds`, which must chase re-export facades to their source module before installing the shared instance, and in `getSharedModule`, which must resolve consumers through the same chase. Co-Authored-By: Claude Code (kimi-k3[1m]) <noreply@anthropic.com>
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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
Adds a compact Turbopack runtime path for modules whose exports are entirely forwarded from other modules. It replaces one generated wrapper getter per binding with a flat re-export description, while preserving live-binding semantics and source evaluation order.
This revision also makes the optimization generic for synthetic re-export facades, rather than leaving facade integration in the stacked export-mangling PR:
ExportRegistrationMode::Reexport;ImportMap;The runtime now inspects source property descriptors. Constant exports reuse the source value, live exports reuse the exact receiver-independent Turbopack namespace getter, and dynamic/proxy/missing properties fall back to a wrapper getter. Destination descriptors remain enumerable, non-configurable, read-only ESM namespace properties; setters are never forwarded.
Output examples
A re-export-only module can suppress its redundant import and use a module-id group:
Two source modules become groups separated by
0:Encoding is selected independently per group. A comma-containing name uses explicit pairs while another group can remain packed:
A synthetic facade includes its evaluation-only locals part as an empty group, then its forwarded bindings in reference order:
Inside a scope-hoisted factory, imports are retained and existing namespace objects are used as heads because another logical module in the merged factory may read them:
A merged evaluation-only locals reference is omitted from that list: scope hoisting supplies only an in-factory ordering placeholder, not a namespace variable that
Scould read.Runtime representation
For each forwarded property,
Sreads the source descriptor:() => namespace[name]as a defensive fallback.Tests pin constant primitives, frozen objects, function values, live updates, exact getter identity, receiver independence, destination descriptor flags, omitted setters, and the fallback path.
Verification
cargo test -p turbopack-ecmascript -p turbopack-tests— 590 unit, 302 execution, 3 auxiliary, 131 snapshot tests passed on this layercargo clippy -p turbopack-ecmascript -p turbopack-core -- -D warningscargo fmt --all -- --checkpnpm checkinturbopack-ecmascript-runtime/js)pnpm build-allDUMMY_SPdeclarations, callable CommonJS namespaces, synthetic facade ordering, scope-hoisted retained imports, pruned and merged evaluation references, async fallback, and export-cycle circuit breakersIncremental size on top of #98932
Scratch comparison only, as requested: #98617 stayed based on
canary. Three apps were built with scope hoisting both disabled and enabled. Client reports raw and deterministic gzip (gzip -9 -n, sorted JS files); server reports raw only. Every row was repeated byte-for-byte.Ssites (client/server)basic-appbasic-appheavy-npm-deps(lodash + Mantine + Mermaid)heavy-npm-deps(lodash + Mantine + Mermaid)module-costmodule-costAggregate raw movement across the six rows is +32,164 B. The shared helper is only amortized in configurations with many eligible facade calls; scope hoisting commonly resolves forwarded bindings to locals, leaving few calls while every runtime copy still carries the helper.
Descriptor reuse does not change generated module code or call-site counts. Compared with the same facade integration using wrapper getters, it adds 155 B client raw and 527 B per runtime copy (1,054 B server raw in these apps). Its purpose is avoiding per-binding wrapper allocation / access indirection where a source value or getter already exists, not reducing bundle bytes. The size result was reported before continuing, and descriptor work proceeded by explicit maintainer direction.