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