Skip to content

Turbopack: mangle exports behind an escaping namespace via the facade - #97770

Open
sokra wants to merge 17 commits into
fleet/turbopack-compact-reexport-registrationfrom
fleet/turbopack-mangle-materialized-namespace
Open

Turbopack: mangle exports behind an escaping namespace via the facade#97770
sokra wants to merge 17 commits into
fleet/turbopack-compact-reexport-registrationfrom
fleet/turbopack-mangle-materialized-namespace

Conversation

@sokra

@sokra sokra commented Aug 24, 2026 •

Copy link
Copy Markdown
Member

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, whose compact re-export registration removes most of the facade's generated overhead. A common single-source facade now performs one compact registration by module id instead of emitting an import variable and one arrow-function getter per export.

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 the shorter names.

How?

The facade's synthesized per-export references now participate in the module graph, so named usage can propagate 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 and suppression include the SWC syntax context so scope-hoisted modules cannot accidentally share or suppress the wrong generated binding.

Testing

  • cargo test -p turbopack-ecmascript -p turbopack-tests — 569 crate, 290 execution, and 128 snapshot tests passed on the current base
  • cargo clippy -p turbopack-ecmascript -p turbopack-core -- -D warnings
  • cargo fmt --all -- --check
  • Existing materialized-namespace fixtures verify namespace values, key enumeration, dynamic imports, CommonJS consumers, and liveness through the locals module
  • bench/basic-app, scope hoisting disabled, comparing Turbopack: compact re-export registration #98617 to this PR: compact registrations 6 → 771; client +6,897 B raw / +1,673 B gzip; server −3,238 B raw (gzip is not meaningful for server code, which Node.js consumes uncompressed)

Co-authored-by: Luke Sandberg 210140+lukesandberg@users.noreply.github.com

@github-actions

github-actions Bot commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Stats from current PR

✅ No significant changes detected

📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

Metric Canary PR Change Trend
Cold (Listen) 814ms 813ms ✓ ▁▁██▁
Cold (Ready in log) 802ms 808ms ✓ ▁▁██▂
Cold (First Request) 1.274s 1.311s ✓ ▁▁██▂
Warm (Listen) 813ms 814ms ✓ ▂▂██▂
Warm (Ready in log) 801ms 815ms ✓ ▁▁██▂
Warm (First Request) 1.272s 1.309s ✓ ▁▁██▃
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change Trend
Cold (Listen) 813ms 813ms ✓ ▁▁█▁▁
Cold (Ready in log) 809ms 808ms ✓ ▂▁█▁▁
Cold (First Request) 3.404s 3.395s ✓ ▂▁█▁▁
Warm (Listen) 814ms 813ms ✓ ▁▁█▁▁
Warm (Ready in log) 813ms 804ms ✓ ▂▁█▁▁
Warm (First Request) 3.439s 3.438s ✓ ▂▁█▁▂

⚡ Production Builds

Metric Canary PR Change Trend
Fresh Build 5.692s 5.926s ✓ ▁▂██▃
Cached Build 2.886s 2.936s ✓ ▁▁██▃
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 24.671s 24.622s ✓ ▂▁█▁▁
Cached Build 25.083s 24.939s ✓ ▂▁█▁▁
node_modules Size 558 MB 558 MB ✓ █████
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles
Canary PR Change
017_4moyv5m34.js gzip 117 B 117 B ✓
05jocc85-b73_.js gzip 341 B N/A -
0cz1d0mv5g_q7.js gzip 39.4 kB 39.4 kB ✓
0e5lgz-xcf6ln.js gzip 273 B 273 B ✓
0enhddgqs2dl-.js gzip 265 B 265 B ✓
0h34mejwa_7pd.js gzip 354 B 354 B ✓
0w853zxni3ro0.js gzip 1.36 kB N/A -
13lq2aw3_0bgw.js gzip 3.52 kB N/A -
19ll0d77yn0fq.js gzip 71.5 kB N/A -
1elt1qium-r2m.css gzip 115 B 115 B ✓
1m5vdnlf0deiv.js gzip 411 B 411 B ✓
1nigpy_mr0xvj.js gzip 339 B N/A -
1p0l8mn2wz5f2.js gzip 1.97 kB N/A -
1ph_nbsm967ot.js gzip 152 B N/A -
1s-3t9k2_uhom.js gzip 117 B N/A -
1sqoyy39jpgkq.js gzip 121 B 121 B ✓
1tf1phijqlx9j.js gzip 220 B 220 B ✓
2-54v5vj7orwp.js gzip 46.8 kB N/A -
2b05_424u1zq-.js gzip 120 B N/A -
2ca40xittx95r.js gzip 8.75 kB N/A -
2hgz42b8sldyc.js gzip 5.85 kB N/A -
2kci0vnt-49o-.js gzip 113 B 113 B ✓
2sxgl_rcrrn_p.js gzip 399 B N/A -
2u_p__ndkooh-.js gzip 5.66 kB N/A -
2vt62i17bhbdu.js gzip 2.35 kB N/A -
33ykhou7tl-4h.js gzip 7.53 kB N/A -
342di-crrg9kq.js gzip 119 B N/A -
36hfc2_979aw2.js gzip 119 B N/A -
38wfp6jjrrdfm.js gzip 131 B 131 B ✓
3dl1ilk37-30g.js gzip 117 B N/A -
3h0us7yy7bd_o.js gzip 13.1 kB N/A -
3kvxagoobts7h.js gzip 117 B N/A -
3p99hd9vwe6ae.js gzip 13 kB N/A -
3r0475panhk6m.js gzip 123 B N/A -
3s0i-weykgzrt.js gzip 125 B N/A -
3wijk2ywqgsv9.js gzip 2.52 kB N/A -
3xuguv9lsoqw7.js gzip 65.5 kB N/A -
41u5s3oe2-erp.js gzip 2.29 kB N/A -
turbopack-0d..9ejy.js gzip 3.74 kB 3.74 kB ✓
02zc7sqlpqxn3.js gzip N/A 119 B -
0dw-iyxdctoxo.js gzip N/A 65.5 kB -
0e28eh3gbwmp0.js gzip N/A 3.51 kB -
0e5ldm8_fmypq.js gzip N/A 71.5 kB -
0jwho9fkrb_t3.js gzip N/A 2.29 kB -
0longconn5mlm.js gzip N/A 123 B -
0p4l3ozbez2cz.js gzip N/A 2.35 kB -
0sbh_15ejop4o.js gzip N/A 13 kB -
0skdm-9rpid8g.js gzip N/A 117 B -
0wv-h56sz83wn.js gzip N/A 120 B -
137xup2t69ovc.js gzip N/A 1.97 kB -
1fk10s3m1oecx.js gzip N/A 400 B -
20qhkyyj2_tla.js gzip N/A 116 B -
25lb_468wpv8t.js gzip N/A 117 B -
2imyufe18bsb7.js gzip N/A 1.36 kB -
2p4n3-8-xixh_.js gzip N/A 341 B -
2vqdfieehz5do.js gzip N/A 8.75 kB -
2xn85ykzw2v1e.js gzip N/A 13.1 kB -
34knadd6wu9qg.js gzip N/A 119 B -
35eub0y5wk0-c.js gzip N/A 7.53 kB -
37-g1kp39exux.js gzip N/A 5.85 kB -
37b0_t16yn97f.js gzip N/A 153 B -
39jq2b6t6z6up.js gzip N/A 339 B -
3a9a9tvkag_-j.js gzip N/A 2.52 kB -
3d61tgick8g6b.js gzip N/A 46.8 kB -
3hk-fj9wj-l1s.js gzip N/A 5.66 kB -
3t121tj-h0kbm.js gzip N/A 125 B -
Total 299 kB 299 kB ⚠️ +4 B

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 1.02 kB 1.02 kB ✓
Total 1.02 kB 1.02 kB ✓
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 846 B 846 B ✓
Total 846 B 846 B ✓
Build Cache
Canary PR Change
00000001.sst gzip 14.1 MB 15.9 MB 🔴 +1.72 MB (+12%)
00000002.sst gzip 12.9 MB 13 MB 🔴 +129 kB (+1%)
00000003.sst gzip 14.9 MB 13.6 MB 🟢 1.25 MB (-8%)
00000004.sst gzip 13 MB 12.3 MB 🟢 633 kB (-5%)
00000005.sst gzip 10.1 MB 10.1 MB 🔴 +39.5 kB (+0%)
00000006.sst gzip 2.75 MB 2.75 MB ✓
00000007.sst gzip 59 B 59 B ✓
00000008.meta gzip 89 B 89 B ✓
00000009.meta gzip 297 kB 297 kB ✓
00000010.meta gzip 297 kB 297 kB ✓
00000011.meta gzip 297 kB 297 kB ✓
00000012.sst gzip 53.4 kB 53.2 kB ✓
00000013.sst gzip 1.73 MB 1.73 MB ✓
00000014.sst gzip 59 B 59 B ✓
00000015.meta gzip 116 B 116 B ✓
00000016.meta gzip 327 kB 327 kB ✓
00000017.meta gzip 403 kB 403 kB ✓
00000018.sst gzip 54.3 kB 53.8 kB ✓
00000019.sst gzip 1.37 MB 1.38 MB 🔴 +5.7 kB (+0%)
00000020.sst gzip 59 B 59 B ✓
00000021.meta gzip 116 B 116 B ✓
00000022.meta gzip 327 kB 327 kB ✓
00000023.meta gzip 372 kB 372 kB ✓
00000024.sst gzip 54.3 kB 54 kB ✓
00000025.sst gzip 1.37 MB 1.37 MB 🔴 +6.31 kB (+0%)
00000026.sst gzip 59 B 59 B ✓
00000027.meta gzip 116 B 116 B ✓
00000028.meta gzip 327 kB 327 kB ✓
00000029.meta gzip 372 kB 372 kB ✓
00000030.sst gzip 53.5 kB 53.4 kB ✓
00000031.sst gzip 1.37 MB 1.38 MB 🔴 +6.42 kB (+0%)
00000032.sst gzip 59 B 59 B ✓
00000033.meta gzip 116 B 116 B ✓
00000034.meta gzip 327 kB 327 kB ✓
00000035.meta gzip 372 kB 372 kB ✓
00000036.sst gzip 54.3 kB 53.8 kB ✓
00000037.sst gzip 1.37 MB 1.38 MB 🔴 +6.16 kB (+0%)
00000038.sst gzip 59 B 59 B ✓
00000039.meta gzip 116 B 116 B ✓
00000040.meta gzip 327 kB 327 kB ✓
00000041.meta gzip 372 kB 372 kB ✓
CURRENT gzip 94 B 94 B ✓
LOG gzip 668 B 669 B ✓
Total 79.6 MB 79.6 MB ⚠️ +34.8 kB

📦 Webpack

Client

Main Bundles
Canary PR Change
3322-HASH.js gzip 65.7 kB N/A -
4191.HASH.js gzip 169 B N/A -
9784-HASH.js gzip 5.57 kB N/A -
b1ad9f4c-HASH.js gzip 63.5 kB N/A -
framework-HASH.js gzip 59.7 kB 59.7 kB ✓
main-app-HASH.js gzip 252 B 254 B ✓
main-HASH.js gzip 39.8 kB 39.7 kB ✓
webpack-HASH.js gzip 1.68 kB 1.68 kB ✓
3577.HASH.js gzip N/A 168 B -
578-HASH.js gzip N/A 65.8 kB -
8590-HASH.js gzip N/A 5.53 kB -
a8984546-HASH.js gzip N/A 63.5 kB -
Total 236 kB 236 kB ⚠️ +108 B
Polyfills
Canary PR Change
polyfills-HASH.js gzip 39.4 kB 39.4 kB ✓
Total 39.4 kB 39.4 kB ✓
Pages
Canary PR Change
_app-HASH.js gzip 194 B 193 B ✓
_error-HASH.js gzip 181 B 182 B ✓
css-HASH.js gzip 334 B 331 B ✓
dynamic-HASH.js gzip 1.81 kB 1.81 kB ✓
edge-ssr-HASH.js gzip 255 B 253 B ✓
head-HASH.js gzip 349 B 351 B ✓
hooks-HASH.js gzip 382 B 384 B ✓
image-HASH.js gzip 5.02 kB 5.01 kB ✓
index-HASH.js gzip 260 B 259 B ✓
link-HASH.js gzip 2.48 kB 2.48 kB ✓
routerDirect..HASH.js gzip 317 B 318 B ✓
script-HASH.js gzip 384 B 386 B ✓
withRouter-HASH.js gzip 316 B 315 B ✓
1afbb74e6ecf..834.css gzip 106 B 106 B ✓
Total 12.4 kB 12.4 kB ✓

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 128 kB 128 kB ✓
page.js gzip 293 kB 293 kB ✓
Total 421 kB 421 kB ✅ -431 B
Middleware
Canary PR Change
middleware-b..fest.js gzip 598 B 601 B ✓
middleware-r..fest.js gzip 156 B 156 B ✓
middleware.js gzip 45.9 kB 45.3 kB 🟢 537 B (-1%)
edge-runtime..pack.js gzip 842 B 842 B ✓
Total 47.5 kB 46.9 kB ✅ -534 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 698 B 698 B ✓
Total 698 B 698 B ✓
Build Cache
Canary PR Change
0.pack gzip 4.8 MB 4.79 MB 🟢 10.1 kB (0%)
index.pack gzip 124 kB 124 kB ✓
index.pack.old gzip 124 kB 122 kB 🟢 1.44 kB (-1%)
Total 5.05 MB 5.04 MB ✅ -12.1 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 377 kB 377 kB ✓
app-page-exp..prod.js gzip 207 kB 207 kB ✓
app-page-tur...dev.js gzip 377 kB 377 kB ✓
app-page-tur..prod.js gzip 207 kB 207 kB ✓
app-page-tur...dev.js gzip 373 kB 373 kB ✓
app-page-tur..prod.js gzip 205 kB 205 kB ✓
app-page.run...dev.js gzip 373 kB 373 kB ✓
app-page.run..prod.js gzip 205 kB 205 kB ✓
app-route-ex...dev.js gzip 82.9 kB 82.9 kB ✓
app-route-ex..prod.js gzip 55.8 kB 55.8 kB ✓
app-route-tu...dev.js gzip 82.9 kB 82.9 kB ✓
app-route-tu..prod.js gzip 55.9 kB 55.9 kB ✓
app-route-tu...dev.js gzip 82.5 kB 82.5 kB ✓
app-route-tu..prod.js gzip 55.6 kB 55.6 kB ✓
app-route.ru...dev.js gzip 82.4 kB 82.4 kB ✓
app-route.ru..prod.js gzip 55.6 kB 55.6 kB ✓
dev-validati...dev.js gzip 139 kB 139 kB ✓
dev-validati...dev.js gzip 139 kB 139 kB ✓
dev-validati...dev.js gzip 137 kB 137 kB ✓
dev-validati...dev.js gzip 137 kB 137 kB ✓
dist_client_...dev.js gzip 324 B 324 B ✓
dist_client_...dev.js gzip 326 B 326 B ✓
dist_client_...dev.js gzip 318 B 318 B ✓
dist_client_...dev.js gzip 317 B 317 B ✓
pages-api-tu...dev.js gzip 46.3 kB 46.3 kB ✓
pages-api-tu..prod.js gzip 34.3 kB 34.3 kB ✓
pages-api.ru...dev.js gzip 46.2 kB 46.2 kB ✓
pages-api.ru..prod.js gzip 34.3 kB 34.3 kB ✓
pages-turbo....dev.js gzip 54.7 kB 54.7 kB ✓
pages-turbo...prod.js gzip 39.7 kB 39.7 kB ✓
pages.runtim...dev.js gzip 54.7 kB 54.7 kB ✓
pages.runtim..prod.js gzip 39.6 kB 39.6 kB ✓
server.runti..prod.js gzip 66 kB 66.1 kB ✓
use-cache-pr...dev.js gzip 73.4 kB 73.4 kB ✓
use-cache-pr...dev.js gzip 73.4 kB 73.4 kB ✓
use-cache-pr...dev.js gzip 71.7 kB 71.7 kB ✓
use-cache-pr...dev.js gzip 71.6 kB 71.6 kB ✓
Total 4.14 MB 4.14 MB ⚠️ +27 B
📝 Changed Files (1 file)

Files with changes:

  • server.runtime.prod.js
View diffs
server.runtime.prod.js

Diff too large to display

📎 Tarball URL
https://vercel-packages.vercel.app/next/commits/af94b105b6c9175d23d90cb4f3ff56852ada435c/next

Commit: af94b10

@github-actions

github-actions Bot commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Failing test suites

Commit: d216200 | About building and testing Next.js

pnpm test-start-experimental-turbo test/production/app-dir/barrel-optimization/mui/index.test.ts (turbopack) (Experimental) (job)

  • Skipped in Turbopack > app-dir - optimizePackageImports - mui > should build successfully (DD)
Expand output

● Skipped in Turbopack › app-dir - optimizePackageImports - mui › should build successfully

next build failed with code/signal 1

  137 |             if (code || signal)
  138 |               reject(
> 139 |                 new Error(
      |                 ^
  140 |                   `next build failed with code/signal ${code || signal}`
  141 |                 )
  142 |               )

  at ChildProcess.<anonymous> (lib/next-modes/next-start.ts:139:17)

pnpm test-dev test/development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts (job)

  • instant-nav-panel > SPA captures > should show loading skeleton during SPA navigation after clicking Start (DD)
  • instant-nav-panel > SPA captures > should reset the panel and app when pressing the close button from captured SPA state (DD)
  • instant-nav-panel > history traversals > re-arms the capture when navigating back from a captured SPA navigation (DD)
  • instant-nav-panel > history traversals > shows real content on forward after back while awaiting navigation (DD)
  • instant-nav-panel > transitions between capture types > should keep the panel in MPA state after capture -> reload and then update to SPA state after client navigation (DD)
Expand output

● instant-nav-panel › SPA captures › should show loading skeleton during SPA navigation after clicking Start

locator.waitFor: Timeout 60000ms exceeded.
Call log:
  - waiting for locator('[data-testid="dynamic-skeleton"]') to be visible

  624 |       await browser
  625 |         .locator('[data-testid="dynamic-skeleton"]')
> 626 |         .waitFor({ state: 'visible' })
      |          ^
  627 |       expect(
  628 |         await browser.locator('[data-testid="dynamic-content"]').count()
  629 |       ).toBe(0)

  at Object.waitFor (development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts:626:10)

● instant-nav-panel › SPA captures › should reset the panel and app when pressing the close button from captured SPA state

locator.waitFor: Timeout 60000ms exceeded.
Call log:
  - waiting for locator('[data-testid="dynamic-skeleton"]') to be visible

  278 |     await browser
  279 |       .locator('[data-testid="dynamic-skeleton"]')
> 280 |       .waitFor({ state: 'visible' })
      |        ^
  281 |     await browser
  282 |       .locator('[data-testid="param-skeleton"]')
  283 |       .waitFor({ state: 'visible' })

  at waitFor (development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts:280:8)
  at Object.expectTargetPageSpaShell (development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts:675:13)

● instant-nav-panel › transitions between capture types › should keep the panel in MPA state after capture -> reload and then update to SPA state after client navigation

locator.waitFor: Timeout 60000ms exceeded.
Call log:
  - waiting for locator('[data-testid="dynamic-skeleton"]') to be visible

  278 |     await browser
  279 |       .locator('[data-testid="dynamic-skeleton"]')
> 280 |       .waitFor({ state: 'visible' })
      |        ^
  281 |     await browser
  282 |       .locator('[data-testid="param-skeleton"]')
  283 |       .waitFor({ state: 'visible' })

  at waitFor (development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts:280:8)
  at Object.expectTargetPageSpaShell (development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts:794:13)

● instant-nav-panel › history traversals › re-arms the capture when navigating back from a captured SPA navigation

locator.waitFor: Timeout 60000ms exceeded.
Call log:
  - waiting for locator('[data-testid="dynamic-skeleton"]') to be visible

  278 |     await browser
  279 |       .locator('[data-testid="dynamic-skeleton"]')
> 280 |       .waitFor({ state: 'visible' })
      |        ^
  281 |     await browser
  282 |       .locator('[data-testid="param-skeleton"]')
  283 |       .waitFor({ state: 'visible' })

  at waitFor (development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts:280:8)
  at Object.expectTargetPageSpaShell (development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts:834:13)

● instant-nav-panel › history traversals › shows real content on forward after back while awaiting navigation

locator.waitFor: Timeout 60000ms exceeded.
Call log:
  - waiting for locator('[data-testid="dynamic-skeleton"]') to be visible

  278 |     await browser
  279 |       .locator('[data-testid="dynamic-skeleton"]')
> 280 |       .waitFor({ state: 'visible' })
      |        ^
  281 |     await browser
  282 |       .locator('[data-testid="param-skeleton"]')
  283 |       .waitFor({ state: 'visible' })

  at waitFor (development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts:280:8)
  at Object.expectTargetPageSpaShell (development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts:870:13)

pnpm test-start-turbo test/production/app-dir/barrel-optimization/mui/index.test.ts (turbopack) (job)

  • Skipped in Turbopack > app-dir - optimizePackageImports - mui > should build successfully (DD)
Expand output

● Skipped in Turbopack › app-dir - optimizePackageImports - mui › should build successfully

next build failed with code/signal 1

  137 |             if (code || signal)
  138 |               reject(
> 139 |                 new Error(
      |                 ^
  140 |                   `next build failed with code/signal ${code || signal}`
  141 |                 )
  142 |               )

  at ChildProcess.<anonymous> (lib/next-modes/next-start.ts:139:17)

pnpm test-start-turbo test/e2e/app-dir/cache-components-allow-otel-spans/cache-components-allow-otel-spans.test.ts (turbopack) (job)

  • cache-components OTEL spans > should allow creating Spans during prerendering during the build - inside a Cache Components (DD)
  • cache-components OTEL spans > should allow creating Spans during prerendering at runtime - inside a Cache Components (DD)
  • cache-components OTEL spans > should allow creating Spans during resuming a fallback - inside a Cache Component (DD)
  • cache-components OTEL spans > should allow creating Spans from a tracer acquired before provider registration (DD)
Expand output

● cache-components OTEL spans › should allow creating Spans during prerendering during the build - inside a Cache Components

page.waitForSelector: Timeout 5000ms exceeded.
Call log:
  - waiting for locator('#t7 .span') to be visible

  583 |
  584 |     return this.startChain(async () => {
> 585 |       const el = await page.waitForSelector(selector, {
      |                             ^
  586 |         timeout,
  587 |         state,
  588 |       })

  at waitForSelector (lib/browsers/playwright.ts:585:29)
  at Playwright._chain (lib/browsers/playwright.ts:715:23)
  at Playwright._chain [as startChain] (lib/browsers/playwright.ts:696:17)
  at Playwright.startChain [as waitForElementByCss] (lib/browsers/playwright.ts:584:17)
  at Playwright.waitForElementByCss [as elementByCss] (lib/browsers/playwright.ts:469:17)
  at Object.elementByCss (e2e/app-dir/cache-components-allow-otel-spans/cache-components-allow-otel-spans.test.ts:231:34)

● cache-components OTEL spans › should allow creating Spans during prerendering at runtime - inside a Cache Components

page.waitForSelector: Timeout 5000ms exceeded.
Call log:
  - waiting for locator('#t7 .span') to be visible

  583 |
  584 |     return this.startChain(async () => {
> 585 |       const el = await page.waitForSelector(selector, {
      |                             ^
  586 |         timeout,
  587 |         state,
  588 |       })

  at waitForSelector (lib/browsers/playwright.ts:585:29)
  at Playwright._chain (lib/browsers/playwright.ts:715:23)
  at Playwright._chain [as startChain] (lib/browsers/playwright.ts:696:17)
  at Playwright.startChain [as waitForElementByCss] (lib/browsers/playwright.ts:584:17)
  at Playwright.waitForElementByCss [as elementByCss] (lib/browsers/playwright.ts:469:17)
  at Object.elementByCss (e2e/app-dir/cache-components-allow-otel-spans/cache-components-allow-otel-spans.test.ts:289:34)

● cache-components OTEL spans › should allow creating Spans during resuming a fallback - inside a Cache Component

page.waitForSelector: Timeout 5000ms exceeded.
Call log:
  - waiting for locator('#t7 .span') to be visible

  583 |
  584 |     return this.startChain(async () => {
> 585 |       const el = await page.waitForSelector(selector, {
      |                             ^
  586 |         timeout,
  587 |         state,
  588 |       })

  at waitForSelector (lib/browsers/playwright.ts:585:29)
  at Playwright._chain (lib/browsers/playwright.ts:715:23)
  at Playwright._chain [as startChain] (lib/browsers/playwright.ts:696:17)
  at Playwright.startChain [as waitForElementByCss] (lib/browsers/playwright.ts:584:17)
  at Playwright.waitForElementByCss [as elementByCss] (lib/browsers/playwright.ts:469:17)
  at Object.elementByCss (e2e/app-dir/cache-components-allow-otel-spans/cache-components-allow-otel-spans.test.ts:358:34)

● cache-components OTEL spans › should allow creating Spans from a tracer acquired before provider registration

page.waitForSelector: Timeout 5000ms exceeded.
Call log:
  - waiting for locator('#t9 .result') to be visible

  583 |
  584 |     return this.startChain(async () => {
> 585 |       const el = await page.waitForSelector(selector, {
      |                             ^
  586 |         timeout,
  587 |         state,
  588 |       })

  at waitForSelector (lib/browsers/playwright.ts:585:29)
  at Playwright._chain (lib/browsers/playwright.ts:715:23)
  at Playwright._chain [as startChain] (lib/browsers/playwright.ts:696:17)
  at Playwright.startChain [as waitForElementByCss] (lib/browsers/playwright.ts:584:17)
  at Playwright.waitForElementByCss [as elementByCss] (lib/browsers/playwright.ts:469:17)
  at Object.elementByCss (e2e/app-dir/cache-components-allow-otel-spans/cache-components-allow-otel-spans.test.ts:391:36)

@sokra
sokra force-pushed the fleet/turbopack-mangle-materialized-namespace branch from c3b6d36 to 0ae1a89 Compare August 24, 2026 08:12
@sokra sokra changed the title Turbopack: EXPERIMENT — mangle behind an escaping namespace via the facade (not for merge) Turbopack: mangle exports behind an escaping namespace via the facade Aug 24, 2026
@sokra
sokra force-pushed the fleet/turbopack-mangle-materialized-namespace branch 3 times, most recently from 985fe56 to 31a365e Compare August 24, 2026 10:48
@sokra
sokra marked this pull request as ready for review August 24, 2026 11:51
Comment thread turbopack/crates/turbopack/src/lib.rs Outdated
Comment on lines +209 to +223
// Some modules — a WASM loader, a client-reference proxy — are chunked and
// read from a *different* module identity than the one that reaches this
// resolution (see the same check in
// `BindingUsageInfo::used_exports`). Splitting one of these into a facade
// and a locals module mints a `<locals>` submodule keyed on the identity
// that reaches it; a second identity that renders to the same ident string
// (which happens for these modules, precisely because they aren't the one
// true identity codegen uses) then collides with it as a duplicate ident.
// Since none of these modules actually need the split for its intended
// purpose (tree shaking / export mangling are moot for a
// generated single-purpose wrapper), skip it outright rather than risk the
// graph-level collision.
let ident = module.ident().to_string().await?;
ident.contains(".wasm_.loader.mjs")
|| ident.contains("/__nextjs-internal-proxy.")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

There should not be a special case here. Also it should not leak next.js stuff into Turbopack.

Maybe it's possible to set mangle_export_names: false on the exports to opt-out. Anyway, figure out a nicer solution

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.

Removed entirely — you were right that it wasn't needed. Root-caused it: the crash was two legitimately different wrapper instances (a plain named-import form and a side-effect-only "module evaluation" form of the same client reference) each independently building their own copy of the generated proxy module, which only becomes visible as a graph-identity problem once each is split into a facade/locals pair.

The fix was already in an earlier commit for an unrelated reason: I'd changed the synthesized facade→locals edges from ImportUsage::TopLevel to ImportUsage::Exports({name}) so tree-shaking usage actually propagates. That same fix lets graph pruning correctly drop the "module evaluation" form's locals submodule — nothing ever names an export through it, so it's provably unused — before the duplicate-ident check runs. Verified with the skip-list gone: the exact crashing fixture, the other suites this crashed originally, and the full turbopack-tests suite. Pushed.

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.

Correction to my last reply: that claim was wrong. CI's broader fixture matrix (edge-runtime layout, an import.meta.url page, a client-component template) still hit the crash — I'd only tested a narrow set of scenarios locally.

Root-caused it properly this time. EcmascriptClientReferenceModule::proxy_module embeds self.server_ident.to_string() into the generated proxy source itself (as the registerClientReference "module path" argument and in the thrown error strings). That ident carries whichever part the resolving edge asked for (AssetIdent::to_string renders <module evaluation> etc.), and the plain named-export form and the always-present module-evaluation form of the same import are two separate edges landing on this same wrapper. Two different embedded strings → two different VirtualSources at the same file path → two raw modules → two facade/locals pairs, both rendering to the same ident. That's the actual collision.

Fix: strip AssetIdent::parts before rendering that string — it's a debug/error-message label, not something that needs to distinguish the part. Both forms now embed the identical string and proxy_module memoizes to the same result regardless of which part reached it.

The wasm loader (the skip-list's other entry) never had this problem — its generated source only reflects the wasm module's own imports/exports, never a rendered ident.

Verified against the crash's full known history: the original fixture, app-edge, non-root-project-monorepo, and the app-dir mega-suite (which has the template.js scenario) — all clean, zero duplicate-ident errors, with the full turbopack-tests suite green. Pushed.

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.

Found it — for real this time, verified with a reliable local reproduction.

The blocker to finding this was that it doesn't reproduce with a plain build-native-release binary at all, only with build-native-release-with-assertions — the profile CI's build-native job actually uses. Once built with that profile, the crash reproduced deterministically, every run, on the exact fixtures CI had been failing on.

With a real repro in hand: the .parts-stripping fix from before is correct as far as it goes — I instrumented it and confirmed the plain and module-evaluation forms of the same client reference now generate byte-identical proxy source content. The crash persisted anyway, which meant content parity was never the actual mechanism.

The real bug: proxy_module was a method on &self, so its memoization key is the whole EcmascriptClientReferenceModule cell. The plain form and the module-evaluation form are two different cells, so nothing makes two calls with equal content share a cache entry — each independently builds its own VirtualSource, processes it into its own module, at the same file path, regardless of whether their generated content matches.

Fix: extracted the whole body into a standalone proxy_module_for(server_asset_context, canonical_server_ident, client_module), keyed on the three inputs both forms genuinely share. Turbo-tasks memoization is argument-value-based (the same mechanism EcmascriptModuleLocalsModule::new(module) already relies on elsewhere in this stack) — so this now interns to one shared result regardless of which wrapper called it.

Verified with the release-with-assertions profile: the original crashing fixture, 0/3 runs crash (was crashing on every attempt before). The five other suites CI caught this on across three rounds — 36 tests, only the pre-existing external-network failure and one methodology mistake on my end remain (app-edge doesn't support cache-components, unrelated to this fix). Full turbopack-tests suite green. Pushed.

@sokra
sokra force-pushed the fleet/turbopack-mangle-materialized-namespace branch 2 times, most recently from 8a4b7e6 to a5dea47 Compare August 24, 2026 16:18
@sokra
sokra marked this pull request as draft August 24, 2026 16:23
@sokra
sokra force-pushed the fleet/turbopack-mangle-materialized-namespace branch from a5dea47 to f00d993 Compare August 24, 2026 17:35
@sokra
sokra force-pushed the fleet/turbopack-mangle-materialized-namespace branch from f00d993 to 140e360 Compare August 24, 2026 18:18
@sokra
sokra force-pushed the fleet/turbopack-mangle-materialized-namespace branch from 40ff3e9 to 692d1ea Compare August 25, 2026 15:56
@sokra
sokra force-pushed the fleet/turbopack-mangle-materialized-namespace branch 2 times, most recently from a3d2182 to efe203a Compare August 25, 2026 17:25
@vercel-fleet-prod

Copy link
Copy Markdown
Contributor

The one red check is the same flake that hit the rest of this stack today, not this change:

test prod (4/10) → test/production/app-dir/sync-io-blocks-root/sync-io-blocks-root.test.ts
→ "does not hang the build and reports sync IO errors - with --debug-prerender › /sync-root-async-page"

The failure output identifies the bundler:

▲ Next.js 16.4.0-canary.6 (webpack)

It is a webpack build (pnpm test-start, not test-start-turbo). Export mangling and the
facade/locals split are Turbopack-only, so nothing in this PR can influence it.

This same test file has now failed four times today across the three PRs in this stack, on a
different route every time:

Commit PR Route
a4ec106ce #97672 /async-root-sync-page
d3f8e6f26 #97672 /sync-root-sync-page-no-suspense
bbc2c7c8e #97676 /sync-root-sync-page
efe203a71 this PR /sync-root-async-page

A different sub-case each run, across three different branches and four different commits, is a
timing-dependent test rather than a deterministic breakage. The assertion is that the build reports
Date.now() as an unstable value during prerendering, which depends on when the prerender observes
it.

thank you, next is only the aggregate gate and goes red purely because of the above.

Everything else passes — 110 checks green, including lint (the prettier issue from the previous
push is fixed), test cargo unit, rust check, rustdoc check, and the full turbopack
production/dev e2e matrix. turbopack-emit-collect also passes now: it needed the canary default
restored in #97676, which the previous run was missing.

I can't re-run it myself (gh run rerun and the rerun-failed-jobs API both return
403 Resource not accessible by integration for this app identity), so it needs a maintainer re-run
or will clear on the next push. Given it has now recurred four times, sync-io-blocks-root looks
worth a look independently of this stack.

@vercel-fleet-prod

Copy link
Copy Markdown
Contributor

Same two pre-existing flakes as before, now on the sha from this round's canary rebase:

No new failure modes. Still can't re-run CI myself (403 Resource not accessible by integration).

@sokra
sokra removed this pull request from stack #98424 September 9, 2026 18:33
@sokra
sokra added this pull request to stack #98486 September 10, 2026 06:43
@sokra
sokra force-pushed the fleet/turbopack-mangle-materialized-namespace branch 2 times, most recently from b5f3777 to 641ec5f Compare September 10, 2026 15:53
@sokra
sokra removed this pull request from stack #98486 September 13, 2026 17:16
@sokra
sokra changed the base branch from codex/turbopack-strict-chunk-factories to fleet/turbopack-compact-reexport-registration September 13, 2026 17:18
@sokra
sokra added this pull request to stack #98618 September 13, 2026 19:42
vercel-fleet-prod Bot and others added 17 commits September 13, 2026 19:47
Webpack, faced with `import * as ns` where the namespace object escapes, keeps
mangling a module's internal export keys and *materializes* a namespace object
mapping the original names onto them. Turbopack backed off instead, so a single
escaping namespace un-mangled a whole module. Same for a dynamic `import()`.

Turbopack already had the structure to express webpack's version — the facade /
locals split — so this makes it carry the information the split was missing.

Three parts:

- `EcmascriptModuleFacadeModule::references()` exposed exactly one edge to the
  locals module, hard-coded `ExportUsage::all()`, because the facade's real
  per-export references are synthesized in `get_exports()` for code generation
  and never enter the module graph. No per-name usage could reach a locals
  module by construction, for any faceted module. Those per-export references
  are now real graph edges, and the structural one is demoted to
  `evaluation()`, which is all it was ever there for (side effects, ordering).

- The synthesized per-export edges declare `ImportUsage::Exports({name})`, so an
  edge is live exactly when the facade export it implements is live. With
  `TopLevel` they would have propagated the names but pinned every local export
  alive, which is the opposite of the point.

- `EcmascriptModulePartReference::code_generation` did not honor
  `chunking_context.unused_references()`, unlike `EsmAssetReference` — a latent
  inconsistency that the `all()` edge hid, since an `All` edge is never pruned.
  With a prunable `Evaluation` edge a side-effect-free locals module could be
  dropped from the chunk while codegen still emitted its import, failing at
  runtime with `module factory is not available`. It now agrees with the graph.

The effect on the fixtures, all of which previously asserted the back-off:

- `mangle-materialized-namespace` graduates out of `__skipped__` (the directory
  is now empty and gone). A module whose namespace escapes is mangled, with the
  facade materializing the original names — webpack's semantics.
- `mangle-destructuring`, `mangle-magic-comments`, `mangle-many-exports` and
  `mangle-cjs-consumer` now expect their modules to be mangled. Every value and
  `Object.keys` assertion in them is unchanged and still passes, which is what
  shows the materialized namespace is faithful — including for a CommonJS
  `require()` consumer, which still sees the original names.
- `side-effects-unsorted-modules` trims a side-effect-free module Turbopack
  could not trim before, matching webpack. Its `TODO` about not being able to
  follow namespace re-exports while tree shaking is resolved and removed.

Verified beyond the fixtures, since these paths are what a real build exercises:
the five app-dir e2e suites that this change's earlier revision broke all pass
under CI's `__NEXT_CACHE_COMPONENTS=true` (dynamic-import,
client-module-with-package-type, actions-streaming, global-not-found,
concurrent-router-queue), and a production `next build` with mangling enabled
compiles and prerenders.

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>

CI caught a real crash this broadening exposed: "Duplicate module idents in
graph" for a client-reference proxy module's `<locals>` split. The proxy
module and a WASM loader module are both cases the module graph's usage
tracking already special-cases for the same underlying reason (see
`BindingUsageInfo::used_exports`'s `.wasm_.loader.mjs` /
`/__nextjs-internal-proxy.` check) — they are chunked and read from a module
identity different from the one that reaches this resolution, so more than
one such identity can render to the same ident string. Splitting one into a
facade and a locals module mints a `<locals>` submodule keyed on whichever
identity reached it, and a second identity that happens to render the same
ident string then collides with it as a duplicate. Neither module benefits
from being split anyway (tree shaking and export mangling are moot for a
generated single-purpose wrapper), so this extends the existing check to skip
the split outright for them.

Verified against the exact CI failures this produced (all under
`__NEXT_CACHE_COMPONENTS=true`, the "cache components prod" job): 7 e2e
suites, 21/21 tests, including the client-reference-bearing
`concurrent-router-queue`.

CI then surfaced a second identity assumption: pages' global-CSS validation
(`validate_pages_css_imports_individual`) decided "is this import coming from
`_app`?" by comparing module identity, so once `_app.js` is split the CSS
import's parent is `_app.js <locals>` rather than `_app.js` and every global
CSS import in a pages app was reported as an error.

That comparison was already fragile before this change — the call site
re-creates `app_module` through its own `process()` call precisely because the
identity it gets elsewhere doesn't match — and the rule is about the *file*:
global CSS is allowed from `pages/_app`, whichever submodule of it the import
lands in. Candidates are now dropped when the parent shares the app module's
ident path, which every submodule preserves (only the `part` differs).

Verified on the suites this broke: css-modules-ordering, css-client-nav and
three of the scss global suites, 15 tests, under `__NEXT_CACHE_COMPONENTS=true`.
The previous attempt at this ("remove the ident-string skip-list, it was
already unnecessary") was wrong — it only happened not to reproduce in the
narrow set of scenarios tested locally at the time; CI's broader fixture
matrix (an edge-runtime layout, a page using import.meta.url, a client
component used as a route template) still hit the same
"Duplicate module idents in graph" crash for `__nextjs-internal-proxy.mjs`.
That attempt has been reverted, and this commit replaces it with an actual
fix.

Root cause, found by reproducing the exact crash and reading the generated
proxy module's own construction: `EcmascriptClientReferenceModule::proxy_module`
built the "module path" string embedded in its generated source —
`registerClientReference(fn, MODULE_PATH, name)`, and the text of the thrown
error messages — from `self.server_ident.to_string()`. That ident carries
whichever *part* the specific resolution asked for (`AssetIdent::to_string`
renders `<module evaluation>` etc. for it — see `ident.rs`), and turbopack
resolves the plain named-export form and the always-present module-evaluation
form of the same import as two separate edges, both landing on this same
wrapper type for the same underlying client component. Two different embedded
strings meant two different `VirtualSource` values at the exact same file
path, so two distinct raw modules got analyzed, split into a facade and a
locals submodule each, and both locals submodules rendered to the same ident
string — the collision this crashed on. The wasm loader (the skip-list's other
entry) doesn't have this problem: its generated source only reflects the wasm
module's own exports/imports, never a rendered ident.

The part suffix in that embedded string was never meaningful to a reader
anyway — it's a debug/error-message label, not something that needs to
distinguish which internal resolution form produced it. Stripping
`AssetIdent::parts` before rendering it removes the divergence at its source:
both forms now embed the identical string, so `proxy_module` memoizes to the
same result regardless of which part asked for it, and the facade/locals
split introduced by this stack no longer has two distinct raw modules to
collide over.

Verified against every previously-crashing suite in this stack's history for
this bug, with the skip-list removed and this fix in place: the exact
crashing fixtures (app-basepath-custom-server, app-edge,
non-root-project-monorepo) all green, the app-dir mega-suite
(test/e2e/app-dir/app/index.test.ts, which contains the template.js scenario)
builds and runs cleanly with zero "Duplicate module idents"/
"TurbopackInternalError" occurrences (its 11 remaining failures are
external-URL navigation timeouts, unrelated — no outbound network in this
sandbox), and the full turbopack-tests suite (545 unit + 272 execution + 125
snapshot).

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
…odule

Tried swapping the stripped-server_ident approach for self.client_module.ident()
(provably canonical regardless of which edge reached this wrapper) instead of
hand-stripping .parts. It broke client component resolution entirely:

  Could not find the module "...global-error.js [app-client] (ecmascript)#default"
  in the React Client Manifest.

The React Client Manifest keys its entries by this exact string elsewhere, so a
differently-shaped ident here is a genuine regression, not a cosmetic change --
confirmed by reverting it, which restored the previously-passing suites
(action-forward-loop, app-basepath, catch-error-react-compiler, dynamic-import,
19/20, the one remaining failure being the pre-existing external-network test).

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
The `.parts`-stripping fix from the previous commit was necessary but not
sufficient — CI kept hitting the identical "Duplicate module idents" crash on
different fixtures afterward. The reason took a wrong turn to find: it does
not reproduce with a `cargo build-native-release` binary at all, only with
`build-native-release-with-assertions` — the profile CI's build-native job
actually uses (`debug-assertions = true`, `overflow-checks = true`, no LTO,
64 codegen units, per `[profile.release-with-assertions]` in the root
Cargo.toml). Rebuilding with that profile turned this from an unreproducible,
CI-only crash into a reliable same-command local one.

With that in hand: instrumenting confirmed the previous commit's fix does what
it says — the plain named-export form and the module-evaluation form of the
same import now generate byte-identical proxy source content. The crash
persisted anyway, which meant the real bug was never about content parity in
the first place.

`proxy_module` was `#[turbo_tasks::function] async fn proxy_module(&self)` —
a method whose memoization key is `self`, i.e. the whole
`EcmascriptClientReferenceModule` cell. Two different cells (one for the plain
form, one for the module-evaluation form of the same client reference) each
call it independently; nothing about calling the same *content* from two
different `self`s makes turbo-tasks treat the two calls as the same call. Each
one builds its own `VirtualSource` and processes it into its own analyzed
module, at the same file path, regardless of whether their generated content
happens to match.

Moved the whole body into a standalone `proxy_module_for(server_asset_context,
canonical_server_ident, client_module)`, keyed on exactly the three
value-equal inputs both forms share (the earlier fix already made the ident
argument value-equal by stripping `.parts` from it). Turbo-tasks' task
memoization is genuinely argument-value-based — this is the same mechanism
`EcmascriptModuleLocalsModule::new(module)` already relies on to dedupe by
`module` alone elsewhere in this stack — so calling this from two different
`EcmascriptClientReferenceModule` cells now correctly interns to one shared
result, and the facade/locals split introduced by this stack has only one raw
module to ever see for a given client reference, not two.

Verified with the release-with-assertions profile, matching CI: the exact
crashing fixture (0/3 runs crash, was crashing on every attempt before this),
the five other suites CI has caught this on across three rounds
(action-forward-loop, app-basepath, catch-error-react-compiler, dynamic-import,
app-edge, non-root-project-monorepo — 36 tests total, only the pre-existing
external-network failure and a testing-methodology mistake on my part
(app-edge doesn't support cache-components, unrelated to this fix) remain),
and the full turbopack-tests suite (545 unit + 272 execution + 125 snapshot).

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
canary gained this suite (turbopack-emit-collect) since the stack's last
rebase, asserting the literal set of module idents Turbopack's own
__turbopack_modules__ registers for various routes. Its fixture module
src/collect-result.js has one plain named export and no re-exports, so once
export mangling defaults on (this branch), it now splits into a facade (kept
at the plain ident, used by importers) plus a <locals> submodule (the
mangled internals) -- exactly the intended behavior, just newly visible to a
snapshot written before mangling existed.

Updated via the test's own -u flag (pnpm test-start-turbo
test/e2e/turbopack-emit-collect/index.test.ts -u) rather than hand-editing,
so the new snapshot text is exactly what the harness produces. Confirmed it
touched precisely the 7 sub-tests CI reported and left the other 2 (/api,
/proxy) alone, matching CI's failure list exactly. Verified 9/9 green after,
and that prettier considers the result unchanged.

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Enabling mangling by default in the execution suite (previous commit) exposed a
code-elimination bug in this layer's split trigger.

`split_locals_and_reexports` returned true whenever `mangle_export_names` was
set, *including for a module with no exports at all*. A test `index.js` -- only
`it(...)` calls, nothing exported -- therefore split into a facade plus a locals
module, and `EcmascriptModuleFacadeModule::side_effects()` unconditionally
reports its module evaluation as side-effect free. The module's real top-level
side effects became invisible to tree shaking and the entire body was
eliminated, leaving `__turbopack_context__.s([])`. Affected test files silently
ran zero tests.

Splitting a module with zero export names is pointless by construction -- there
is nothing to mangle -- so it is now skipped. This fixes, with no fixture
changes at all: webpack/scope-hoisting/order-without-side-effects,
webpack/scope-hoisting/side-effects-11990{,-star}, and
turbopack/tree-shaking/ignore-side-effect-free-module. The doc comments on
`split_locals_and_reexports` and on the facade's `side_effects()` now record the
invariant that connects the two.

The mangle-exports snapshots are regenerated: their export-less entry modules no
longer emit a pointless `<locals>` module. Verified at module-key level that this
is the only change -- `lone.js` etc. still split, and the single-export `.f` key
is still emitted.

Two fixtures need deliberate accommodation, both commented in place:

- `turbopack/exports/live` asserted a plain `{ value, writable: false }`
  descriptor for a non-mutated export. Its module has exports, so it does split,
  and reading across a facade boundary always goes through a getter
  (`ReferencedAssetIdent::Module` cannot know the target's liveness). Values are
  unchanged; the fixture now asserts the getter shape *and* that the values are
  still correct.
- `turbopack/scope-hoisting/pure-comments` looks a module factory up by ident
  suffix, which the split changes. It opts out of mangling; the fixture is about
  `/*#__PURE__*/` retention.

`turbopack/async-modules/cycle-2` also opts out, but for a KNOWN BUG rather than
a property of the fixture: an import cycle of split modules containing an async
module deadlocks while building the module graph. `follow_reexports` steps
facade -> <locals> and stalls in the locals module's `get_exports()`, which
awaits the original module's `analyze()`, already in flight further up the same
cycle (~0.5% CPU, flat RSS, no Node process -- a turbo-tasks await cycle, not a
loop; `compute_async_module_info_single` is never reached). That bug is
pre-existing on canary and needs nothing but a re-export, an import cycle and a
top-level `await` -- there is a standalone reproduction on branch
`fleet/turbopack-reexport-cycle-deadlock-repro`. Mangling only makes it far
easier to reach, by splitting every module with exports rather than only
re-exporting ones. The opt-out and its `index.js` comment both say to remove it
once that is fixed.

Verified: 546 unit + 274 execution + 125 snapshot green, run to completion;
clippy and fmt clean.

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Every fixture that opts out of mangling should say why in the fixture itself, so
a reader hitting the `options.json` does not have to guess whether it is papering
over a bug. `exports/live` and `async-modules/cycle-2` already did; this one did
not.

The reason here is benign: the test finds the module factory to inspect by
matching an ident suffix, and splitting a module into facade + `<locals>` changes
that ident. The behaviour under test -- `/*#__PURE__*/` retention -- has nothing
to do with export names.

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
…e-2 opt-out

`exports/live` asserted property descriptors on an `import * as` namespace
object. Once a module's exports are mangled it is split into a facade plus a
`<locals>` module, and the namespace those assertions see is the facade's. A
facade re-exposes every name through a getter, because it forwards a binding it
does not own and cannot know the binding is never reassigned. The assertions
therefore observed the facade rather than the value-vs-getter decision they
exist to check, and could no longer fail.

Reach the locals module instead: find its id in `__turbopack_modules__`, load
its namespace with `__turbopack_import__`, and resolve mangled keys through
`__webpack_exports_info__` evaluated in the module that owns the exports. The
non-mutated bindings are asserted to be plain values and `g` to be a getter, in
the same namespace, so the test discriminates rather than just checking that
descriptors exist. `g` is also read through the facade first: used-export
tracking is per name, and an export this fixture never mentions is dropped from
the locals module altogether.

Also removes the `mangleExportNames: false` opt-out from `async-modules/cycle-2`
and the explanation it needed. That workaround existed for the re-export cycle
deadlock, which is fixed on canary in #97933, so the fixture now passes with
mangling enabled and is byte-identical to canary again.

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
These fixtures' emitted chunk filenames are content hashed, so they move
whenever the base changes the hashing or the chunking around them. Regenerated
with the harness rather than edited by hand.

The behaviour they assert is unchanged: single-export still emits the fixed
`s(["f",0,...])` key and its consumer still reads `.f` off the `<locals>`
module. Only the filenames and the entry's reference to them move.

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
The previous explanation blamed the facade reporting its own module evaluation
as side-effect free. That was wrong: the facade also builds an
`ExportUsage::evaluation()` reference on the locals module, which is what keeps
the original module's side effects reachable.

The real chain, confirmed by removing the guard and varying the input:

- an export-less facade builds no `ExportUsage::named` references, so its
  evaluation reference is the only edge to the locals module;
- `BindingUsageInfo` drops an evaluation edge whose target is side-effect free;
- the locals module reports whatever the original module reports, and a
  `"sideEffects": false` package declaration makes that true even when the body
  does have side effects;
- unsplit, the module is a chunk group entry and evaluates regardless; split,
  its one prunable edge goes away and the body with it.

Removing the guard fails `tree-shaking/ignore-side-effect-free-module`,
`scope-hoisting/order-without-side-effects` and both `side-effects-11990`
fixtures with "No tests were run". Deleting `"sideEffects": false` from
`order-without-side-effects` makes it pass with the guard still removed, which
isolates the cause.

Also moves the facade's `side_effects` note out of a doc comment into the
function body. No behaviour change.

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
…mangling

The fixture located `a.ts`'s factory with an exact `endsWith(... (ecmascript))`
match, which misses once the module is split into a facade plus a `<locals>`
module, so it disabled mangling to keep the ident stable. Nothing it tests
involves export names, so the opt-out was working around the lookup rather than
a real interaction.

Match with `includes` instead and drop `mangleExportNames` from its options.
Only one module id matches either way -- the import is of a local binding, so it
resolves to the locals module and the facade is never chunked -- and the fixture
now passes both with mangling on and with it explicitly off.

`turbopack/exports/mangle-off` is now the only fixture that opts out, which is
the deliberate control for the feature.

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Seed `part_references` with the locals module's evaluation reference before
the export loop rather than building a separate `vec!` and chaining the two,
which drops a temporary and an `into_iter().chain(...).collect()`. The
evaluation reference stays first, so reference order -- and therefore
evaluation order -- is unchanged; the committed snapshots are byte-identical.

Cut three comments back to what their sites actually need. The facade's
`side_effects` no longer restates the evaluation-reference rationale, which
belongs where that reference is built. The unused-reference check in
`EcmascriptModulePartReference::code_generation` says what it does and why in
two lines. `split_locals_and_reexports` keeps the two reasons a module is
split and why an export-less one is not, without walking through the pruning
mechanics of a decision made in another crate.

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Rebasing onto `codex/turbopack-strict-chunk-factories` changes how module
factories are written into a chunk: strict factories are grouped so the
per-factory `"use strict"` directive is emitted once for the group rather than
once per factory. That shifts the emitted text of any snapshot containing a
strict factory, including the facade module this PR adds for a module whose
namespace escapes.

Regenerated with the test harness; no assertion or fixture input changed.

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
The facade a mangled module splits into is the shape the compact re-export
registration exists for: every one of its exports forwards a binding out of the
locals module, and the only other reference is that module's evaluation. Its
emitted body goes from

    var t = e.i("…/enums.js <locals>");
    e.s(["ENUM_A", ()=>t.Q, "ENUM_B", ()=>t.A, "default", ()=>t.U, "exportsInfo", ()=>t.D]);

to

    e.S(["…/enums.js <locals>", "ENUM_A,Q,ENUM_B,A,default,U,exportsInfo,D"]);

Three things were needed.

A facade is synthetic: it has no source, so `with_consumed_parse_result` hands
its code generation a default `EvalContext` with an empty `ImportMap`, which
classifies as `Normal` and could never reach the compact path. The registration
mode is therefore an option on `EcmascriptModuleContentOptions`, which the
facade sets to `Reexport` and the other synthetic wrappers set to `Normal`;
ordinary modules leave it unset and keep deriving it from their own imports.

That empty `ImportMap` also means an export's source position cannot be looked
up, and positions are what order the groups. Rather than give up, a missing
position now restricts the result to a single group -- with only one group there
is no order to get wrong, which is exactly the facade's case.

A facade's imports are `EcmascriptModulePartReference`s, so those needed the
same suppression as `EsmAssetReference`. Both check the namespace variables the
registration reports as redundant.

Also excludes async modules, found by the async-module and wasm fixtures
failing: their imports are promises that the async-module wrapper awaits and
assigns back over the namespace variables, whereas the compact registration
reads the namespace when it runs. The signal is the chunk item's
`async_module_info`, not the module's `AsyncModule` -- a facade always has the
latter, so using it disabled the very case this change is for.

Fixes the `type_complexity` lint on `CodeModuleIdsAndPaths` while here; it fails
`clippy -D warnings` on the branch this is stacked on and so blocks CI for
everything above it.

Co-authored-by: Fleet Dev Agent <fleet@vercel.com>
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
A synthetic facade has no ImportMap of its own, so its export groups have no
source positions. That does not require abandoning the compact form:
namespace-object heads instantiate nothing, so their order is irrelevant.

Use namespace heads when several such groups are present. When there is only
one group, there is no relative order to get wrong, so let the registration use
a module-id head and subsume the facade's import entirely. This is the common
case and produces the requested form:

    e.S(["…<locals>", "ENUM_A,Q,ENUM_B,A,default,U"])

instead of retaining `var t=e.i(id)` and passing `t` as the head.

Co-authored-by: Fleet Dev Agent <fleet@vercel.com>
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
A circuit breaker's export registration is emitted before its imports so a
cyclic importer can see the getters while this module is still evaluating. The
compact registration performs the imports itself and so cannot reproduce that
ordering, which makes it unsafe for those modules.

`is_circuit_breaker` alone cannot express this: when no export-usage analysis
has run, the conservative fallback reports every module as one, and excluding
all of them disables the feature entirely. Record whether the usage is actually
known, and exclude only modules that a real cycle was found for.

Also keys the facade's part-reference suppression on the namespace's syntax
context, matching the change below this one.

Co-authored-by: Fleet Dev Agent <fleet@vercel.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: Fleet Dev Agent <fleet@vercel.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-mangle-materialized-namespace branch from 641ec5f to d216200 Compare September 13, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants