Skip to main content

oj_graph/
propagate.rs

1//! HMR propagation (Vite's `propagateUpdate`): from a changed module up its
2//! importers to the accepting boundaries, or a full reload.
3
4use std::collections::{HashMap, HashSet};
5use std::path::{Path, PathBuf};
6
7use crate::ModuleGraph;
8
9/// One boundary an update stops at (Vite's `PropagationBoundary`): the module
10/// that accepts, the module it accepts (itself, or a declared dependency), and
11/// whether the boundary sits inside an import cycle with the changed chain, in
12/// which case a failed re-import must reset the page instead of surfacing an
13/// error (Vite's `isWithinCircularImport`).
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
15pub struct UpdateTarget {
16    pub boundary: PathBuf,
17    pub accepted: PathBuf,
18    pub within_circular_import: bool,
19}
20
21#[derive(Debug, PartialEq, Eq)]
22pub enum HmrDecision {
23    Update { boundaries: Vec<PathBuf> },
24    FullReload { reason: String },
25}
26
27impl ModuleGraph {
28    /// Whether every binding `importer` uses from `dep` falls inside `dep`'s
29    /// accepted exports (Vite's `areAllImportsAccepted`): the update then
30    /// never climbs through this importer.
31    fn all_imports_accepted(
32        &self,
33        importer: &Path,
34        dep: &Path,
35        accepted: &HashSet<String>,
36    ) -> bool {
37        self.modules
38            .get(importer)
39            .and_then(|n| n.imported_bindings.get(dep))
40            .is_some_and(|bindings| bindings.iter().all(|b| accepted.contains(b)))
41    }
42
43    fn accepts_dep(&self, importer: &Path, dep: &Path) -> bool {
44        self.modules
45            .get(importer)
46            .is_some_and(|n| n.accepted_hmr_deps.contains(dep))
47    }
48
49    /// The update targets for a change. A self-accepting boundary accepts
50    /// itself; an importer that declared the changed module (or a module on the
51    /// way up) in `hot.accept(deps)` is the boundary for that dependency. `Err`
52    /// means a full reload.
53    pub fn update_targets(&self, changed: &Path) -> Result<Vec<UpdateTarget>, String> {
54        if !self.modules.contains_key(changed) {
55            return Err(format!("{} is not in the module graph", changed.display()));
56        }
57        self.collect_boundaries(&[changed], &[]).map(sorted)
58    }
59
60    /// The update targets when a module calls `import.meta.hot.invalidate()`:
61    /// its own acceptance is skipped and the walk starts at its importers, as
62    /// Vite's `updateModules(..., [...mod.importers])` does. A module nothing
63    /// imports is an entry, so the page must reload.
64    pub fn update_targets_from_importers(
65        &self,
66        changed: &Path,
67    ) -> Result<Vec<UpdateTarget>, String> {
68        let Some(node) = self.modules.get(changed) else {
69            return Err(format!("{} is not in the module graph", changed.display()));
70        };
71        if node.importers.is_empty() {
72            return Err(format!("{} invalidated at an entry", changed.display()));
73        }
74        let seeds: Vec<&Path> = node.importers.iter().map(PathBuf::as_path).collect();
75        self.collect_boundaries(&seeds, &[changed]).map(sorted)
76    }
77
78    pub fn propagate_update(&self, changed: &Path) -> HmrDecision {
79        self.propagate_from_seeds(&[changed])
80    }
81
82    pub fn propagate_from_seeds(&self, seeds: &[&Path]) -> HmrDecision {
83        for seed in seeds {
84            if !self.modules.contains_key(*seed) {
85                return HmrDecision::FullReload {
86                    reason: format!("{} is not in the module graph", seed.display()),
87                };
88            }
89        }
90        match self.collect_boundaries(seeds, &[]) {
91            Ok(targets) => HmrDecision::Update {
92                boundaries: sorted(targets.into_iter().map(|t| t.boundary).collect()),
93            },
94            Err(reason) => HmrDecision::FullReload { reason },
95        }
96    }
97
98    /// The dirty set an update of `changed` would stamp (its importers up to
99    /// and including the accepting boundaries), without mutating anything.
100    pub fn dirty_closure(&self, changed: &Path) -> Vec<PathBuf> {
101        let mut dirty: Vec<PathBuf> = vec![changed.to_path_buf()];
102        let mut queue = vec![changed.to_path_buf()];
103        let mut seen: HashSet<PathBuf> = queue.iter().cloned().collect();
104        while let Some(current) = queue.pop() {
105            let Some(node) = self.modules.get(&current) else {
106                continue;
107            };
108            if node.is_self_accepting && current != changed {
109                continue;
110            }
111            for importer in &node.importers {
112                // A dep-accepting importer is not re-fetched: its callback receives
113                // the new dependency module instead.
114                if self.accepts_dep(importer, &current) {
115                    continue;
116                }
117                if seen.insert(importer.clone()) {
118                    dirty.push(importer.clone());
119                    queue.push(importer.clone());
120                }
121            }
122        }
123        dirty.sort();
124        dirty
125    }
126
127    fn collect_boundaries<'a>(
128        &'a self,
129        seeds: &[&'a Path],
130        pre_stack: &[&'a Path],
131    ) -> Result<Vec<UpdateTarget>, String> {
132        let mut colors: HashMap<&'a Path, Color> = HashMap::new();
133        for module in pre_stack {
134            colors.insert(module, Color::Gray);
135        }
136        let mut boundaries: Vec<UpdateTarget> = Vec::new();
137        for seed in seeds {
138            self.climb(seed, &mut colors, &mut boundaries)?;
139        }
140        Ok(boundaries)
141    }
142
143    /// Whether `boundary` is imported, directly or through other modules, by a
144    /// module on the current change chain (the gray nodes of the walk, plus the
145    /// boundary itself): Vite's `isNodeWithinCircularImports`. Stylesheet
146    /// importers are skipped as there, and a module's direct self-import is not
147    /// a cycle.
148    fn is_within_circular_imports(&self, boundary: &Path, colors: &HashMap<&Path, Color>) -> bool {
149        let mut stack = vec![boundary];
150        let mut seen: HashSet<&Path> = HashSet::new();
151        while let Some(current) = stack.pop() {
152            if !seen.insert(current) {
153                continue;
154            }
155            let Some(node) = self.modules.get(current) else {
156                continue;
157            };
158            for importer in &node.importers {
159                let importer = importer.as_path();
160                if importer == current || is_css_path(importer) {
161                    continue;
162                }
163                if importer == boundary || colors.get(importer) == Some(&Color::Gray) {
164                    return true;
165                }
166                stack.push(importer);
167            }
168        }
169        false
170    }
171
172    fn target(
173        &self,
174        boundary: &Path,
175        accepted: &Path,
176        colors: &HashMap<&Path, Color>,
177    ) -> UpdateTarget {
178        UpdateTarget {
179            boundary: boundary.to_path_buf(),
180            accepted: accepted.to_path_buf(),
181            within_circular_import: self.is_within_circular_imports(boundary, colors),
182        }
183    }
184
185    fn climb<'a>(
186        &'a self,
187        seed: &'a Path,
188        colors: &mut HashMap<&'a Path, Color>,
189        boundaries: &mut Vec<UpdateTarget>,
190    ) -> Result<(), String> {
191        enum Step<'s> {
192            Enter(&'s Path),
193            Blacken(&'s Path),
194        }
195
196        let mut stack = vec![Step::Enter(seed)];
197        while let Some(step) = stack.pop() {
198            let current = match step {
199                Step::Blacken(path) => {
200                    colors.insert(path, Color::Black);
201                    continue;
202                }
203                Step::Enter(path) => path,
204            };
205            match colors.get(current) {
206                Some(Color::Black) => continue,
207                // A circular import: skipped like Vite does, so barrel-file cycles
208                // don't turn every edit into a reload; only an entry with no
209                // accepting module does.
210                Some(Color::Gray) => continue,
211                None => {}
212            }
213            let Some(node) = self.modules.get(current) else {
214                return Err(format!("{} is not in the module graph", current.display()));
215            };
216            if node.is_self_accepting {
217                boundaries.push(self.target(current, current, colors));
218                colors.insert(current, Color::Black);
219                continue;
220            }
221            // A partially accepting module (`acceptExports`) is a boundary, and
222            // each importer is then gated on the bindings it uses (Vite's
223            // acceptedHmrExports + importedBindings). Without importers it is
224            // self-accepting, never a dead end.
225            let accepted_exports = node.accepted_exports.as_ref();
226            if accepted_exports.is_some() {
227                boundaries.push(self.target(current, current, colors));
228            } else if node.importers.is_empty() {
229                return Err(format!(
230                    "update reached entry {} with no accepting boundary",
231                    current.display()
232                ));
233            }
234            colors.insert(current, Color::Gray);
235            stack.push(Step::Blacken(current));
236            let mut importers: Vec<&Path> = node.importers.iter().map(PathBuf::as_path).collect();
237            importers.sort_unstable();
238            for importer in importers.into_iter().rev() {
239                // An importer that accepts `current` via hot.accept(deps) is the
240                // boundary for this change; the walk does not continue above it.
241                if self.accepts_dep(importer, current) {
242                    boundaries.push(self.target(importer, current, colors));
243                    continue;
244                }
245                // An importer using only accepted exports is already covered by
246                // the partial boundary above (Vite's areAllImportsAccepted).
247                if let Some(accepted) = accepted_exports {
248                    if self.all_imports_accepted(importer, current, accepted) {
249                        continue;
250                    }
251                }
252                stack.push(Step::Enter(importer));
253            }
254        }
255        Ok(())
256    }
257}
258
259/// Sorted, without duplicates.
260fn sorted<T: Ord>(mut items: Vec<T>) -> Vec<T> {
261    items.sort();
262    items.dedup();
263    items
264}
265
266fn is_css_path(path: &Path) -> bool {
267    matches!(
268        path.extension().and_then(|e| e.to_str()),
269        Some("css" | "scss" | "sass" | "less" | "styl" | "stylus" | "pcss" | "postcss" | "sss")
270    )
271}
272
273#[derive(Clone, Copy, PartialEq)]
274enum Color {
275    Gray,
276    Black,
277}