Skip to content

Turbopack: compact re-export registration - #98617

Merged
sokra merged 16 commits into
canaryfrom
fleet/turbopack-compact-reexport-registration
Sep 22, 2026
Merged

sokra merged 16 commits into
canaryfrom
fleet/turbopack-compact-reexport-registration

Conversation

@sokra

@sokra sokra commented Sep 13, 2026 •

Copy link
Copy Markdown
Member

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:

  • synthetic facades use ExportRegistrationMode::Reexport;
  • their explicit reference list supplies ordering because they have no source ImportMap;
  • an evaluation-only locals reference can be represented as an empty group;
  • references whose import is performed by the compact registration do not emit it twice;
  • pruned references and merged evaluation-only references are omitted, because they deliberately produce no namespace binding and may have no module factory in the chunk;
  • async modules and known export-cycle circuit breakers retain the general registration path.

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:

// before
var ns = __turbopack_context__.i("a.js")
__turbopack_context__.s(["a", () => ns.a, "b", () => ns.b])

// after
__turbopack_context__.S(["a.js", "a,a,b,b"])

Two source modules become groups separated by 0:

__turbopack_context__.S([
  "first.js", "a,a,b,b",
  0,
  "second.js", "c,c",
])

Encoding is selected independently per group. A comma-containing name uses explicit pairs while another group can remain packed:

__turbopack_context__.S([
  "first.js", "b", "b", "has,comma", "has,comma",
  0,
  "second.js", "c,c",
])

A synthetic facade includes its evaluation-only locals part as an empty group, then its forwarded bindings in reference order:

__turbopack_context__.S([
  "reexports.js <locals>",
  0,
  "x.js", "x,x",
  0,
  "y.js", "y,y",
])

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:

var locals = __turbopack_context__.i("reexports.js <locals>")
var x = __turbopack_context__.i("x.js")
__turbopack_context__.S([locals, 0, x, "x,x"], facadeId)

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 S could read.

Runtime representation

For each forwarded property, S reads the source descriptor:

  • data descriptor → capture the value with the runtime's value tag;
  • getter descriptor → reuse that exact getter (Turbopack-generated namespace getters are receiver-independent);
  • no usable own descriptor → use () => 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 layer
  • Full stacked Turbopack: mangle exports behind an escaping namespace via the facade #97770 verification — 590 unit, 303 execution, 3 auxiliary, 131 snapshot tests passed
  • cargo clippy -p turbopack-ecmascript -p turbopack-core -- -D warnings
  • cargo fmt --all -- --check
  • Runtime TypeScript check (pnpm check in turbopack-ecmascript-runtime/js)
  • pnpm build-all
  • Dedicated coverage for ordinary import/re-export ordering, transformed DUMMY_SP declarations, callable CommonJS namespaces, synthetic facade ordering, scope-hoisted retained imports, pruned and merged evaluation references, async fallback, and export-cycle circuit breakers

Incremental 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.

benchmark scope hoisting client raw client gzip server raw real S sites (client/server)
basic-app off +510 B +214 B −6,114 B 0 / 56
basic-app on +510 B +164 B +8,618 B 0 / 0
heavy-npm-deps (lodash + Mantine + Mermaid) off −2,268 B −432 B +4,680 B 3 / 8
heavy-npm-deps (lodash + Mantine + Mermaid) on +522 B +191 B +8,630 B 2 / 2
module-cost off +510 B +263 B +7,432 B 0 / 6
module-cost on +510 B +265 B +8,624 B 0 / 1

Aggregate 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.

@sokra
sokra force-pushed the fleet/turbopack-compact-reexport-registration branch from af0f0e3 to 7b42ee6 Compare September 13, 2026 19:37
@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: 99b6050

@sokra
sokra force-pushed the fleet/turbopack-compact-reexport-registration branch from 7b42ee6 to aa36f2c Compare September 14, 2026 06:31
Comment thread turbopack/crates/turbopack-ecmascript/src/references/esm/export.rs Outdated
Comment thread turbopack/crates/turbopack-ecmascript/src/references/esm/export.rs
@sokra
sokra force-pushed the fleet/turbopack-compact-reexport-registration branch from aa36f2c to d81514c Compare September 14, 2026 10:06
Base automatically changed from codex/turbopack-strict-chunk-factories to canary September 14, 2026 10:34
@sokra
sokra force-pushed the fleet/turbopack-compact-reexport-registration branch 2 times, most recently from 6603f83 to 9ee8121 Compare September 15, 2026 07:35
@sokra
sokra marked this pull request as ready for review September 15, 2026 09:20
@sokra
sokra force-pushed the fleet/turbopack-compact-reexport-registration branch 3 times, most recently from 064eca0 to 879129c Compare September 16, 2026 13:34
@sokra
sokra requested a review from mischnic September 17, 2026 11:49
Comment thread turbopack/crates/turbopack-ecmascript/src/analyzer/imports.rs Outdated
@sokra
sokra removed this pull request from stack #98618 September 17, 2026 19:19
@sokra
sokra added this pull request to stack #98839 September 17, 2026 19:27
@sokra
sokra force-pushed the fleet/turbopack-compact-reexport-registration branch from 20f3e67 to 3084b04 Compare September 17, 2026 20:36

@lukesandberg lukesandberg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@sokra

sokra commented Sep 21, 2026 •

Copy link
Copy Markdown
Member Author

Before:

var ns = e.i(123)
e.s(['bar', () => ns.foo])

#98603:

var ns = e.i(123)
e.s(['bar', 0, ns.foo])

#98932:

var ns = e.i(123)
e.s(['bar', ns.foo])

This PR:

e.S([123, 'bar,foo']

So there is still some optimization on top of #98603 and #98932.

@sokra
sokra requested a review from lukesandberg September 21, 2026 06:02
@sokra

sokra commented Sep 21, 2026

Copy link
Copy Markdown
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.

Comment thread turbopack/crates/turbopack-core/src/module_graph/binding_usage_info.rs Outdated
Comment thread turbopack/crates/turbopack-ecmascript/src/references/esm/export.rs Outdated
Comment thread turbopack/crates/turbopack-ecmascript/src/runtime_functions.rs Outdated
vercel-fleet-prod Bot and others added 6 commits September 22, 2026 07:00
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
sokra force-pushed the fleet/turbopack-compact-reexport-registration branch from 515126c to 57a2d42 Compare September 22, 2026 07:00
Comment thread turbopack/crates/turbopack-core/src/module_graph/binding_usage_info.rs Outdated
Comment thread turbopack/crates/turbopack-ecmascript/src/references/esm/export.rs Outdated
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
@sokra
sokra merged commit 812c88f into canary Sep 22, 2026
101 checks passed
@sokra
sokra deleted the fleet/turbopack-compact-reexport-registration branch September 22, 2026 10:59
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 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>
@github-actions github-actions Bot added the locked label Oct 6, 2026
@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Oct 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants