Skip to content

Commit 03e086a

Browse files
committed
Turbopack: aggregate client HMR into one subscription
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.
1 parent 9702a76 commit 03e086a

19 files changed

Lines changed: 586 additions & 762 deletions

File tree

crates/next-api/src/aggregate_hmr.rs

Lines changed: 113 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,32 @@
11
//! Aggregated HMR: one [`VersionState`] covering every chunk under a target's
2-
//! root, so the dev server can subscribe once instead of per chunk.
2+
//! root, so the dev server subscribes once instead of per chunk.
33
//!
44
//! [`VersionState`]: turbopack_core::version::VersionState
55
66
use std::sync::Arc;
77

88
use anyhow::Result;
9-
use rustc_hash::FxHashMap;
9+
use rustc_hash::{FxHashMap, FxHashSet};
1010
use turbo_rcstr::RcStr;
11-
use turbo_tasks::{FxIndexMap, ReadRef, ResolvedVc, TraitRef, TryJoinIterExt, Vc};
11+
use turbo_tasks::{
12+
FxIndexMap, NonLocalValue, ReadRef, ResolvedVc, TraitRef, TryJoinIterExt, Vc,
13+
debug::ValueDebugFormat, trace::TraceRawVcs,
14+
};
1215
use turbo_tasks_fs::FileSystemPath;
1316
use turbo_tasks_hash::{Xxh3Hash64Hasher, encode_base64};
1417
use turbopack_core::version::{
1518
NotFoundVersion, PartialUpdate, Update, Version, VersionState, VersionedContent,
1619
};
1720

18-
use crate::versioned_content_map::VersionedContentMap;
21+
use crate::{project::HmrTarget, versioned_content_map::VersionedContentMap};
1922

20-
/// One chunk's contribution to an [`AggregateHmrVersion`]: its output path and
21-
/// the versioned content backing it.
2223
pub struct HmrChunkWithContent {
2324
pub path: RcStr,
2425
pub content: ResolvedVc<Box<dyn VersionedContent>>,
2526
}
2627

27-
/// Whether an emitted chunk participates in HMR. Source map (`.map`) files do
28-
/// not: their content fully rewrites on any source change, which would force
29-
/// per-chunk diffs to escalate to `Total`.
28+
/// Whether an emitted chunk participates in HMR. `.map` files don't: their
29+
/// content rewrites on any source change, forcing per-chunk diffs to `Total`.
3030
pub fn is_hmr_eligible_chunk(name: &str) -> bool {
3131
!name.ends_with(".map")
3232
}
@@ -69,22 +69,21 @@ impl Version for AggregateHmrVersion {
6969
}
7070

7171
impl AggregateHmrVersion {
72-
/// Snapshots every HMR-eligible chunk under `root` in `map` into a new
73-
/// [`Version`]. Returns a [`NotFoundVersion`] when no chunks exist yet
74-
/// (e.g. before any endpoints have been written).
72+
/// Snapshots every HMR-eligible chunk under `root` in `map` for `target`.
73+
/// Returns [`NotFoundVersion`] when no chunks exist yet.
7574
pub async fn from_map(
7675
map: Vc<VersionedContentMap>,
7776
root: &FileSystemPath,
77+
target: HmrTarget,
7878
) -> Result<Vc<Box<dyn Version>>> {
79-
let chunks = map.hmr_chunks_in_path(root).await?;
79+
let chunks = map.hmr_chunks_in_path(root, target).await?;
8080
if chunks.is_empty() {
8181
return Ok(Vc::upcast(NotFoundVersion::new()));
8282
}
8383
Ok(Vc::upcast(Self::from_chunks(&chunks).await?))
8484
}
8585

86-
/// Snapshots each [`HmrChunkWithContent`]'s [`Version`] into a new
87-
/// [`AggregateHmrVersion`].
86+
/// Snapshots each chunk's [`Version`] into a new [`AggregateHmrVersion`].
8887
pub async fn from_chunks(chunks: &[HmrChunkWithContent]) -> Result<Vc<Self>> {
8988
let versions = chunks
9089
.iter()
@@ -126,14 +125,12 @@ pub fn merge_ecmascript_merged_update(
126125
}
127126
}
128127

129-
/// Builds an `Update::Partial` whose instruction is a combined
130-
/// `EcmascriptMergedUpdate` covering `entries` and `chunks`. Empty maps are
131-
/// omitted so an empty `entries`/`chunks` field never appears in the payload.
128+
/// Builds an `Update::Partial` with a combined `EcmascriptMergedUpdate`
129+
/// instruction. Empty maps are omitted so they never appear in the payload.
132130
///
133-
/// Passing empty maps produces an instruction with only `type:
134-
/// "EcmascriptMergedUpdate"`, used to advance `VersionState` to `to` without
135-
/// the JS consumer applying anything: it sees a `partial` event with nothing
136-
/// to apply and short-circuits.
131+
/// With empty maps, produces an instruction with only `type:
132+
/// "EcmascriptMergedUpdate"` — used to advance `VersionState` to `to` without
133+
/// the JS consumer applying anything.
137134
pub fn merged_partial_update(
138135
to: TraitRef<Box<dyn Version>>,
139136
entries: FxHashMap<String, serde_json::Value>,
@@ -162,38 +159,120 @@ pub fn merged_partial_update(
162159
})
163160
}
164161

162+
/// A single chunk list's contribution to an aggregated client HMR tick.
163+
#[derive(Debug, Clone, PartialEq, Eq, TraceRawVcs, NonLocalValue, ValueDebugFormat)]
164+
pub struct ClientChunkListUpdate {
165+
/// Chunk list path relative to the client root. Used as `resource.path`
166+
/// by the browser's HMR dispatcher.
167+
pub path: RcStr,
168+
/// Per-chunk-list update kind. The instruction is kept as
169+
/// `Arc<serde_json::Value>` so it can be relayed to napi without rebuilding.
170+
pub kind: ClientChunkListUpdateKind,
171+
}
172+
173+
#[derive(Debug, Clone, PartialEq, Eq, TraceRawVcs, NonLocalValue, ValueDebugFormat)]
174+
pub enum ClientChunkListUpdateKind {
175+
/// Restart: the browser must reload to recover (Total/Missing).
176+
Restart,
177+
Partial {
178+
#[turbo_tasks(trace_ignore)]
179+
instruction: Arc<serde_json::Value>,
180+
},
181+
}
182+
183+
/// Aggregated client HMR tick: every chunk list with a non-empty diff, plus
184+
/// the aggregate `to` version to advance [`VersionState`] to. `to` is
185+
/// computed alongside `updates` so it participates in invalidation with them.
186+
#[turbo_tasks::value(serialization = "skip", shared)]
187+
pub struct ClientHmrUpdates {
188+
pub updates: Vec<ClientChunkListUpdate>,
189+
#[turbo_tasks(trace_ignore)]
190+
pub to: TraitRef<Box<dyn Version>>,
191+
}
192+
165193
/// Per-chunk [`Update`]s computed against an `AggregateHmrVersion` snapshot.
166-
/// `has_new_chunks` is true when the current snapshot contains chunks absent
167-
/// from `from` (e.g. a new endpoint was written); callers decide whether that
168-
/// affects the batch shape.
194+
/// Used by both server and client aggregate flows; each post-processes the
195+
/// per-chunk results into its target-specific shape.
169196
pub struct DiffResult {
170197
pub chunk_updates: Vec<(RcStr, ReadRef<Update>)>,
198+
/// Chunks present in the current snapshot but absent from `from`. The
199+
/// server reports a non-`None` Partial when new chunks appear; the client
200+
/// ignores this (the runtime require()s new chunk lists on demand).
171201
pub has_new_chunks: bool,
202+
/// Chunk list paths present in `from` but absent from the current
203+
/// snapshot. The client emits a per-resource `Restart` for each so the
204+
/// browser clears stale issues/state for deleted chunk lists. The server
205+
/// ignores this (a `Total` restart is already escalated when any chunk
206+
/// needs `Total`/`Missing`).
207+
pub deleted_chunks: Vec<RcStr>,
208+
}
209+
210+
pub struct AggregateHmrSnapshot {
211+
pub chunks: Vec<HmrChunkWithContent>,
212+
pub to_ref: TraitRef<Box<dyn Version>>,
213+
pub diff: DiffResult,
214+
}
215+
216+
/// Shared prologue for server and client HMR ticks. Lists every chunk under
217+
/// `root` for `target`, builds the aggregate `to` version via
218+
/// [`AggregateHmrVersion::from_map`], and diffs against `from`.
219+
///
220+
/// When no chunks exist yet, `to_ref` is a [`NotFoundVersion`] and `diff` is
221+
/// empty so the consumer can short-circuit.
222+
pub async fn snapshot_aggregate_hmr(
223+
map: Vc<VersionedContentMap>,
224+
root: &FileSystemPath,
225+
target: HmrTarget,
226+
from: Vc<VersionState>,
227+
) -> Result<AggregateHmrSnapshot> {
228+
let chunks = map.hmr_chunks_in_path(root, target).await?;
229+
let to_ref = AggregateHmrVersion::from_map(map, root, target)
230+
.await?
231+
.into_trait_ref()
232+
.await?;
233+
let diff = diff_chunks_against(&chunks, from).await?;
234+
Ok(AggregateHmrSnapshot {
235+
chunks,
236+
to_ref,
237+
diff,
238+
})
172239
}
173240

174241
/// Diffs each chunk against `from`'s [`AggregateHmrVersion`] snapshot, if any.
175-
/// When `from` doesn't downcast to an aggregate version (e.g. the seed
176-
/// transition), the returned `chunk_updates` is empty.
242+
/// When `from` doesn't downcast to an aggregate version (the seed transition),
243+
/// `chunk_updates` is empty.
177244
pub async fn diff_chunks_against(
178245
chunks: &[HmrChunkWithContent],
179246
from: Vc<VersionState>,
180247
) -> Result<DiffResult> {
181-
if chunks.is_empty() {
182-
return Ok(DiffResult {
183-
chunk_updates: Vec::new(),
184-
has_new_chunks: false,
185-
});
186-
}
187248
let from_resolved = from.get().to_resolved().await?;
188249
let Some(from_aggregate) = ResolvedVc::try_downcast_type::<AggregateHmrVersion>(from_resolved)
189250
else {
190251
return Ok(DiffResult {
191252
chunk_updates: Vec::new(),
192253
has_new_chunks: false,
254+
deleted_chunks: Vec::new(),
193255
});
194256
};
195257
let from_aggregate = from_aggregate.await?;
196258

259+
let current_paths: FxHashSet<RcStr> = chunks.iter().map(|c| c.path.clone()).collect();
260+
261+
let deleted_chunks = from_aggregate
262+
.versions
263+
.keys()
264+
.filter(|path| !current_paths.contains(*path))
265+
.cloned()
266+
.collect();
267+
268+
if chunks.is_empty() {
269+
return Ok(DiffResult {
270+
chunk_updates: Vec::new(),
271+
has_new_chunks: false,
272+
deleted_chunks,
273+
});
274+
}
275+
197276
let mut has_new_chunks = false;
198277
let chunk_updates = chunks
199278
.iter()
@@ -213,5 +292,6 @@ pub async fn diff_chunks_against(
213292
Ok(DiffResult {
214293
chunk_updates,
215294
has_new_chunks,
295+
deleted_chunks,
216296
})
217297
}

crates/next-api/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#![feature(arbitrary_self_types_pointers)]
33
#![feature(impl_trait_in_assoc_type)]
44

5-
mod aggregate_hmr;
5+
pub mod aggregate_hmr;
66
pub mod analyze;
77
mod app;
88
mod asset_hashes_manifest;

0 commit comments

Comments
 (0)