Skip to content

refactor(turbopack): chunk chunk groups by key - #99750

Merged
sokra merged 1 commit into
codex/turbopack-derived-chunk-groupsfrom
codex/turbopack-chunk-group-key-api
Oct 24, 2026
Merged

sokra merged 1 commit into
codex/turbopack-derived-chunk-groupsfrom
codex/turbopack-chunk-group-key-api

Conversation

@sokra

@sokra sokra commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

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. 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. This PR — chunking takes a ChunkGroupKey
  5. Turbopack: track availability by chunk group #98685 — chunk-group availability bitmaps
  6. perf(turbopack): look up chunk group indices through a keyed cell #99050 — keyed chunk-group index lookups

Verification

  • The tree of this PR is identical to the previous Turbopack: consume graph-derived chunk groups #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

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: 9203f05

sokra commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Reviewed 26fd598

@sokra
sokra marked this pull request as ready for review October 9, 2026 05:55
@sokra
sokra requested a review from mischnic October 9, 2026 05:55
@sokra
sokra force-pushed the codex/turbopack-chunk-group-key-api branch from 26fd598 to 1a13763 Compare October 9, 2026 07:33
@github-actions github-actions Bot added created-by: Turbopack team PRs by the Turbopack team. Turbopack Related to Turbopack with Next.js. labels 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-chunk-group-key-api branch from 1a13763 to 3578cc3 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-derived-chunk-groups to canary October 9, 2026 14:22
@sokra
sokra force-pushed the codex/turbopack-chunk-group-key-api branch from 3578cc3 to 6063005 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-derived-chunk-groups 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-chunk-group-key-api branch from 6063005 to 0df91ea Compare October 9, 2026 22:15
@mischnic

mischnic commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

I'm curious if this is actually a requirement for the rest of the stack, or just a way to prevent random stuff from being chunked?

@vercel-fleet-prod

Copy link
Copy Markdown
Contributor

Mostly the latter. It isn't a hard build or ordering requirement for #98685/#99050, but it closes a hole that #98685 makes dangerous.

Not a hard requirement: I applied #98685 directly on #98734, without this PR. Turbopack core and the chunking contexts compile unchanged. The only error is an import in pages.rs, where #98685's _app code names a ChunkGroupKey:

AvailabilityInfo::root().with_chunk_group(module_graph, ChunkGroupKey::Entry(app_entries))

What #98685 actually depends on is that every chunked group is registered in ChunkGroupInfo under the key it is chunked with. That comes from #99749 and #98734.

Why it still matters with #98685: before this PR, chunking already looked up each group by key (and errored if it wasn't registered), but traversed the entries the caller passed in. Today a mismatch there is mostly harmless, because availability is the set of modules that were actually emitted.

With #98685, availability becomes "group index i is available", and module availability is derived from the registered group's membership bitmap, not from what was emitted. If a caller passed a ChunkGroup whose entries differ from the registered group with the same key, the bitmap would claim modules available that this chunk group never emitted. Downstream groups would then skip them, and you'd get "module factory is not available" at runtime instead of a duplicate module.

Taking only a ChunkGroupKey makes the registered group the single source of a group's contents, so chunking and availability can't disagree by construction. It's also what lets #99050 resolve indices by key without re-deriving anything.

So: #98685 would work today if all callers happen to pass the registered entries (they do after #98734), but this PR turns "callers must not hand-build groups" from a convention into something the type system enforces.

@sokra

sokra commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

In short: It encodes on type-level that only registered chunk groups can be chunked.

@sokra
sokra force-pushed the codex/turbopack-chunk-group-key-api branch from 0df91ea to 7a2b098 Compare October 15, 2026 07:53
@mischnic

Copy link
Copy Markdown
Member

The chunking context I recently added caused a merge conflict

error[E0053]: method `evaluated_chunk_group` has an incompatible type for trait
    --> crates/next-api/src/server_actions.rs:1254:23
     |
1254 |         _chunk_group: ChunkGroup,
     |                       ^^^^^^^^^^ expected `ChunkGroupKey`, found `ChunkGroup`
     |
     = note: expected signature `fn(Vc<_>, Vc<_>, ChunkGroupKey, Vc<_>, Vc<_>, AvailabilityInfo) -> Vc<_>`
                found signature `fn(Vc<_>, Vc<_>, ChunkGroup, Vc<_>, Vc<_>, AvailabilityInfo) -> Vc<_>`

Make chunking APIs take ChunkGroupKey values and resolve the registered
chunk groups from ChunkGroupInfo, so callers cannot supply group contents
that disagree with the module graph.

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
@sokra
sokra force-pushed the codex/turbopack-chunk-group-key-api branch from 7a2b098 to 9203f05 Compare October 15, 2026 09:02
@sokra
sokra merged commit 9bbacd4 into canary Oct 24, 2026
120 checks passed
@sokra
sokra deleted the codex/turbopack-chunk-group-key-api 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?

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

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


<!-- 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