Skip to content

Commit 7a134b9

Browse files
committed
feat(rust/watch): handle bulk-change
1 parent b44f77d commit 7a134b9

File tree

5 files changed

+220
-62
lines changed

5 files changed

+220
-62
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Rust Core (crates/rolldown)
4343
- `packages/rolldown`: The main Node.js package exposing the TypeScript API.
4444
- `packages/rolldown-tests`: Test suite for the `rolldown` package using Vitest.
4545
- `packages/rollup-tests`: Compatibility test suite for Rollup plugins.
46+
- `crates/rolldown_watcher`: Watch mode coordinator. See `meta/design/watch-mode.md` for architecture, state machine, debounce/consolidation rules, and event lifecycle.
4647
- `docs/`: Documentation site built with VitePress.
4748
- `meta/design/`: Design documents. See the "Spec-Driven Development" section above.
4849

crates/rolldown_watcher/src/watch_coordinator.rs

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ use crate::watcher::WatcherConfig;
66
use crate::watcher_msg::WatcherMsg;
77
use crate::watcher_state::WatcherState;
88
use oxc_index::IndexVec;
9+
use rolldown_common::WatcherChangeKind;
10+
use rolldown_utils::indexmap::FxIndexMap;
911
use std::mem;
1012
use std::time::Duration;
1113
use tokio::sync::{mpsc, oneshot};
@@ -123,15 +125,15 @@ impl<H: WatcherEventHandler> WatchCoordinator<H> {
123125
/// 5. For each task needing rebuild: BundleStart → build → BundleEnd/Error
124126
/// 6. handler.on_event(End)
125127
/// 7. drain_buffered_events
126-
async fn run_build_sequence(&mut self, changes: Vec<FileChangeEvent>) {
128+
async fn run_build_sequence(&mut self, changes: FxIndexMap<String, WatcherChangeKind>) {
127129
// Step 1 & 2: Notify handler and plugin hooks for each change
128-
for change in &changes {
129-
self.handler.on_change(change.path.as_str(), change.kind).await;
130+
for (path, kind) in &changes {
131+
self.handler.on_change(path.as_str(), *kind).await;
130132
}
131133

132134
for task in &self.tasks {
133-
for change in &changes {
134-
task.call_watch_change(change.path.as_str(), change.kind).await;
135+
for (path, kind) in &changes {
136+
task.call_watch_change(path.as_str(), *kind).await;
135137
}
136138
}
137139

@@ -173,20 +175,21 @@ impl<H: WatcherEventHandler> WatchCoordinator<H> {
173175
self.drain_buffered_events().await;
174176
}
175177

176-
/// Process file changes: call on_invalidate, mark tasks for rebuild, and update state.
178+
/// Process file changes: call on_invalidate per file, mark task for rebuild,
179+
/// then batch all changes into a single state transition.
177180
async fn process_file_changes(
178181
&mut self,
179182
task_index: WatchTaskIdx,
180183
changes: Vec<FileChangeEvent>,
181184
) {
182-
for change in changes {
183-
if let Some(task) = self.tasks.get_mut(task_index) {
185+
if let Some(task) = self.tasks.get_mut(task_index) {
186+
for change in &changes {
184187
task.mark_needs_rebuild(&change.path);
185188
task.call_on_invalidate(&change.path).await;
186189
}
187-
188-
self.state = mem::take(&mut self.state).on_file_change(change, self.debounce_duration);
189190
}
191+
192+
self.state = mem::take(&mut self.state).on_file_changes(changes, self.debounce_duration);
190193
}
191194

192195
/// Drain buffered fs events that arrived during a build.

crates/rolldown_watcher/src/watcher_state.rs

Lines changed: 168 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
use crate::file_change_event::FileChangeEvent;
2+
use rolldown_common::WatcherChangeKind;
3+
use rolldown_utils::indexmap::FxIndexMap;
24
use std::time::{Duration, Instant};
35

46
/// The state machine for the watcher
@@ -16,32 +18,36 @@ pub enum WatcherState {
1618
#[default]
1719
Idle,
1820
/// Collecting changes before triggering a build
19-
Debouncing { changes: Vec<FileChangeEvent>, deadline: Instant },
21+
Debouncing { changes: FxIndexMap<String, WatcherChangeKind>, deadline: Instant },
2022
/// Watcher is closing
2123
Closing,
2224
/// Watcher has closed
2325
Closed,
2426
}
2527

2628
impl WatcherState {
27-
/// Handle a file change event
29+
/// Handle a batch of file change events
2830
///
29-
/// Returns the new state after processing the file change
31+
/// Returns the new state after processing the file changes.
32+
/// See "Kind Consolidation" in `meta/design/watch-mode.md` for dedup rules.
3033
#[must_use]
31-
pub fn on_file_change(self, entry: FileChangeEvent, debounce_duration: Duration) -> Self {
34+
pub fn on_file_changes(self, entries: Vec<FileChangeEvent>, debounce_duration: Duration) -> Self {
35+
if entries.is_empty() {
36+
return self;
37+
}
3238
match self {
3339
WatcherState::Idle => {
40+
let mut changes = FxIndexMap::default();
41+
for entry in entries {
42+
merge_change_kind(&mut changes, entry.path, entry.kind);
43+
}
3444
let deadline = Instant::now() + debounce_duration;
35-
WatcherState::Debouncing { changes: vec![entry], deadline }
45+
WatcherState::Debouncing { changes, deadline }
3646
}
3747
WatcherState::Debouncing { mut changes, .. } => {
38-
// Check if we already have a change for this path
39-
if let Some(existing) = changes.iter_mut().find(|c| c.path == entry.path) {
40-
existing.kind = entry.kind;
41-
} else {
42-
changes.push(entry);
48+
for entry in entries {
49+
merge_change_kind(&mut changes, entry.path, entry.kind);
4350
}
44-
// Reset the deadline
4551
let deadline = Instant::now() + debounce_duration;
4652
WatcherState::Debouncing { changes, deadline }
4753
}
@@ -54,9 +60,15 @@ impl WatcherState {
5460
///
5561
/// Returns (new_state, changes_to_build) if transitioning to Idle,
5662
/// otherwise returns (self, None)
57-
pub fn on_debounce_timeout(self) -> (Self, Option<Vec<FileChangeEvent>>) {
63+
pub fn on_debounce_timeout(self) -> (Self, Option<FxIndexMap<String, WatcherChangeKind>>) {
5864
match self {
59-
WatcherState::Debouncing { changes, .. } => (WatcherState::Idle, Some(changes)),
65+
WatcherState::Debouncing { changes, .. } => {
66+
if changes.is_empty() {
67+
(WatcherState::Idle, None)
68+
} else {
69+
(WatcherState::Idle, Some(changes))
70+
}
71+
}
6072
other => (other, None),
6173
}
6274
}
@@ -107,32 +119,61 @@ impl WatcherState {
107119
}
108120
}
109121

122+
/// Merge a new change kind into the accumulated changes for a path.
123+
///
124+
/// See "Kind Consolidation" in `meta/design/watch-mode.md` for the full rule table.
125+
fn merge_change_kind(
126+
changes: &mut FxIndexMap<String, WatcherChangeKind>,
127+
path: String,
128+
new_kind: WatcherChangeKind,
129+
) {
130+
if let Some(old_kind) = changes.get(&path).copied() {
131+
match (old_kind, new_kind) {
132+
// File was created then modified — still a creation
133+
(WatcherChangeKind::Create, WatcherChangeKind::Update) => {}
134+
// File was created then deleted — cancel out entirely
135+
(WatcherChangeKind::Create, WatcherChangeKind::Delete) => {
136+
changes.swap_remove(&path);
137+
}
138+
// File was deleted then recreated — net effect is an update
139+
(WatcherChangeKind::Delete, WatcherChangeKind::Create) => {
140+
changes.insert(path, WatcherChangeKind::Update);
141+
}
142+
// All other cases: new kind wins
143+
_ => {
144+
changes.insert(path, new_kind);
145+
}
146+
}
147+
} else {
148+
changes.insert(path, new_kind);
149+
}
150+
}
151+
110152
#[cfg(test)]
111153
mod tests {
112154
use super::*;
113-
use rolldown_common::WatcherChangeKind;
114155

115156
fn default_duration() -> Duration {
116157
Duration::from_millis(100)
117158
}
118159

119160
#[test]
120-
fn test_idle_to_debouncing_on_file_change() {
161+
fn test_idle_to_debouncing_on_file_changes() {
121162
let state = WatcherState::Idle;
122-
let entry = FileChangeEvent::new("test.js".into(), WatcherChangeKind::Update);
123-
let new_state = state.on_file_change(entry, default_duration());
163+
let entries = vec![FileChangeEvent::new("test.js".into(), WatcherChangeKind::Update)];
164+
let new_state = state.on_file_changes(entries, default_duration());
124165

125166
assert!(new_state.is_debouncing());
126167
}
127168

128169
#[test]
129170
fn test_debouncing_accumulates_changes() {
130171
let state = WatcherState::Idle;
131-
let entry1 = FileChangeEvent::new("test1.js".into(), WatcherChangeKind::Update);
132-
let entry2 = FileChangeEvent::new("test2.js".into(), WatcherChangeKind::Create);
172+
let batch1 = vec![FileChangeEvent::new("test1.js".into(), WatcherChangeKind::Update)];
173+
let batch2 = vec![FileChangeEvent::new("test2.js".into(), WatcherChangeKind::Create)];
133174

134-
let state = state.on_file_change(entry1, default_duration());
135-
let state = state.on_file_change(entry2, default_duration());
175+
let state = state.on_file_changes(batch1, default_duration());
176+
let state = state.on_file_changes(batch2, default_duration());
136177

137178
if let WatcherState::Debouncing { changes, .. } = state {
138179
assert_eq!(changes.len(), 2);
@@ -143,10 +184,9 @@ mod tests {
143184

144185
#[test]
145186
fn test_debounce_timeout_to_idle() {
146-
let state = WatcherState::Debouncing {
147-
changes: vec![FileChangeEvent::new("test.js".into(), WatcherChangeKind::Update)],
148-
deadline: Instant::now(),
149-
};
187+
let mut changes = FxIndexMap::default();
188+
changes.insert("test.js".to_string(), WatcherChangeKind::Update);
189+
let state = WatcherState::Debouncing { changes, deadline: Instant::now() };
150190

151191
let (new_state, changes) = state.on_debounce_timeout();
152192

@@ -156,22 +196,117 @@ mod tests {
156196
}
157197

158198
#[test]
159-
fn test_debouncing_deduplicates_same_path() {
199+
fn test_create_then_update_consolidates_to_create() {
200+
let state = WatcherState::Idle;
201+
let batch1 = vec![FileChangeEvent::new("test.js".into(), WatcherChangeKind::Create)];
202+
let batch2 = vec![FileChangeEvent::new("test.js".into(), WatcherChangeKind::Update)];
203+
204+
let state = state.on_file_changes(batch1, default_duration());
205+
let state = state.on_file_changes(batch2, default_duration());
206+
207+
if let WatcherState::Debouncing { changes, .. } = state {
208+
assert_eq!(changes.len(), 1);
209+
assert_eq!(changes["test.js"], WatcherChangeKind::Create);
210+
} else {
211+
panic!("Expected Debouncing state");
212+
}
213+
}
214+
215+
#[test]
216+
fn test_create_then_delete_cancels_out() {
217+
let state = WatcherState::Idle;
218+
let batch1 = vec![FileChangeEvent::new("test.js".into(), WatcherChangeKind::Create)];
219+
let batch2 = vec![FileChangeEvent::new("test.js".into(), WatcherChangeKind::Delete)];
220+
221+
let state = state.on_file_changes(batch1, default_duration());
222+
let state = state.on_file_changes(batch2, default_duration());
223+
224+
if let WatcherState::Debouncing { changes, .. } = state {
225+
assert_eq!(changes.len(), 0);
226+
} else {
227+
panic!("Expected Debouncing state");
228+
}
229+
}
230+
231+
#[test]
232+
fn test_delete_then_create_consolidates_to_update() {
160233
let state = WatcherState::Idle;
161-
let entry1 = FileChangeEvent::new("test.js".into(), WatcherChangeKind::Create);
162-
let entry2 = FileChangeEvent::new("test.js".into(), WatcherChangeKind::Update);
234+
let batch1 = vec![FileChangeEvent::new("test.js".into(), WatcherChangeKind::Delete)];
235+
let batch2 = vec![FileChangeEvent::new("test.js".into(), WatcherChangeKind::Create)];
236+
237+
let state = state.on_file_changes(batch1, default_duration());
238+
let state = state.on_file_changes(batch2, default_duration());
239+
240+
if let WatcherState::Debouncing { changes, .. } = state {
241+
assert_eq!(changes.len(), 1);
242+
assert_eq!(changes["test.js"], WatcherChangeKind::Update);
243+
} else {
244+
panic!("Expected Debouncing state");
245+
}
246+
}
163247

164-
let state = state.on_file_change(entry1, default_duration());
165-
let state = state.on_file_change(entry2, default_duration());
248+
#[test]
249+
fn test_batch_create_then_update_within_single_call() {
250+
let state = WatcherState::Idle;
251+
let entries = vec![
252+
FileChangeEvent::new("test.js".into(), WatcherChangeKind::Create),
253+
FileChangeEvent::new("test.js".into(), WatcherChangeKind::Update),
254+
];
255+
let state = state.on_file_changes(entries, default_duration());
166256

167257
if let WatcherState::Debouncing { changes, .. } = state {
168258
assert_eq!(changes.len(), 1);
169-
assert_eq!(changes[0].kind, WatcherChangeKind::Update);
259+
assert_eq!(changes["test.js"], WatcherChangeKind::Create);
170260
} else {
171261
panic!("Expected Debouncing state");
172262
}
173263
}
174264

265+
#[test]
266+
fn test_empty_entries_keeps_idle() {
267+
let state = WatcherState::Idle;
268+
let new_state = state.on_file_changes(vec![], default_duration());
269+
assert!(new_state.is_idle());
270+
}
271+
272+
#[test]
273+
fn test_empty_entries_keeps_debouncing_without_deadline_reset() {
274+
let state = WatcherState::Idle;
275+
let entries = vec![FileChangeEvent::new("test.js".into(), WatcherChangeKind::Update)];
276+
let state = state.on_file_changes(entries, default_duration());
277+
let deadline_before = match &state {
278+
WatcherState::Debouncing { deadline, .. } => *deadline,
279+
_ => panic!("Expected Debouncing state"),
280+
};
281+
282+
let state = state.on_file_changes(vec![], default_duration());
283+
284+
match &state {
285+
WatcherState::Debouncing { deadline, changes, .. } => {
286+
assert_eq!(changes.len(), 1);
287+
assert_eq!(*deadline, deadline_before);
288+
}
289+
_ => panic!("Expected Debouncing state"),
290+
}
291+
}
292+
293+
#[test]
294+
fn test_debounce_timeout_with_empty_changes_after_consolidation() {
295+
let state = WatcherState::Idle;
296+
let batch1 = vec![FileChangeEvent::new("a.js".into(), WatcherChangeKind::Create)];
297+
let batch2 = vec![FileChangeEvent::new("a.js".into(), WatcherChangeKind::Delete)];
298+
299+
let state = state.on_file_changes(batch1, default_duration());
300+
let state = state.on_file_changes(batch2, default_duration());
301+
302+
// Changes cancelled out — map is empty
303+
assert!(state.is_debouncing());
304+
305+
let (new_state, changes) = state.on_debounce_timeout();
306+
assert!(new_state.is_idle());
307+
assert!(changes.is_none());
308+
}
309+
175310
#[test]
176311
fn test_close_from_idle() {
177312
let state = WatcherState::Idle;
@@ -193,8 +328,8 @@ mod tests {
193328
#[test]
194329
fn test_closing_ignores_file_changes() {
195330
let state = WatcherState::Closing;
196-
let entry = FileChangeEvent::new("test.js".into(), WatcherChangeKind::Update);
197-
let new_state = state.on_file_change(entry, default_duration());
331+
let entries = vec![FileChangeEvent::new("test.js".into(), WatcherChangeKind::Update)];
332+
let new_state = state.on_file_changes(entries, default_duration());
198333

199334
assert!(matches!(new_state, WatcherState::Closing));
200335
}

0 commit comments

Comments
 (0)