Skip to content

Commit 2ceba2f

Browse files
committed
feat(watch): use new watcher to support watch mode (#8475)
## Summary Wire up `rolldown_watcher` to fully replace the legacy watcher (`crates/rolldown/src/watch/`). - Deprecate `watch.notify` option and add `watch.watcher` with `usePolling` / `pollInterval` (flat options instead of nested notify struct) - Replace `setInterval` keep-alive hack with pending `waitForClose()` Promise - Move event dispatching from channel-based (`rx.recv()` loop) to trait-based (`WatcherEventHandler`) - Use `with_cached_bundle_experimental` for split-phase builds — register FS watches between scan and write so render-hook file changes are detected (see `meta/design/watch-mode.md` "Split-Phase Build") - Add kind consolidation during debounce (Create+Update→Create, Create+Delete→removed, Delete+Create→Update) — see `meta/design/watch-mode.md` "Kind Consolidation" - Simplify `WatcherEmitter` to a thin `emit()` dispatcher, move event decoding to `Watcher.createEventCallback()` - Add `BindingWatcherState` (Pending→Running→Closed) in NAPI binding layer --- I will fix reviews in separate PRs, this PR is too cumbersome to make changes ## Test plan - [x] `cargo test -p rolldown_watcher` — 17 unit tests pass - [x] `pnpm --filter rolldown-tests test:watcher` — 25 integration tests pass - [x] `just roll` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 6e2d82f commit 2ceba2f

File tree

29 files changed

+646
-388
lines changed

29 files changed

+646
-388
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ rolldown_testing = { version = "0.1.0", path = "crates/rolldown_testing" }
139139
rolldown_testing_config = { version = "0.1.0", path = "crates/rolldown_testing_config" }
140140
rolldown_tracing = { version = "0.1.0", path = "crates/rolldown_tracing" }
141141
rolldown_utils = { version = "0.1.0", path = "crates/rolldown_utils" }
142+
rolldown_watcher = { version = "0.1.0", path = "crates/rolldown_watcher" }
142143
rolldown_workspace = { version = "0.1.0", path = "crates/rolldown_workspace" }
143144
string_wizard = { version = "0.0.27", path = "crates/string_wizard", features = ["serde"] }
144145

crates/rolldown/examples/watch.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,6 @@ async fn main() {
2020
},
2121
vec![],
2222
);
23-
let watcher = Watcher::new(config, None).unwrap();
23+
let watcher = Watcher::new(config).unwrap();
2424
watcher.start().await;
2525
}

crates/rolldown/src/bundle/bundle.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ impl Bundle {
8888
}
8989

9090
#[tracing::instrument(level = "debug", skip_all, parent = &*self.bundle_span)]
91-
pub(crate) async fn scan_modules(
91+
pub async fn scan_modules(
9292
&mut self,
9393
scan_mode: ScanMode<ArcStr>,
9494
) -> BuildResult<NormalizedScanStageOutput> {
@@ -201,7 +201,7 @@ impl Bundle {
201201
}
202202

203203
#[tracing::instrument(level = "debug", skip_all, parent = &*self.bundle_span)]
204-
pub(crate) async fn bundle_generate(
204+
pub async fn bundle_generate(
205205
&mut self,
206206
scan_stage_output: NormalizedScanStageOutput,
207207
) -> BuildResult<BundleOutput> {

crates/rolldown/src/bundler/bundler.rs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,10 @@ impl Bundler {
5959

6060
// Rollup always creates a new build in watch mode, which could be called multiple times.
6161
// Here only reset the closed flag to make it possible to call again.
62-
pub(crate) fn reset_closed_for_watch_mode(&mut self) {
62+
pub fn reset_closed_for_watch_mode(&mut self) {
6363
self.closed = false;
6464
}
6565

66-
#[cfg(feature = "experimental")]
67-
pub fn reset_closed_for_watch_mode_experimental(&mut self) {
68-
self.reset_closed_for_watch_mode();
69-
}
70-
7166
pub(super) async fn inner_close(&mut self) -> Result<()> {
7267
if self.closed {
7368
return Ok(());

crates/rolldown/src/watch/watcher.rs

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use notify::{
99
event::{ModifyKind, RenameMode},
1010
};
1111

12-
use rolldown_common::{NotifyOption, WatcherChangeKind};
12+
use rolldown_common::WatcherChangeKind;
1313
use rolldown_error::BuildResult;
1414
use rolldown_utils::dashmap::FxDashSet;
1515
use std::{
@@ -56,25 +56,12 @@ pub struct WatcherImpl {
5656
}
5757

5858
impl WatcherImpl {
59-
#[expect(clippy::needless_pass_by_value)]
60-
pub fn new(
61-
bundlers: Vec<Arc<Mutex<Bundler>>>,
62-
notify_option: Option<NotifyOption>,
63-
) -> Result<Self> {
59+
pub fn new(bundlers: Vec<Arc<Mutex<Bundler>>>) -> Result<Self> {
6460
let (tx, rx) = channel();
6561
let (exec_tx, exec_rx) = channel();
6662
let tx = Arc::new(tx);
6763
let cloned_tx = Arc::clone(&tx);
68-
let watch_option = {
69-
let mut config = Config::default();
70-
if let Some(notify) = &notify_option {
71-
if let Some(poll_interval) = notify.poll_interval {
72-
config = config.with_poll_interval(poll_interval);
73-
}
74-
config = config.with_compare_contents(notify.compare_contents);
75-
}
76-
config
77-
};
64+
let watch_option = Config::default();
7865
let notify_watcher = Arc::new(Mutex::new(RecommendedWatcher::new(
7966
move |res| {
8067
if let Err(e) = tx.send(WatcherChannelMsg::NotifyEvent(res)) {

crates/rolldown/src/watcher.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
use std::sync::Arc;
22

3-
use rolldown_common::NotifyOption;
43
use rolldown_error::{BuildDiagnostic, BuildResult};
54
use tokio::sync::Mutex;
65

@@ -13,14 +12,11 @@ use crate::{
1312
pub struct Watcher(Arc<WatcherImpl>);
1413

1514
impl Watcher {
16-
pub fn new(config: BundlerConfig, notify_option: Option<NotifyOption>) -> BuildResult<Self> {
17-
Self::with_configs(vec![config], notify_option)
15+
pub fn new(config: BundlerConfig) -> BuildResult<Self> {
16+
Self::with_configs(vec![config])
1817
}
1918

20-
pub fn with_configs(
21-
configs: Vec<BundlerConfig>,
22-
notify_option: Option<NotifyOption>,
23-
) -> BuildResult<Self> {
19+
pub fn with_configs(configs: Vec<BundlerConfig>) -> BuildResult<Self> {
2420
let mut bundlers = Vec::with_capacity(configs.len());
2521

2622
for config in configs {
@@ -46,7 +42,7 @@ impl Watcher {
4642
bundlers.push(Arc::new(Mutex::new(bundler)));
4743
}
4844

49-
let watcher = Arc::new(WatcherImpl::new(bundlers, notify_option)?);
45+
let watcher = Arc::new(WatcherImpl::new(bundlers)?);
5046
Ok(Self(watcher))
5147
}
5248

crates/rolldown_binding/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ rolldown_plugin_vite_web_worker_post = { workspace = true }
6363
rolldown_sourcemap = { workspace = true }
6464
rolldown_tracing = { workspace = true }
6565
rolldown_utils = { workspace = true }
66+
rolldown_watcher = { workspace = true }
6667
serde = { workspace = true }
6768
serde_json = { workspace = true }
6869
rustc-hash = { workspace = true }

crates/rolldown_binding/src/options/binding_input_options/binding_watch_option.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ pub struct BindingWatchOption {
1414
pub include: Option<Vec<BindingStringOrRegex>>,
1515
pub exclude: Option<Vec<BindingStringOrRegex>>,
1616
pub build_delay: Option<u32>,
17+
pub use_polling: Option<bool>,
18+
pub poll_interval: Option<u32>,
1719
#[napi(ts_type = "((id: string) => void) | undefined")]
1820
#[debug(skip)]
1921
pub on_invalidate: Option<JsCallback<FnArgs<(String,)>>>,
@@ -26,6 +28,8 @@ impl From<BindingWatchOption> for rolldown_common::WatchOption {
2628
include: value.include.map(bindingify_string_or_regex_array),
2729
exclude: value.exclude.map(bindingify_string_or_regex_array),
2830
build_delay: value.build_delay,
31+
use_polling: value.use_polling.unwrap_or_default(),
32+
poll_interval: value.poll_interval.map(u64::from),
2933
on_invalidate: value.on_invalidate.map(|js_callback| {
3034
OnInvalidate::new(Arc::new(move |path| {
3135
let f = Arc::clone(&js_callback);

crates/rolldown_binding/src/types/binding_watcher_event.rs

Lines changed: 58 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,78 +2,101 @@ use std::sync::Arc;
22

33
use napi::tokio::sync::Mutex;
44
use napi_derive::napi;
5+
use rolldown::Bundler;
6+
use rolldown_watcher::WatchEvent;
57

6-
use super::{binding_outputs::to_binding_error, error::BindingError};
8+
use super::binding_outputs::to_binding_error;
9+
use super::error::BindingError;
710
use crate::binding_watcher_bundler::BindingWatcherBundler;
8-
use rolldown::{BundleEvent, Bundler, WatcherEvent};
11+
12+
enum WatcherEventInner {
13+
/// Bundle event (on_event): START, BUNDLE_START, BUNDLE_END, END, ERROR
14+
BundleEvent(WatchEvent),
15+
/// File change event (on_change)
16+
Change { path: String, kind: String },
17+
/// Restart event (on_restart)
18+
Restart,
19+
/// Close event (on_close)
20+
Close,
21+
}
922

1023
#[napi]
1124
pub struct BindingWatcherEvent {
12-
inner: WatcherEvent,
25+
inner: WatcherEventInner,
1326
}
1427

15-
#[napi]
1628
impl BindingWatcherEvent {
17-
pub fn new(inner: WatcherEvent) -> Self {
18-
Self { inner }
29+
pub fn from_watch_event(event: WatchEvent) -> Self {
30+
Self { inner: WatcherEventInner::BundleEvent(event) }
31+
}
32+
33+
pub fn from_change(path: String, kind: String) -> Self {
34+
Self { inner: WatcherEventInner::Change { path, kind } }
35+
}
36+
37+
pub fn from_restart() -> Self {
38+
Self { inner: WatcherEventInner::Restart }
39+
}
40+
41+
pub fn from_close() -> Self {
42+
Self { inner: WatcherEventInner::Close }
1943
}
44+
}
2045

46+
#[napi]
47+
impl BindingWatcherEvent {
2148
#[napi]
2249
pub fn event_kind(&self) -> &str {
23-
self.inner.as_str()
50+
match &self.inner {
51+
WatcherEventInner::BundleEvent(_) => "event",
52+
WatcherEventInner::Change { .. } => "change",
53+
WatcherEventInner::Restart => "restart",
54+
WatcherEventInner::Close => "close",
55+
}
2456
}
2557

2658
#[napi]
27-
pub fn watch_change_data(&self) -> BindingWatcherChangeData {
59+
pub fn bundle_event_kind(&self) -> &str {
2860
match &self.inner {
29-
WatcherEvent::Change(data) => {
30-
BindingWatcherChangeData { path: data.path.to_string(), kind: data.kind.to_string() }
31-
}
32-
_ => {
33-
unreachable!("Expected WatcherEvent::Change")
34-
}
61+
WatcherEventInner::BundleEvent(event) => event.as_str(),
62+
_ => unreachable!("Expected BundleEvent"),
3563
}
3664
}
3765

3866
#[napi]
3967
pub fn bundle_end_data(&self) -> BindingBundleEndEventData {
4068
match &self.inner {
41-
WatcherEvent::Event(BundleEvent::BundleEnd(data)) => BindingBundleEndEventData {
69+
WatcherEventInner::BundleEvent(WatchEvent::BundleEnd(data)) => BindingBundleEndEventData {
4270
output: data.output.clone(),
4371
duration: data.duration,
44-
result: Arc::clone(&data.result),
72+
result: Arc::clone(&data.bundler),
4573
},
46-
_ => {
47-
unreachable!("Expected WatcherEvent::Event(BundleEventKind::BundleEnd)")
48-
}
49-
}
50-
}
51-
52-
#[napi]
53-
pub fn bundle_event_kind(&self) -> &str {
54-
match &self.inner {
55-
WatcherEvent::Event(kind) => kind.as_str(),
56-
_ => {
57-
unreachable!("Expected WatcherEvent::Event")
58-
}
74+
_ => unreachable!("Expected BundleEvent::BundleEnd"),
5975
}
6076
}
6177

6278
#[napi]
6379
pub fn bundle_error_data(&self) -> BindingBundleErrorEventData {
6480
match &self.inner {
65-
WatcherEvent::Event(BundleEvent::Error(data)) => BindingBundleErrorEventData {
81+
WatcherEventInner::BundleEvent(WatchEvent::Error(data)) => BindingBundleErrorEventData {
6682
error: data
67-
.error
6883
.diagnostics
6984
.iter()
70-
.map(|diagnostic| to_binding_error(diagnostic, data.error.cwd.clone()))
85+
.map(|diagnostic| to_binding_error(diagnostic, data.cwd.clone()))
7186
.collect(),
72-
result: Arc::clone(&data.result),
87+
result: Arc::clone(&data.bundler),
7388
},
74-
_ => {
75-
unreachable!("Expected WatcherEvent::Event(BundleEventKind::Error)")
89+
_ => unreachable!("Expected BundleEvent::Error"),
90+
}
91+
}
92+
93+
#[napi]
94+
pub fn watch_change_data(&self) -> BindingWatcherChangeData {
95+
match &self.inner {
96+
WatcherEventInner::Change { path, kind } => {
97+
BindingWatcherChangeData { path: path.clone(), kind: kind.clone() }
7698
}
99+
_ => unreachable!("Expected Change event"),
77100
}
78101
}
79102
}

0 commit comments

Comments
 (0)