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