Skip to content

Turbopack: aggregate server HMR into one subscription#94948

Open
wbinnssmith wants to merge 1 commit into
canaryfrom
wbinnssmith/server-hmr-firehose
Open

Turbopack: aggregate server HMR into one subscription#94948
wbinnssmith wants to merge 1 commit into
canaryfrom
wbinnssmith/server-hmr-firehose

Conversation

@wbinnssmith

@wbinnssmith wbinnssmith commented Jun 18, 2026

Copy link
Copy Markdown
Member

This replaces per-chunk Server HMR turbo tasks with a single firehose subscription that diffs every HMR chunk. This significantly cuts the number of tokio task churn on projects with many server chunks and centralizes the diff/clear logic. It leads to a multi-second saving in both cold and warm builds in a large app.

A following PR will bring this to client chunks.

Rust:

  • New aggregate_hmr module: AggregateHmrVersion keyed by chunk path, merged_partial_update builder, and is_hmr_eligible_chunk (excludes .map files, which would force every diff to Total).
  • Project::all_hmr_version_state / all_hmr_update aggregate over the whole hmr_root_path. The seed transition emits an empty Partial so the JS consumer doesn't treat it as a restart and wipe handlers the triggering request just populated. Any chunk requiring Total/Missing escalates the batch.
  • VersionedContentMap::hmr_chunks_in_path lists eligible chunks with their VersionedContent.

NAPI:

  • projectAllHmrEvents(target) returns a single subscription.

JS:

  • setupServerHmr subscribes once via allHmrEvents instead of fanning out over hmrChunkNamesSubscribe.
  • clear() evicts every chunk under server/chunks/ from require.cache directly rather than tracking subscriptions. This is a bit fragile as it relies on the path prefix and scanning require.cache.

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: f5be7ce

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Stats cancelled

Commit: f5be7ce
View workflow run

Comment thread packages/next/src/server/dev/hot-reloader-turbopack.ts
@wbinnssmith wbinnssmith force-pushed the wbinnssmith/server-hmr-firehose branch from 831cbfb to 98ac44a Compare June 23, 2026 00:23
wbinnssmith added a commit that referenced this pull request Jun 23, 2026
This uses the infrastructure for a single firehose feed of HMR events introduced
 in #94948. It replaces the chunk list subscriber and per-chunk-list fan-out of
\`project.hmrEvents\` subscriptions with a single \`projectClientHmrEvents\` sub
scription that diffs every client chunk list under the client root in one tick.

For now, unlike the server side, updates are *not* merged into one instruction.
The browser identifies each update by chunk list path (\`resource.path\`), so th
e napi layer emits one \`ClientUpdateInstruction\` per changed chunk list and th
e existing per-resource HMR dispatcher routes each one to its callback.

Rust:
- \`aggregate_hmr.rs\`: new \`ClientChunkListUpdate\` / \`ClientChunkListUpdateK
ind\` / \`ClientHmrUpdates\` to model per-chunk-list updates while preserving ch
unk list identity.
- \`versioned_content_map.rs\`: \`hmr_chunks_in_path\` filters to entries that d
owncast to \`EcmascriptDevChunkListContent\` for \`HmrTarget::Client\`, the only
shape the browser HMR runtime can apply.
- \`project.rs\`:
  - \`hmr_version_state\` handles \`HmrTarget::Client\` by snapshotting chunk li
sts.
  - \`client_hmr_update\` returns one entry per chunk list with a non-empty diff
. \`Total\`/\`Missing\` becomes a per-resource \`Restart\`; the rest of the batc
h is still delivered. The first tick returns an empty result so the seed transi
tion can advance \`VersionState\` without the JS consumer applying anything.
  - \`ClientHmrUpdates::to\` exposes the aggregate \`to\` version on the same st
ruct as \`updates\` so the napi subscriber can advance state even when no chunk
lists changed.

NAPI:
- \`projectClientHmrEvents\` returns a single subscription emitting \`{ updates:
 ClientUpdateInstruction[] }\`. Issues are mirrored on every entry; when there a
re issues but no updates, a sentinel \`issues\`-only frame is emitted under \`__
next_client_hmr__\`.

JS:
- \`hot-reloader-turbopack.ts\` subscribes once via \`clientHmrEvents()\` instea
d of fanning out per \`turbopack-subscribe\`. The browser still emits \`turbopa
ck-subscribe\`/\`-unsubscribe\` frames on chunk list registration; they're now
accepted as no-ops for protocol compat.
- \`turbopack-utils.ts\` drops the per-client \`subscriptions\` map and \`unsubs
cribeFromHmrEvents\` hook.
@wbinnssmith wbinnssmith force-pushed the wbinnssmith/server-hmr-firehose branch 2 times, most recently from 46e9051 to 9c55cbe Compare June 23, 2026 17:56
wbinnssmith added a commit that referenced this pull request Jun 23, 2026
This uses the infrastructure for a single firehose feed of HMR events introduced
 in #94948. It replaces the chunk list subscriber and per-chunk-list fan-out of
\`project.hmrEvents\` subscriptions with a single \`projectClientHmrEvents\` sub
scription that diffs every client chunk list under the client root in one tick.

For now, unlike the server side, updates are *not* merged into one instruction.
The browser identifies each update by chunk list path (\`resource.path\`), so th
e napi layer emits one \`ClientUpdateInstruction\` per changed chunk list and th
e existing per-resource HMR dispatcher routes each one to its callback.

Rust:
- \`aggregate_hmr.rs\`: new \`ClientChunkListUpdate\` / \`ClientChunkListUpdateK
ind\` / \`ClientHmrUpdates\` to model per-chunk-list updates while preserving ch
unk list identity.
- \`versioned_content_map.rs\`: \`hmr_chunks_in_path\` filters to entries that d
owncast to \`EcmascriptDevChunkListContent\` for \`HmrTarget::Client\`, the only
shape the browser HMR runtime can apply.
- \`project.rs\`:
  - \`hmr_version_state\` handles \`HmrTarget::Client\` by snapshotting chunk li
sts.
  - \`client_hmr_update\` returns one entry per chunk list with a non-empty diff
. \`Total\`/\`Missing\` becomes a per-resource \`Restart\`; the rest of the batc
h is still delivered. The first tick returns an empty result so the seed transi
tion can advance \`VersionState\` without the JS consumer applying anything.
  - \`ClientHmrUpdates::to\` exposes the aggregate \`to\` version on the same st
ruct as \`updates\` so the napi subscriber can advance state even when no chunk
lists changed.

NAPI:
- \`projectClientHmrEvents\` returns a single subscription emitting \`{ updates:
 ClientUpdateInstruction[] }\`. Issues are mirrored on every entry; when there a
re issues but no updates, a sentinel \`issues\`-only frame is emitted under \`__
next_client_hmr__\`.

JS:
- \`hot-reloader-turbopack.ts\` subscribes once via \`clientHmrEvents()\` instea
d of fanning out per \`turbopack-subscribe\`. The browser still emits \`turbopa
ck-subscribe\`/\`-unsubscribe\` frames on chunk list registration; they're now
accepted as no-ops for protocol compat.
- \`turbopack-utils.ts\` drops the per-client \`subscriptions\` map and \`unsubs
cribeFromHmrEvents\` hook.
@wbinnssmith wbinnssmith force-pushed the wbinnssmith/server-hmr-firehose branch 2 times, most recently from 2c63e80 to 98f5dd9 Compare June 23, 2026 19:10
@wbinnssmith wbinnssmith marked this pull request as ready for review June 23, 2026 19:36
bail!("all_hmr_version_state is not yet implemented for the client target");
}

// The session argument keeps this from caching across sessions.

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.

this is a trick reused from

// The session argument is important to avoid caching this function between
// sessions.
let _ = session;

@bgw @lukesandberg is there a more modern way of doing this with turbotasks?

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.

you can also tag it as session_dependent which will force a re-execution on next session

if you don't want it to be cached then you could just make VersionState as serialization=skip

@sokra sokra left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I really don't like that we stay subscribed to all visited routes over the lifetime of the dev server. This leads to excessive compilation and memory usage over time.

Instead we should maintain some state in a turbo-tasks value which is the list of subscribed chunk groups. We can update that state via napi methods, when clients are subscribing/unsubscribing.

@wbinnssmith

Copy link
Copy Markdown
Member Author

I really don't like that we stay subscribed to all visited routes over the lifetime of the dev server. This leads to excessive compilation and memory usage over time.

As discussed, this will be a priority going forward with the client implementation, but this is a reasonable limitation to start with for the server implementation.

Replace per-chunk Server HMR fan-out with a single firehose subscription that diffs every HMR-eligible chunk under the target root in one tick.  This significantly cuts the number of tokio task churn on projects with many server chunks and centralizes the diff/clear logic. It leads to a multi-second saving in both cold and warm builds in a large app.

A following PR will bring this to client chunks.

Rust:
- New `aggregate_hmr` module: `AggregateHmrVersion` keyed by chunk path, `merged_partial_update` builder, and `is_hmr_eligible_chunk` (excludes `.map` files, which would force every diff to `Total`).
- `Project::all_hmr_version_state` / `all_hmr_update` aggregate over the whole `hmr_root_path`. The seed transition emits an empty `Partial` so the JS consumer doesn't treat it as a restart and wipe handlers the triggering request just populated. Any chunk requiring `Total`/`Missing`
  escalates the batch.
- `VersionedContentMap::hmr_chunks_in_path` lists eligible chunks with their `VersionedContent`.

NAPI:
- `projectAllHmrEvents(target)` returns a single subscription.

JS:
- `setupServerHmr` subscribes once via `allHmrEvents` instead of fanning out over `hmrChunkNamesSubscribe`.
- `clear()` evicts every chunk under `server/chunks/` from `require.cache` directly rather than tracking subscriptions. This is a bit fragile as it relies on the path prefix and scanning require.cache.
wbinnssmith added a commit that referenced this pull request Jun 25, 2026
This uses the infrastructure for a single firehose feed of HMR events introduced
 in #94948. It replaces the chunk list subscriber and per-chunk-list fan-out of
\`project.hmrEvents\` subscriptions with a single \`projectClientHmrEvents\` sub
scription that diffs every client chunk list under the client root in one tick.

For now, unlike the server side, updates are *not* merged into one instruction.
The browser identifies each update by chunk list path (\`resource.path\`), so th
e napi layer emits one \`ClientUpdateInstruction\` per changed chunk list and th
e existing per-resource HMR dispatcher routes each one to its callback.

Rust:
- \`aggregate_hmr.rs\`: new \`ClientChunkListUpdate\` / \`ClientChunkListUpdateK
ind\` / \`ClientHmrUpdates\` to model per-chunk-list updates while preserving ch
unk list identity.
- \`versioned_content_map.rs\`: \`hmr_chunks_in_path\` filters to entries that d
owncast to \`EcmascriptDevChunkListContent\` for \`HmrTarget::Client\`, the only
shape the browser HMR runtime can apply.
- \`project.rs\`:
  - \`hmr_version_state\` handles \`HmrTarget::Client\` by snapshotting chunk li
sts.
  - \`client_hmr_update\` returns one entry per chunk list with a non-empty diff
. \`Total\`/\`Missing\` becomes a per-resource \`Restart\`; the rest of the batc
h is still delivered. The first tick returns an empty result so the seed transi
tion can advance \`VersionState\` without the JS consumer applying anything.
  - \`ClientHmrUpdates::to\` exposes the aggregate \`to\` version on the same st
ruct as \`updates\` so the napi subscriber can advance state even when no chunk
lists changed.

NAPI:
- \`projectClientHmrEvents\` returns a single subscription emitting \`{ updates:
 ClientUpdateInstruction[] }\`. Issues are mirrored on every entry; when there a
re issues but no updates, a sentinel \`issues\`-only frame is emitted under \`__
next_client_hmr__\`.

JS:
- \`hot-reloader-turbopack.ts\` subscribes once via \`clientHmrEvents()\` instea
d of fanning out per \`turbopack-subscribe\`. The browser still emits \`turbopa
ck-subscribe\`/\`-unsubscribe\` frames on chunk list registration; they're now
accepted as no-ops for protocol compat.
- \`turbopack-utils.ts\` drops the per-client \`subscriptions\` map and \`unsubs
cribeFromHmrEvents\` hook.
@wbinnssmith wbinnssmith force-pushed the wbinnssmith/server-hmr-firehose branch from 98f5dd9 to f5be7ce Compare June 25, 2026 21:35
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