Skip to main content

next_api/
aggregate_hmr.rs

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/// An update plus the baseline for the next pull.
139#[derive(Debug)]
140pub enum ServerHmrUpdate {
141    /// No runtime update and the graph is equivalent. However, `to` may still advance the pull
142    /// version.
143    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/// Chunk lists that appeared or vanished relative to the pull baseline.
161#[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    // New chunks load on demand but must advance the baseline.
293    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
305/// Kept outside Turbo Tasks so old pull baselines cannot reactivate.
306pub 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}