Skip to content

perf(turbopack): look up chunk group indices through a keyed cell - #99050

Merged
sokra merged 1 commit into
codex/turbopack-chunk-group-availabilityfrom
codex/turbopack-keyed-chunk-group-index
Oct 24, 2026
Merged

sokra merged 1 commit into
codex/turbopack-chunk-group-availabilityfrom
codex/turbopack-keyed-chunk-group-index

Conversation

@sokra

@sokra sokra commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

What?

Narrows the turbo-tasks dependency of ChunkGroupInfo::get_index_of so that looking up one chunk group's index no longer depends on the entire ChunkGroupInfo.

This is PR 6 of a six-PR stack, based on canary (#98733, #99686 and #99749 are already merged):

  1. Turbopack: chain the Pages Router development client module graph #98823 — Pages Router dev client graph chaining
  2. test: cover Pages Router _app availability in Turbopack dev (#99789) #99910 — regression test for Pages Router _app availability (Turbopack dev (16.4.0): Pages Router page modules missing ("module factory is not available") after _app availability seeding (#97664) #99789)
  3. Turbopack: consume graph-derived chunk groups #98734 — use graph-derived merged groups
  4. refactor(turbopack): chunk chunk groups by key #99750 — chunking takes a ChunkGroupKey
  5. Turbopack: track availability by chunk group #98685 — chunk-group availability bitmaps
  6. This PR — keyed chunk-group index lookups

Why?

#98685 makes availability a set of chunk-group indices, which turns get_index_of into a hot path: every availability translation between graphs resolves keys to indices through it.

That lookup previously read chunk_group_keys off the whole ChunkGroupInfo value. In turbo-tasks, reading one field of a cell creates a dependency on the cell as a whole, so every index lookup in the graph was invalidated whenever any unrelated part of the chunk group info changed — a new async chunk group anywhere in the app re-ran every lookup, and everything downstream of them. The wider the availability work built on top of it, the more that costs.

How?

Adds ChunkGroupKeyToIndex, a cell = "keyed" map from ChunkGroupKey to its index, alongside the existing set, exposed through an argument-less chunk_group_indices() task. get_index_of reads a single key from it through Vc::get, so its caller depends only on the key it actually asked for: an unrelated chunk group changing no longer invalidates it.

get_index_of itself is deliberately a plain async method rather than a turbo-tasks function. As a function it would create one task per chunk group argument, which is exactly the per-chunk-group overhead this change is meant to remove; the single chunk_group_indices() task is what provides the caching.

The interesting constraint is why this can't reuse the FxIndexSet<ChunkGroupKey> that was already there — a set that already holds exactly these keys in exactly this order. Two properties of the keyed-cell traits rule it out:

  • KeyedAccess for IndexSet yields (), not the index, so the value needed isn't retrievable through a keyed read.
  • KeyedEq for IndexSet compares membership and ignores position. A key whose index merely shifted — same set, different order — would not be reported as changed, and lookups would keep returning a stale index. That is a correctness bug, not just a missed invalidation.

Mapping the key to the index as the value makes KeyedEq for HashMap compare indices, which detects exactly that shift. The FxIndexSet stays for the reverse (index → key) direction, so the duplication is deliberate and documented at the type.

The debug-only "couldn't find chunk group" diagnostic still reads the whole struct to render its message. That path is only taken on failure, so the happy path keeps its narrow dependency.

Verification

Keyed-cell invalidation semantics (per-key dependencies, KeyedEq comparing values) are covered by turbo-tasks' own tests, so this PR does not add a dedicated invalidation test.

  • cargo clippy --workspace --all-targets -- -D warnings -A deprecated
  • turbopack-core + next-core — 252 passed
  • Turbopack snapshots — 126 passed, no output churn
  • cargo fmt --check clean

Behavior is unchanged; this is a dependency-graph refactor with no effect on emitted output.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: 62d66a2

Comment thread turbopack/crates/turbopack-core/src/module_graph/chunk_group_info.rs Outdated
Comment thread turbopack/crates/turbopack-core/tests/chunk_group_index_invalidation.rs Outdated
@sokra
sokra force-pushed the codex/turbopack-keyed-chunk-group-index branch from af5b337 to 17e8e12 Compare September 23, 2026 10:47
@sokra
sokra removed this pull request from stack #98824 September 23, 2026 13:08
@sokra
sokra added this pull request to stack #99101 September 23, 2026 13:08
@sokra
sokra force-pushed the codex/turbopack-keyed-chunk-group-index branch from 17e8e12 to fd0bbf2 Compare September 23, 2026 14:00
@sokra
sokra force-pushed the codex/turbopack-keyed-chunk-group-index branch from fd0bbf2 to 3f74d26 Compare September 24, 2026 08:22
sokra added a commit that referenced this pull request Sep 24, 2026
### What?

Stops Turbopack from re-chunking every App Router layout segment once
per page in production builds. The manually constructed server-utilities
chunk group is now only created in development.

### Why?

A production build should chunk each shared layout segment exactly once.
Instead, a layout segment was chunked again for every endpoint below it.
On a small app with four endpoints, the root layout was chunked 4 times
and a nested `dashboard` layout 2 times. The emitted output was still
shared, so this was wasted build work rather than bigger bundles, which
is why output-based tests never caught it.

Production already builds a single module graph for the whole app, so
the module graph is not what differs between endpoints. The difference
comes from the server-utilities chunk group that seeds the
layout-segment chain:

- It is a `SharedMerged` group whose parent is the current endpoint's
entry chunk group.
- A merged group's parent is part of its identity, so every endpoint
gets a different group (5 chunkings on the same app).
- Its result seeds the availability info passed to each layout segment
after it. Availability info is part of the chunking task key, so each
shared segment ended up with a different key per endpoint and was
chunked again.

The code comment already said this manual shared chunk exists mainly to
help development. That makes sense there: each endpoint has its own
module graph in development, and without the group layout segments would
share very little across pages.

This is PR 0 of the chunk-group stack. The PRs above it are:

1. #98733 — canonical chunk-group identity
2. #98734 — graph-derived output groups
3. #98823 — Pages Router dev client graph chaining
4. #98685 — chunk-group availability bitmaps
5. #99050 — keyed chunk-group index lookups

### How?

The manual server-utilities group is now created only in
per-page-module-graph mode, which is development. In production the
layout-segment chain starts from an endpoint-independent state, so
sibling endpoints produce the same chunking task key for a shared
segment. The server utilities are then chunked with the entry through
the whole-app graph. Development behaves exactly as before.

On the same four-endpoint app, a production build now chunks the root
layout and the nested layout **once each**, and the manual
server-utilities group is gone. Development still creates the group and
chunks segments per page, which is expected for on-demand compilation.

Adds `test/e2e/app-dir/layout-segment-chunk-sharing`, a root layout and
a nested layout that share one module across two sibling pages. It
asserts two things:

- **Output sharing (Turbopack dev and production):** the shared module
is emitted into exactly one server chunk. Webpack builds a
self-contained server bundle per route, so this assertion is gated on
the bundler, not the mode.
- **Work sharing (Turbopack production only):** output sharing alone
does not prove the chunking *task* was reused, since a segment could be
re-chunked per page with an identical result. The test builds with
tracing enabled and uses `next internal query-trace` to count executions
of the nested layout's chunking task, which must be exactly one. A cache
hit produces no execution span, so the count equals the number of real
executions. Development compiles pages on demand with a module graph per
page, so repeated chunking is expected there and the assertion is
skipped.

With the fix reverted, the work-sharing assertion fails with 2
executions, while the output-sharing assertion still passes.

### Performance

Measured on a synthetic App Router app: a root layout and a nested
`section/layout.tsx`, each importing M modules (~20% `'use client'`),
with N trivial sibling pages under `section/`. Cold `next build` with
release native builds of both commits (`192fcaf7` vs this PR, one shared
JS build); paired runs in ABBA order on an 8 vCPU / 16 GB Vercel Sandbox
VM; 95% Student-t intervals on the paired differences.

At **800 pages × 1,000 modules per layout**, a dedicated 12-pair run:

| metric | Δ | 95% CI | pairs improved |
|---|---|---|---|
| Turbopack compile time | **−6.3%** (−767 ms) | [−968, −565] ms | 12/12
|
| total `next build` time | **−2.2%** (−764 ms) | [−1241, −287] ms |
9/12 |
| Turbopack process peak RSS | **−4.2%** (−104 MB) | [−141, −68] MB |
12/12 |

In traces of that build, the nested layout goes from 800 chunking
executions to 1, the root layout from 802 to 1, and the server-utilities
group from 803 to 0 (Node.js chunking spans overall: 7,223 → 1,617).
Smaller configurations (50–800 pages × 200 modules, 200 pages × 1,000
modules; 8 pairs each) showed no detected difference in time or
Turbopack memory, so the gain matters mainly for large apps.

**Trade-off:** without the manual shared group, a few server utilities
(the `*-async-storage.external` and `runtime-reacts.external` wrappers,
the vendored `react-rsc` getter and a small `bytes` helper) are now
inlined into each page's SSR entry chunk instead of being emitted once.
That adds about **2.3 KB of server output per App Router endpoint**
(+0.9% to +4.4% of server JS in these fixtures). Client JS is
byte-identical. Keeping these modules shared in production without
per-endpoint identity would be a possible follow-up.

### Verification

- Trace measurement on the fixture (production): root layout 4 → 1,
nested layout 2 → 1, server-utilities group 5 → 0; dev unchanged
- Production build renders and serves every route with the shared
module; the shared module is still emitted into exactly one server chunk
- `layout-segment-chunk-sharing` — Turbopack production (3 passed),
Turbopack dev (output assertion passed, trace assertion skipped),
webpack production (Turbopack-only assertions skipped)
- `cargo clippy --workspace --all-targets -- -D warnings -A deprecated`,
`cargo fmt --all -- --check`
- `turbopack-core` + `next-core` unit tests (252 passed);
`turbopack-tests` snapshots (126 passed, no changes)

<!-- NEXT_JS_LLM -->


<!-- fleet 4f67c6f7-bbe2-454b-9a17-822a60ef8f7e -->

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>
@sokra
sokra force-pushed the codex/turbopack-keyed-chunk-group-index branch from 3f74d26 to 64a5ed3 Compare September 24, 2026 16:14
@sokra
sokra force-pushed the codex/turbopack-keyed-chunk-group-index branch from 64a5ed3 to 84105dd Compare September 24, 2026 16:45
@sokra
sokra force-pushed the codex/turbopack-keyed-chunk-group-index branch from 84105dd to f91631e Compare September 30, 2026 18:02
@sokra
sokra force-pushed the codex/turbopack-keyed-chunk-group-index branch from f91631e to 9599468 Compare October 2, 2026 08:59
@sokra
sokra force-pushed the codex/turbopack-keyed-chunk-group-index branch from 9599468 to 3fc5dd3 Compare October 5, 2026 14:37
@sokra
sokra removed this pull request from stack #99101 October 5, 2026 14:38
@github-actions github-actions Bot added the Turbopack Related to Turbopack with Next.js. label Oct 9, 2026
sokra added a commit that referenced this pull request Oct 9, 2026
### What?

Makes `ChunkGroupInfo` resolve chunk groups through their canonical
`ChunkGroupKey` identity.

### Why?

Merged chunk groups carry an entry list whose order is intentionally
unspecified. Looking up the full `ChunkGroup` value therefore makes
identity depend on incidental entry ordering, even though the module
graph already indexes groups by parent and merge tag.

This is PR 1 of an eight-PR stack (on top of #99100, already merged):

1. **This PR** — canonical chunk-group identity
2. #99686 — chunk several chunk groups in one `chunk_group` call
3. #99749 — register the chunk groups that are chunked
4. #98734 — use graph-derived merged groups
5. #99750 — chunking takes a `ChunkGroupKey`
6. #98823 — Pages Router dev client graph chaining
7. #98685 — chunk-group availability bitmaps
8. #99050 — keyed chunk-group index lookups

It provides stable identity for the graph-derived chunk-group work above
it.

### How?

Adds a canonical key conversion for every chunk-group variant and uses
the existing `chunk_group_keys` index for lookup. Merged keys
intentionally contain only their parent group and merge tag.

### Verification

- `cargo test -p turbopack-core` — 160 passed
- `cargo test -p turbopack-tests --test snapshot` — 125 passed
- Affected Cargo checks and Rust formatting

<!-- NEXT_JS_LLM -->


<!-- fleet 4f67c6f7-bbe2-454b-9a17-822a60ef8f7e -->

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>
sokra added a commit that referenced this pull request Oct 9, 2026
…ll (#99686)

### What?

`ChunkingContext::chunk_group` takes a list of chunk groups and chunks
them together, as one chunk group, in a single chunking task. It
produces one `ChunkGroupResult` (one `make_chunks` run) for the whole
list.

`evaluated_chunk_group`, `entry_chunk_group` and the
`ChunkingContextExt` helpers still take a single group and wrap it in a
one-element list. All existing callers pass a one-element list, so
**this PR changes no output** (zero snapshot changes).

### Why?

Review feedback on #98734
([1](#98734 (comment)),
[2](#98734 (comment))):
a layout segment's client references can derive several isolated merged
chunk groups, and #98734 chunked each one with a separate `chunk_group`
call. That costs an extra chunking task per group and threads
availability by hand. This PR adds the list form; #98734 then calls
`chunk_group` once per segment and side with all of the segment's
derived groups.

### How?

`make_chunk_group` / `chunk_group_content` /
`chunk_group_content_operation` take the list as a
`SmallVec<[ChunkGroup; 1]>`: almost every call passes a single group, so
the common case doesn't heap-allocate. The list crosses turbo-task
boundaries, so this PR also adds `impl TaskInput for SmallVec<A>` in
turbo-tasks, mirroring the `Vec` impl. Semantics:

- **Traversal:** the entries of each group, in list order, with one
shared traversal state. A module already reached from an earlier group
isn't collected again, which matches what the previous
one-call-per-group sequence produced (CSS order unchanged).
- **Availability:** every listed group is added to the resulting
availability info.
- **Entry groups:** if several `Entry` groups are listed, the active
page entries and the resulting entry group are the union of their
modules, in list order.
- **Edge cases:** an empty list is an error; groups listed more than
once (same key) are chunked once.

Unit tests cover the edge cases, the `Entry` union, and `SmallVec` as a
task input (inline and spilled).

This is PR 2 of an eight-PR stack (on top of #99100, already merged):

1. #98733 — canonical chunk-group identity
2. **This PR** — chunk several chunk groups in one `chunk_group` call
3. #99749 — register the chunk groups that are chunked
4. #98734 — use graph-derived merged groups
5. #99750 — chunking takes a `ChunkGroupKey`
6. #98823 — Pages Router dev client graph chaining
7. #98685 — chunk-group availability bitmaps
8. #99050 — keyed chunk-group index lookups

<!-- NEXT_JS_LLM -->


<!-- fleet 4f67c6f7-bbe2-454b-9a17-822a60ef8f7e -->

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>
sokra added a commit that referenced this pull request Oct 9, 2026
### What?

Registers chunk groups in the module graph exactly as output code chunks
them, in two places:

- **Pages layout segments**: `_document` and `_app` are registered as
their own `Entry` groups (`layout_chunk_group_entry`), in both the
per-page (dev) graph chain and the whole-app `entries()`.
`internal_ssr_chunk` chunks them as `ChunkGroup::Entry(vec![layout])`
instead of `ChunkGroup::Shared(layout)`.
- **turbopack-cli dev**: `create_web_entry_source` registers one `Entry`
group per entry, with the same entries `DevHtmlAsset` chunks: the
runtime entries followed by the entry for evaluatable modules, or the
module alone otherwise. It passes `runtime_entries` without
`with_entry(entry)`, because `DevHtmlAsset` appends the entry itself.

### Why?

A chunk group is identified by its key, and for an `Entry` group that is
its ordered entries. The next PR in the stack looks every chunked group
up in `ChunkGroupInfo` by key, so a chunked group the graph didn't
register fails.

- Pages layouts were declared `Shared(layout)`. The whole-app graph also
registers the standalone `/_document` and `/_app` endpoints'
`Entry([module])` group for the same module and keeps only one root
group per module, so `Shared(layout)` wasn't registered there.
- turbopack-cli registered a single `Entry` group with all entries and
runtime entries, while `DevHtmlAsset` chunks one group per entry (with
the entry appended twice).

No output change is intended: the same modules are chunked, but the
registered groups now match them.

This is PR 3 of an eight-PR stack (on top of #99100, already merged):

1. #98733 — canonical chunk-group identity
2. #99686 — chunk several chunk groups in one `chunk_group` call
3. **This PR** — register the chunk groups that are chunked
4. #98734 — use graph-derived merged groups
5. #99750 — chunking takes a `ChunkGroupKey`
6. #98823 — Pages Router dev client graph chaining
7. #98685 — chunk-group availability bitmaps
8. #99050 — keyed chunk-group index lookups

### Verification

- Workspace `cargo clippy --all-targets -D warnings`, `cargo check
--workspace --all-targets`, `cargo fmt --check`
- Turbopack snapshots: 132 passed, no snapshot changes
- `turbo-tasks` + `turbopack-core` + `next-core` unit tests: 362 passed
- Turbopack production and dev `404-page-router` (custom `_document` /
`_app`): 25/25, 12/12

<!-- NEXT_JS_LLM -->


<!-- fleet 4f67c6f7-bbe2-454b-9a17-822a60ef8f7e -->

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>
@sokra
sokra force-pushed the codex/turbopack-keyed-chunk-group-index branch from c171d17 to 9662000 Compare October 9, 2026 12:20
@sokra
sokra removed this pull request from stack #99752 October 9, 2026 14:22
@sokra
sokra changed the base branch from codex/turbopack-chunk-group-availability to canary October 9, 2026 14:23
@sokra
sokra force-pushed the codex/turbopack-keyed-chunk-group-index branch from 9662000 to f21e085 Compare October 9, 2026 14:25
@github-actions github-actions Bot added the tests label Oct 9, 2026
@sokra
sokra changed the base branch from canary to codex/turbopack-chunk-group-availability October 9, 2026 14:26
@sokra
sokra added this pull request to stack #99941 October 9, 2026 14:26
sokra added a commit that referenced this pull request Oct 9, 2026
…8823)

### What?

Chains the Pages Router **development** client module graph so that
`/_app` and the page being chunked live in the same `ModuleGraph`.

This is PR 1 of a six-PR stack, based on `canary` (#98733, #99686 and
#99749 are already merged):

1. **This PR** — Pages Router dev client graph chaining
2. #99910 — regression test for Pages Router `_app` availability
(#99789)
3. #98734 — use graph-derived merged groups
4. #99750 — chunking takes a `ChunkGroupKey`
5. #98685 — chunk-group availability bitmaps
6. #99050 — keyed chunk-group index lookups

### Why?

In development `Project::per_page_module_graph()` is true, so every page
endpoint builds its own client `ModuleGraph`, each with its own
`ChunkGroupInfo` and its own index space starting at `0`.

`/_app` is always loaded before the page in the browser, so the page
reuses `/_app`'s availability when chunking. Today that availability is
a set of concrete modules, which is graph-independent, so the split
graphs go unnoticed.

That stops being true as soon as availability is expressed as
chunk-group indices (#98685): `/_app` would record index `0` in *its*
graph, and the page would resolve that same `0` against *its own* graph
— where it means the page's own entry group. Every module of the group
being chunked then looks already-available and is dropped, producing
runtime errors like:

```
Module [hmr-entry]/hmr-entry.js { ENTRY => "[project]/pages/_error" }
  was instantiated as a runtime entry of chunk …,
  but the module factory is not available.
```

### How?

- `PageEndpoint::client_module_graph` builds a graph *chain* in per-page
mode: `/_app`'s entry group first, then the page's, with
`VisitedModules` threaded between them — the same layout-segment
approach `ssr_module_graph` already uses for document → app → page.
- Whole-app mode (production) is unchanged and still goes through
`module_graph_for_modules`.
- `/_app` itself is not chained onto anything.
- Extracts a small `client_entry_modules` helper, since the entry module
list is now needed in two places.

Availability is still carried as the set of modules `/_app` emitted.
Making the two endpoints share one graph also fixes #99789: with
separate graphs, batching in the page graph could produce a node for a
module shared with `/_app` whose edges point at page-only modules, and
those modules were skipped as already available ("module factory is not
available" on hydration). #99910 adds the regression test. Lining up the
graph identities is also what #98685 builds on.

### Verification

- `turbopack-core` — 160 passed
- `next-core` — 84 passed
- Turbopack snapshots — 125 passed
- `404-page-router` and `server-navigation-error` development suites
- Affected Cargo checks and Rust formatting

<!-- NEXT_JS_LLM -->


<!-- fleet 4f67c6f7-bbe2-454b-9a17-822a60ef8f7e -->

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>
sokra added a commit that referenced this pull request Oct 9, 2026
…99910)

### What?

Regression coverage for #99789, stacked on #98823.

This is PR 2 of a six-PR stack, based on `canary`:

1. #98823 — Pages Router dev client graph chaining
2. **This PR** — regression test for Pages Router `_app` availability
(#99789)
3. #98734 — use graph-derived merged groups
4. #99750 — chunking takes a `ChunkGroupKey`
5. #98685 — chunk-group availability bitmaps
6. #99050 — keyed chunk-group index lookups

- **New e2e suite** `test/e2e/pages-app-availability-boundary`: a Pages
Router app with the import shape from #99789:
- `_app` imports `c-shared` and dynamically imports a module that also
imports it.
- `pages/index` imports `a-page-only`, `b-boundary`, `c-shared`,
`d-boundary`, `e-page`, and dynamically imports a module that imports
`b`/`d`, which makes `b` and `d` batching boundaries.

The test asserts that the page hydrates (a marker set in `useEffect`),
that every imported value renders, and that no "module factory is not
available" error is logged.
- **`app-document-import-order`**: the "loads modules shared by _app and
the page only once" assertion added in #97664 was skipped in all of dev.
It now runs everywhere and is gated `// @gate !(turbopack && dev)`
(expected failure).

### Why?

In Turbopack dev, #97664 seeds each page's client chunk group with
`/_app`'s availability. But each page had its own module graph, so
availability items (`ModuleOrBatch` identities) were compared across
graphs. In the page graph, batching can produce a
`ModuleOrBatch::Module(c-shared)` node whose edges point at earlier
siblings (`a`, `b`). Because `/_app` already has `Module(c-shared)`, the
traversal excluded that subtree, and `a`/`b` were never chunked.
Hydration then fails with `Module [project]/lib/a-page-only.js … was
instantiated … but the module factory is not available`. No test covered
this, because the #97664 test was skipped in dev.

#98823 chains `/_app` and the page into one dev client module graph,
which fixes the missing modules.

It does **not** yet dedupe the `app-document-import-order` fixture in
Turbopack dev. The shared module is still emitted in both the `_app` and
the page chunk, because the page's batches in the combined graph carry
different chunk-group bitmaps than `_app`'s own graph, so they don't
match `_app`'s availability items. Hence the gate. It stays valid
through #98734 and #99750; #98685 (higher in this stack) dedupes the
fixture in Turbopack dev, turning it into a "stale gate", and removes
it.

### How was it verified?

Local JS build of this branch plus the preview native binaries of
#98823's head (`8dd759a`) and its base (`26fd598`) in
`packages/next-swc/native`. The two commits differ only in Rust.

| Suite | Mode | #98823 base | #98823 head |
|---|---|---|---|
| `pages-app-availability-boundary` | Turbopack dev | ❌ module factory
not available | ✅ |
| `pages-app-availability-boundary` | Turbopack start, webpack dev/start
| | ✅ |
| `app-document-import-order` | Turbopack dev | | ✅ (gated: failed as
expected) |
| `app-document-import-order` | webpack dev, Turbopack/webpack start | |
✅ |

After restacking onto canary, with native builds of each layer, in
Turbopack dev:

- This PR's tree: `pages-app-availability-boundary` ✅,
`app-document-import-order` ✅ (gated: failed as expected).
- #98734 and #99750: gated assertion still fails as expected.
- #98685 and #99050: gate stale, so #98685 removes it; ungated, both
suites ✅.

Fixes #99789 (together with #98823)

<!-- NEXT_JS_LLM -->


<!-- fleet a7614af3-e186-4297-beaf-7e55eaddc0da -->


<!-- fleet 4f67c6f7-bbe2-454b-9a17-822a60ef8f7e -->

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: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com>
@sokra
sokra force-pushed the codex/turbopack-keyed-chunk-group-index branch from f21e085 to 9785538 Compare October 9, 2026 22:15
@lukesandberg
lukesandberg marked this pull request as ready for review October 14, 2026 21:18
@sokra
sokra force-pushed the codex/turbopack-keyed-chunk-group-index branch from 9785538 to 575d570 Compare October 15, 2026 07:53
@sokra
sokra force-pushed the codex/turbopack-keyed-chunk-group-index branch from 575d570 to 82ab6bf Compare October 15, 2026 09:02
`ChunkGroupInfo::get_index_of` read `chunk_group_keys` off the whole
`ChunkGroupInfo`, so every index lookup in the graph depended on all of it
and re-executed whenever any unrelated chunk group changed.

Add `ChunkGroupKeyToIndex`, a `cell = "keyed"` map from `ChunkGroupKey` to
its index, and resolve lookups through `Vc::get` so a lookup depends only on
the key it asks for.

The existing `FxIndexSet<ChunkGroupKey>` cannot serve this: `KeyedAccess for
IndexSet` yields `()` rather than the index, and `KeyedEq for IndexSet`
compares membership while ignoring position, so a key whose index merely
shifted would not be reported as changed and lookups would return a stale
index. Mapping to the index as the value makes `KeyedEq for HashMap` compare
indices, which detects exactly that. The set stays for the reverse
(index -> key) direction.

The debug-only "couldn't find chunk group" path still reads the whole struct;
it is only taken on failure, so the happy path keeps its narrow dependency.

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
@sokra
sokra force-pushed the codex/turbopack-keyed-chunk-group-index branch from 82ab6bf to 62d66a2 Compare October 15, 2026 10:10
@sokra
sokra merged commit 2420b9f into canary Oct 24, 2026
119 checks passed
@sokra
sokra deleted the codex/turbopack-keyed-chunk-group-index branch October 24, 2026 18:36
sokra added a commit that referenced this pull request Oct 24, 2026
### What?

Next.js output chunking uses the merged chunk groups derived by
`ChunkGroupInfo`, rather than reconstructing them in output code:

- **Client references** (`app_client_references_chunks.rs`): each layout
segment chunks the isolated merged client reference groups the graph
derived for its references, with one `chunk_group` call per segment and
side (`ssr modules`, `client modules`, using the list form from #99686).
There is no manual availability threading or result concatenation.
- **Server utilities** (`app.rs`, per-page module graph): the entry's
server utilities are chunked as the graph's shared merged group for the
entry and the server utility merge tag, instead of a hand-built
`SharedMerged` group.
- **Ordered traversal**: chunk traversal starts from
`ModuleBatchesGraph::get_ordered_entries` for each group, in list order.

The chunking API still takes `ChunkGroup` values; this PR passes the
groups exactly as the graph registered them. #99750 then switches the
API to keys.

This is PR 3 of a six-PR stack, based on `canary` (#98733, #99686 and
#99749 are already merged):

1. #98823 — Pages Router dev client graph chaining
2. #99910 — regression test for Pages Router `_app` availability
(#99789)
3. **This PR** — use graph-derived merged groups
4. #99750 — chunking takes a `ChunkGroupKey`
5. #98685 — chunk-group availability bitmaps
6. #99050 — keyed chunk-group index lookups

### Why?

The module graph derives merged parents from actual membership bitmaps.
Output code previously guessed a parent and assembled an entry list by
hand, which could disagree with the graph for shared client references
and framework routes.

A segment's client references can derive several merged groups (e.g. one
per page rendering the root layout). Chunking each one separately ran
one chunking task per group; chunking them together runs one per segment
and side.

Graph-derived merged entries have unspecified raw order, so chunk
traversal must use the postorder `ModuleBatchesGraph` already computes,
to preserve CSS evaluation order.

### How?

- `ChunkGroupInfo` exposes exact memberships
(`get_chunk_groups_for_module`) and the shared merged child of a parent
and merge tag (`get_shared_merged_chunk_group`).
- `ClientReference` retains its source module (`parent_module`), so
merge-tagged groups are scoped to their graph parent. Component-owned
references are intersected with their server component's memberships,
which prevents collecting across segments, falling back to the source
module's groups when the intersection is empty.
- The client reference merge tags are shared `pub const`s in
`next_client_reference` (`CLIENT_MERGE_TAG`, used by CSS and ecmascript
client references, and `SSR_MERGE_TAG`), so producers and the chunking
code match on the same values.
- The `server side rendering` / `client side rendering` tracing spans
are kept around the per-segment chunking.

### Verification

- Workspace `cargo clippy --all-targets -D warnings`, `cargo check
--workspace --all-targets`, `cargo fmt --check`
- Turbopack snapshots: 132 passed, no snapshot changes
- `turbo-tasks` + `turbopack-core` + `next-core` unit tests: 364 passed
(incl. the new parent-index and merge-tag tests)
- Production `layout-segment-chunk-sharing`, with a new assertion that
the root layout's client and SSR modules are each chunked by one task.
The suite now waits until the trace server has fully read the trace
before querying spans.
- Turbopack production and dev: `404-page-router`,
`app-dir/actions/app-action`, `app-dir/initial-css-order`; production
`edge-pages-support`

<!-- NEXT_JS_LLM -->


<!-- fleet 4f67c6f7-bbe2-454b-9a17-822a60ef8f7e -->

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>
sokra added a commit that referenced this pull request Oct 24, 2026
### What?

Chunking APIs take chunk group keys instead of chunk groups:

- `ChunkingContext::chunk_group` takes `chunk_groups:
SmallVec<[ChunkGroupKey; 1]>`.
- `evaluated_chunk_group`, `entry_chunk_group` and all
`ChunkingContextExt` helpers take `chunk_group: ChunkGroupKey`.
- The browser and Node.js chunking contexts resolve each key with the
new `ChunkGroupInfo::get_chunk_group(key)` and chunk the registered
group, using its entries for evaluation and bootstrapping.
- Callers pass keys: `ChunkGroupKey::{Entry, Async, Isolated, Shared}`,
or the `.key()` of a graph-derived merged group.

### Why?

Chunking already resolved every group by key and failed if the group
wasn't registered, so the entries a caller passed in were redundant at
best and inconsistent with the graph at worst. With key-only APIs,
`ChunkGroupInfo` is the single source of a group's contents, and callers
can no longer construct a group that disagrees with the module graph.

This is PR 4 of a six-PR stack, based on `canary` (#98733, #99686 and
#99749 are already merged):

1. #98823 — Pages Router dev client graph chaining
2. #99910 — regression test for Pages Router `_app` availability
(#99789)
3. #98734 — use graph-derived merged groups
4. **This PR** — chunking takes a `ChunkGroupKey`
5. #98685 — chunk-group availability bitmaps
6. #99050 — keyed chunk-group index lookups

### Verification

- The tree of this PR is identical to the previous #98734 head, which
passed:
- Workspace `cargo clippy --all-targets -D warnings`, per-layer `cargo
fmt` and workspace check
  - Turbopack snapshots: 132 passed, no snapshot changes
- Turbopack production and dev `404-page-router`,
`app-dir/actions/app-action` and `app-dir/initial-css-order`; production
`edge-pages-support`, `app-dir/css-order` and
`layout-segment-chunk-sharing`

<!-- NEXT_JS_LLM -->


<!-- fleet 4f67c6f7-bbe2-454b-9a17-822a60ef8f7e -->

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>
sokra added a commit that referenced this pull request Oct 24, 2026
### What?

Replaces Turbopack's retained available-module sets with compact
chunk-group bitmaps.

This is PR 5 of a six-PR stack, based on `canary` (#98733, #99686 and
#99749 are already merged):

1. #98823 — Pages Router dev client graph chaining
2. #99910 — regression test for Pages Router `_app` availability
(#99789)
3. #98734 — use graph-derived merged groups
4. #99750 — chunking takes a `ChunkGroupKey`
5. **This PR** — chunk-group availability bitmaps
6. #99050 — keyed chunk-group index lookups

### Why?

`AvailabilityInfo` previously retained every concrete module, module
batch, and async-loader item emitted by ancestor chunk groups. The same
module lists were duplicated across availability chains, increasing
memory use for large graphs.

The module graph already records which chunk groups contain each module.
Retaining only available group indices lets availability checks reuse
that information.

### How?

- Stores available chunk-group indices in a roaring bitmap
(`AvailableChunkGroups`), replacing `AvailableModules`.
- Determines module availability by intersecting its `ChunkGroupInfo`
membership bitmap with the available groups.
- Uses a batch's representative first module, because every module in a
batch has identical group membership.
- Tracks async-loader membership by the parent groups that emit each
loader, preserving nested loader deduplication.
- Scopes availability to its `ModuleGraph`: chunk-group indices only
mean something inside one graph's `ChunkGroupInfo`, so the graph is
stored alongside the bitmap and asserted on every use, and `translate`
re-expresses availability in another graph by mapping indices through
their canonical `ChunkGroupKey`s. Groups the target graph doesn't have
are dropped, which can only duplicate a module, never omit one.

Behavior is unchanged. Availability-dependent asset identifiers are
derived from the *identity* of the available chunk groups — each group
rendered via `ChunkGroupKey::ident_str` (module idents plus merge tags,
merged parents resolved recursively) and sorted — never from the raw
graph-local indices, which are only meaningful within a single
`ModuleGraph` and would collide across the per-endpoint graphs used in
development. Deterministic filenames change as a result; the snapshots
are updated accordingly and the module IDs they contain are identical.

One observable improvement: in Turbopack dev, `/_app` and the page are
chunked in the same graph (#98823), and availability is now tracked by
chunk-group membership, so modules shared by both are loaded only once.
The `// @gate !(turbopack && dev)` that #99910 put on the "loads modules
shared by _app and the page only once" assertion in
`app-document-import-order` is therefore stale, and this PR removes it.

### Verification

- `turbopack-core` — 164 passed
- `next-core` — 84 passed
- Turbopack snapshots — 126 passed
- Nested async availability coverage verifies shared-module and loader
deduplication
- Pages/App development and production suites, including
`404-page-router`, `server-navigation-error`, client-reference chunking,
and full `css-order`
- Turbopack dev `app-document-import-order` (now ungated) and
`pages-app-availability-boundary`, on this PR and on #99050
- Affected Cargo checks and Rust formatting

### Benchmarks

Measured on commits `64b73c08` (this PR) against `c543a06d` (#98823) —
the pre-rebase SHAs of this stack. The stack has since been rebased onto
newer canary; the refactor's code diff is byte-identical across that
rebase, so these numbers still characterise this patch, but they were
not re-measured on the current heads.

The two commits were one apart and differed only in Rust, so the JS
build was shared and the two native binaries were swapped in place with
their sha256 asserted before every run.

`next build --turbopack`, cold (`rm -rf .next`) every run, **3 runs per
side, sides interleaved**. Memory is the peak **aggregate RSS of the
whole build process tree**, sampled from `/proc` every 50 ms (GNU `time
-v` reports only the max of a single waited-for child, which understates
a multi-process build). Median shown, with min–max in brackets.
"disjoint" = the two sides' ranges do not overlap at all.

**Workloads where availability actually engages** — it only does work
when a chunk group inherits availability from a parent, i.e. dynamic
imports or multiple entries:

| Workload | Metric | base | this PR | Δ | |
|---|---|---|---|---|---|
| 200 routes × 6 dynamic imports | build | 108.7 s [105.7–111.6] |
**75.9 s** [74.0–78.0] | **−30.1%** | disjoint |
| | peak RSS | 3462 MB [3361–3467] | **3093 MB** [3052–3153] |
**−10.7%** | disjoint |
| 750 routes, no dynamic imports | build | 314.5 s [313.1–323.0] |
**292.4 s** [289.4–311.2] | **−7.0%** | disjoint |
| | peak RSS | 9220 MB [9212–9687] | 10904 MB [9196–11164] | — |
inconclusive |

**Control workloads** — single entry, no `import()`, so availability is
effectively root everywhere and there is nothing to improve:

| Workload | build Δ | peak RSS Δ | |
|---|---|---|---|
| `bench/module-cost` (~18.6k static modules) | −3.3% | 0.0% | noise |
| `bench/heavy-npm-deps` (1 page, heavy deps) | −1.9% | −1.6% | noise |

Build time improves monotonically with how much availability a workload
exercises (−1.9%, −3.3%, −7.0%, −30.1%). Emitted `.js` file counts are
identical between sides on every workload, consistent with unchanged
behaviour.

Caveats: the dynamic-import workload is synthetic and its 30% figure is
not a typical-app claim; n=3 with no statistical test; aggregate RSS
double-counts pages shared between processes; the 750-route memory
result is bimodal on this PR's side (its own range spans the base
median) and is therefore reported as inconclusive rather than a
regression. The many-pages fixture was reduced from its stock 3000
routes to 750 because 3000 exceeds the 16 GB benchmark machine and
thrashes, which would measure swap rather than chunking.

<!-- NEXT_JS_LLM -->


<!-- fleet 4f67c6f7-bbe2-454b-9a17-822a60ef8f7e -->

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

created-by: Turbopack team PRs by the Turbopack team. tests Turbopack Related to Turbopack with Next.js.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants