1use std::{
2 fmt::Display,
3 sync::{Arc, LazyLock},
4};
5
6use anyhow::Result;
7use serde::Serialize;
8use turbo_rcstr::RcStr;
9use turbo_tasks::{
10 FxIndexMap, FxIndexSet, NonLocalValue, ReadRef, ResolvedVc, TryJoinIterExt, Vc,
11 debug::ValueDebugFormat,
12 message_queue::{CompilationEvent, Severity},
13 trace::TraceRawVcs,
14 turbo_tasks,
15};
16use turbo_tasks_hash::{Xxh3Hash64Hasher, encode_base64};
17use turbopack_core::{
18 update_instruction::UpdateInstruction,
19 version::{PartialUpdate, Update, Version},
20};
21use turbopack_ecmascript::chunk_list::{
22 merged_update::EcmascriptMergedUpdate,
23 update::{ChunkListUpdate, ChunkUpdate, EcmascriptUpdateInstruction},
24 version::ChunkListVersion,
25};
26use turbopack_nodejs::ecmascript::node::entry::chunk_list_content::{
27 EcmascriptBuildNodeChunkListContent, compute_update_from_version_operation,
28};
29
30#[derive(Clone, TraceRawVcs, PartialEq, Eq, ValueDebugFormat, NonLocalValue)]
31pub struct ServerHmrChunkList {
32 pub relative_path: RcStr,
33 pub versioned_content: ResolvedVc<EcmascriptBuildNodeChunkListContent>,
34}
35
36#[turbo_tasks::value(transparent, serialization = "skip")]
37#[derive(Clone)]
38pub struct ServerHmrChunkLists(Vec<ServerHmrChunkList>);
39
40impl ServerHmrChunkLists {
41 pub fn new(chunk_lists: Vec<ServerHmrChunkList>) -> Self {
42 Self(chunk_lists)
43 }
44
45 pub fn as_slice(&self) -> &[ServerHmrChunkList] {
46 &self.0
47 }
48
49 pub fn retain_entry_paths(&mut self, entry_paths: &FxIndexSet<RcStr>) {
50 self.0
51 .retain(|chunk_list| entry_paths.contains(&chunk_list.relative_path));
52 }
53}
54
55#[turbo_tasks::value(serialization = "skip", shared)]
56#[derive(Debug)]
57pub struct ServerHmrChunkListVersion {
58 #[turbo_tasks(trace_ignore)]
59 pub versions_by_chunk_list_path: FxIndexMap<RcStr, ReadRef<ChunkListVersion>>,
60}
61
62#[turbo_tasks::value_impl]
63impl Version for ServerHmrChunkListVersion {
64 #[turbo_tasks::function]
65 async fn id(&self) -> Result<Vc<RcStr>> {
66 let mut hasher = Xxh3Hash64Hasher::new();
67 hasher.write_value(self.versions_by_chunk_list_path.len());
68 for (path, version) in &self.versions_by_chunk_list_path {
69 hasher.write_value(path.as_str());
70 hasher.write_value(version.id.as_str());
71 }
72 Ok(Vc::cell(encode_base64(hasher.finish()).into()))
73 }
74}
75
76impl ServerHmrChunkListVersion {
77 pub async fn from_chunk_lists(chunk_lists: &[ServerHmrChunkList]) -> Result<Self> {
78 let versions_by_chunk_list_path = chunk_lists
79 .iter()
80 .map(|chunk_list| {
81 let relative_path = chunk_list.relative_path.clone();
82 let versioned_content = chunk_list.versioned_content;
83 async move {
84 let version = versioned_content.version().await?;
85 anyhow::Ok((relative_path, version))
86 }
87 })
88 .try_join()
89 .await?
90 .into_iter()
91 .collect();
92 Ok(Self {
93 versions_by_chunk_list_path,
94 })
95 }
96}
97
98#[derive(Default)]
99pub struct ChunkListUpdateBuilder {
100 chunks: FxIndexMap<RcStr, ChunkUpdate>,
101 merged: FxIndexSet<EcmascriptMergedUpdate>,
102}
103
104impl ChunkListUpdateBuilder {
105 pub fn add_instruction(&mut self, instruction: &UpdateInstruction) {
106 let instruction = instruction
107 .downcast_ref::<EcmascriptUpdateInstruction>()
108 .expect("aggregate HMR only accepts ECMAScript update instructions");
109
110 match instruction {
111 EcmascriptUpdateInstruction::ChunkList(update) => {
112 for (chunk_path, update) in &update.chunks {
113 self.chunks.insert(chunk_path.clone(), update.clone());
114 }
115 for update in &update.merged {
116 self.push_merged(update);
117 }
118 }
119 EcmascriptUpdateInstruction::Merged(update) => self.push_merged(update),
120 }
121 }
122
123 fn push_merged(&mut self, update: &EcmascriptMergedUpdate) {
124 self.merged.insert(update.clone());
125 }
126
127 pub fn is_empty(&self) -> bool {
128 self.chunks.is_empty() && self.merged.is_empty()
129 }
130
131 pub fn build(self) -> UpdateInstruction {
132 ChunkListUpdate {
133 chunks: self.chunks,
134 merged: self.merged.into_iter().collect(),
135 }
136 .into_instruction()
137 }
138}
139
140#[derive(Debug, TraceRawVcs)]
142pub enum ServerHmrUpdate {
143 NoRuntimeUpdate {
146 to: Option<ReadRef<ServerHmrChunkListVersion>>,
147 },
148 FullReevaluation {
149 to: ReadRef<ServerHmrChunkListVersion>,
150 },
151 Partial {
152 to: ReadRef<ServerHmrChunkListVersion>,
153 instruction: UpdateInstruction,
154 },
155}
156
157struct DiffResult {
158 chunk_updates: Vec<ReadRef<Update>>,
159 membership: ChunkListMembershipChange,
160}
161
162#[derive(Default, Clone, Copy)]
164struct ChunkListMembershipChange {
165 has_new: bool,
166 has_removed: bool,
167}
168
169static TRACE_DIFFING: LazyLock<bool> = LazyLock::new(|| {
170 cfg!(debug_assertions) && std::env::var_os("NEXT_TEST_SERVER_HMR_DIFFING").is_some()
171});
172
173#[derive(Serialize)]
174#[serde(rename_all = "camelCase")]
175struct ServerHmrChunkListDiffEvent {
176 #[serde(rename = "entryPath")]
177 chunk_list_path: RcStr,
178}
179
180impl Display for ServerHmrChunkListDiffEvent {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 write!(f, "Diffing server HMR entry {}", self.chunk_list_path)
183 }
184}
185
186impl CompilationEvent for ServerHmrChunkListDiffEvent {
187 fn type_name(&self) -> &'static str {
188 "ServerHmrEntryDiffEvent"
189 }
190
191 fn severity(&self) -> Severity {
192 Severity::Trace
193 }
194
195 fn message(&self) -> String {
196 self.to_string()
197 }
198
199 fn to_json(&self) -> String {
200 serde_json::to_string(self).expect("server HMR entry diff event serializes")
201 }
202}
203
204async fn diff_chunks_against(
205 chunk_lists: &[ServerHmrChunkList],
206 from: &ServerHmrChunkListVersion,
207) -> Result<DiffResult> {
208 let current_chunk_list_paths = chunk_lists
209 .iter()
210 .map(|chunk_list| &chunk_list.relative_path)
211 .collect::<FxIndexSet<_>>();
212 let has_removed_chunk_lists = from
213 .versions_by_chunk_list_path
214 .keys()
215 .any(|path| !current_chunk_list_paths.contains(path));
216 let mut has_new_chunk_lists = false;
217 let chunk_updates = chunk_lists
218 .iter()
219 .filter_map(
220 |ServerHmrChunkList {
221 relative_path,
222 versioned_content,
223 }| {
224 if *TRACE_DIFFING {
225 turbo_tasks().send_compilation_event(Arc::new(ServerHmrChunkListDiffEvent {
226 chunk_list_path: relative_path.clone(),
227 }));
228 }
229 let Some(prev) = from.versions_by_chunk_list_path.get(relative_path).cloned()
230 else {
231 has_new_chunk_lists = true;
232 return None;
233 };
234 Some((*versioned_content, prev))
235 },
236 )
237 .map(|(content, prev)| async move {
238 compute_update_from_version_operation(
239 content,
240 turbo_tasks::TransientInstance::new(prev),
241 )
242 .read_strongly_consistent()
243 .await
244 })
245 .try_join()
246 .await?;
247 Ok(DiffResult {
248 chunk_updates,
249 membership: ChunkListMembershipChange {
250 has_new: has_new_chunk_lists,
251 has_removed: has_removed_chunk_lists,
252 },
253 })
254}
255
256enum ServerHmrChunkUpdate<'a> {
257 None,
258 Missing,
259 Total,
260 Partial(&'a UpdateInstruction),
261}
262
263impl<'a> From<&'a Update> for ServerHmrChunkUpdate<'a> {
264 fn from(update: &'a Update) -> Self {
265 match update {
266 Update::None => Self::None,
267 Update::Missing => Self::Missing,
268 Update::Total(_) => Self::Total,
269 Update::Partial(PartialUpdate { instruction, .. }) => Self::Partial(instruction),
270 }
271 }
272}
273
274fn classify_server_hmr_update<'a>(
275 chunk_updates: impl IntoIterator<Item = ServerHmrChunkUpdate<'a>>,
276 membership: ChunkListMembershipChange,
277 to: ReadRef<ServerHmrChunkListVersion>,
278) -> ServerHmrUpdate {
279 if membership.has_removed {
280 return ServerHmrUpdate::FullReevaluation { to };
281 }
282
283 let mut builder = ChunkListUpdateBuilder::default();
284 for update in chunk_updates {
285 match update {
286 ServerHmrChunkUpdate::None => {}
287 ServerHmrChunkUpdate::Missing | ServerHmrChunkUpdate::Total => {
288 return ServerHmrUpdate::FullReevaluation { to };
289 }
290 ServerHmrChunkUpdate::Partial(instruction) => builder.add_instruction(instruction),
291 }
292 }
293
294 if builder.is_empty() {
296 return ServerHmrUpdate::NoRuntimeUpdate {
297 to: membership.has_new.then_some(to),
298 };
299 }
300
301 ServerHmrUpdate::Partial {
302 to,
303 instruction: builder.build(),
304 }
305}
306
307pub async fn compute_server_hmr_update(
309 chunk_lists: &[ServerHmrChunkList],
310 from: Option<&ServerHmrChunkListVersion>,
311 to: ReadRef<ServerHmrChunkListVersion>,
312) -> Result<ServerHmrUpdate> {
313 if chunk_lists.is_empty() {
314 return Ok(ServerHmrUpdate::NoRuntimeUpdate { to: None });
315 }
316
317 let Some(from) = from else {
318 return Ok(ServerHmrUpdate::NoRuntimeUpdate { to: Some(to) });
319 };
320
321 let DiffResult {
322 chunk_updates,
323 membership,
324 } = diff_chunks_against(chunk_lists, from).await?;
325
326 Ok(classify_server_hmr_update(
327 chunk_updates
328 .iter()
329 .map(|update| ServerHmrChunkUpdate::from(&**update)),
330 membership,
331 to,
332 ))
333}
334
335#[cfg(test)]
336mod tests {
337 use turbo_tasks::{FxIndexMap, FxIndexSet, ReadRef};
338 use turbopack_core::update_instruction::UpdateInstruction;
339 use turbopack_ecmascript::chunk_list::{
340 merged_update::{
341 EcmascriptMergedChunkDeleted, EcmascriptMergedChunkUpdate, EcmascriptMergedUpdate,
342 },
343 update::{ChunkListUpdate, ChunkUpdate, EcmascriptUpdateInstruction},
344 };
345
346 use super::{
347 ChunkListMembershipChange, ChunkListUpdateBuilder, ServerHmrChunkListVersion,
348 ServerHmrChunkUpdate, ServerHmrUpdate, classify_server_hmr_update,
349 };
350
351 fn version() -> ReadRef<ServerHmrChunkListVersion> {
352 ReadRef::new_owned(ServerHmrChunkListVersion {
353 versions_by_chunk_list_path: Default::default(),
354 })
355 }
356
357 fn unchanged_membership() -> ChunkListMembershipChange {
358 ChunkListMembershipChange::default()
359 }
360
361 fn added_chunk_lists() -> ChunkListMembershipChange {
362 ChunkListMembershipChange {
363 has_new: true,
364 has_removed: false,
365 }
366 }
367
368 fn removed_chunk_lists() -> ChunkListMembershipChange {
369 ChunkListMembershipChange {
370 has_new: false,
371 has_removed: true,
372 }
373 }
374
375 fn merged(chunk_path: &str) -> EcmascriptMergedUpdate {
376 EcmascriptMergedUpdate {
377 entries: Default::default(),
378 chunks: [(
379 chunk_path.into(),
380 EcmascriptMergedChunkUpdate::Deleted(EcmascriptMergedChunkDeleted {
381 modules: Default::default(),
382 }),
383 )]
384 .into_iter()
385 .collect(),
386 }
387 }
388
389 #[test]
390 fn unchanged_chunks_produce_no_runtime_update() {
391 assert!(matches!(
392 classify_server_hmr_update(
393 [ServerHmrChunkUpdate::None],
394 unchanged_membership(),
395 version()
396 ),
397 ServerHmrUpdate::NoRuntimeUpdate { to: None }
398 ));
399 }
400
401 #[test]
402 fn missing_chunk_produces_full_reevaluation() {
403 assert!(matches!(
404 classify_server_hmr_update(
405 [ServerHmrChunkUpdate::Missing],
406 unchanged_membership(),
407 version()
408 ),
409 ServerHmrUpdate::FullReevaluation { .. }
410 ));
411 }
412
413 #[test]
414 fn total_update_produces_full_reevaluation() {
415 assert!(matches!(
416 classify_server_hmr_update(
417 [ServerHmrChunkUpdate::Total],
418 unchanged_membership(),
419 version()
420 ),
421 ServerHmrUpdate::FullReevaluation { .. }
422 ));
423 }
424
425 #[test]
426 fn partial_instructions_are_combined() {
427 let chunk_list = ChunkListUpdate {
428 chunks: FxIndexMap::from_iter([("a.js".into(), ChunkUpdate::Added)]),
429 merged: vec![],
430 }
431 .into_instruction();
432 let merged_instruction =
433 UpdateInstruction::new(EcmascriptUpdateInstruction::Merged(merged("b.js")));
434
435 let ServerHmrUpdate::Partial { instruction, .. } = classify_server_hmr_update(
436 [
437 ServerHmrChunkUpdate::Partial(&chunk_list),
438 ServerHmrChunkUpdate::Partial(&merged_instruction),
439 ],
440 unchanged_membership(),
441 version(),
442 ) else {
443 panic!("partial instructions should produce a partial aggregate update");
444 };
445 let instruction = instruction
446 .downcast_ref::<EcmascriptUpdateInstruction>()
447 .expect("aggregate instruction is ECMAScript");
448 let EcmascriptUpdateInstruction::ChunkList(update) = instruction else {
449 panic!("aggregate instruction should be a chunk-list update");
450 };
451 assert_eq!(update.chunks["a.js"], ChunkUpdate::Added);
452 assert_eq!(update.merged, [merged("b.js")]);
453 }
454
455 #[test]
456 fn new_chunk_lists_advance_baseline_without_runtime_update() {
457 assert!(matches!(
458 classify_server_hmr_update([], added_chunk_lists(), version()),
459 ServerHmrUpdate::NoRuntimeUpdate { to: Some(_) }
460 ));
461 }
462
463 #[test]
464 fn removed_chunk_lists_produce_full_reevaluation() {
465 assert!(matches!(
466 classify_server_hmr_update([], removed_chunk_lists(), version()),
467 ServerHmrUpdate::FullReevaluation { .. }
468 ));
469 }
470
471 #[test]
472 fn deduplicates_merged_updates_in_first_seen_order() {
473 let first = merged("first.js");
474 let second = merged("second.js");
475 let mut builder = ChunkListUpdateBuilder::default();
476
477 builder.add_instruction(&UpdateInstruction::new(
478 EcmascriptUpdateInstruction::Merged(first.clone()),
479 ));
480 builder.add_instruction(&UpdateInstruction::new(
481 EcmascriptUpdateInstruction::Merged(second.clone()),
482 ));
483 builder.add_instruction(&UpdateInstruction::new(
484 EcmascriptUpdateInstruction::Merged(first.clone()),
485 ));
486
487 assert_eq!(builder.merged, FxIndexSet::from_iter([first, second]));
488 }
489
490 #[test]
491 fn chunk_updates_use_last_writer_and_stable_order() {
492 let mut builder = ChunkListUpdateBuilder::default();
493 let first = ChunkListUpdate {
494 chunks: FxIndexMap::from_iter([
495 ("a.js".into(), ChunkUpdate::Total),
496 ("b.js".into(), ChunkUpdate::Added),
497 ]),
498 merged: vec![],
499 };
500 let second = ChunkListUpdate {
501 chunks: FxIndexMap::from_iter([
502 ("a.js".into(), ChunkUpdate::Deleted),
503 ("c.js".into(), ChunkUpdate::Total),
504 ]),
505 merged: vec![],
506 };
507
508 builder.add_instruction(&first.into_instruction());
509 builder.add_instruction(&second.into_instruction());
510
511 assert_eq!(
512 builder
513 .chunks
514 .keys()
515 .map(|path| path.as_str())
516 .collect::<Vec<_>>(),
517 ["a.js", "b.js", "c.js"]
518 );
519 assert_eq!(builder.chunks["a.js"], ChunkUpdate::Deleted);
520 }
521}