From 94f245d1a969e3690dc3e3c7d1bb395144c78208 Mon Sep 17 00:00:00 2001 From: Matt Yan Date: Mon, 2 Mar 2026 23:43:44 +0900 Subject: [PATCH 1/4] fix: yield every 50ms --- packages/yew/src/scheduler.rs | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/yew/src/scheduler.rs b/packages/yew/src/scheduler.rs index 4a42f33df65..08c174cb3f7 100644 --- a/packages/yew/src/scheduler.rs +++ b/packages/yew/src/scheduler.rs @@ -249,7 +249,10 @@ pub(crate) fn start_now() { mod arch { use std::sync::atomic::{AtomicBool, Ordering}; + use wasm_bindgen_futures::JsFuture; + use crate::platform::spawn_local; + // Really only used as a `Cell` that is also `Sync` static IS_SCHEDULED: AtomicBool = AtomicBool::new(false); fn check_scheduled() -> bool { @@ -267,16 +270,45 @@ mod arch { check_scheduled() } + const YIELD_DEADLINE_MS: f64 = 50.0; + + async fn yield_to_browser() { + let promise = js_sys::Promise::new(&mut |resolve, _| { + let _ = web_sys::window() + .expect("should be in a browser environment") + .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, 0); + }); + let _ = JsFuture::from(promise).await; + } + /// We delay the start of the scheduler to the end of the micro task queue. /// So any messages that needs to be queued can be queued. + /// Once running, we yield to the browser every ~50ms to avoid long tasks. pub(crate) fn start() { if check_scheduled() { return; } set_scheduled(true); spawn_local(async { + let mut queue = vec![]; + let mut deadline = js_sys::Date::now() + YIELD_DEADLINE_MS; + + loop { + super::with(|s| s.fill_queue(&mut queue)); + if queue.is_empty() { + break; + } + for r in queue.drain(..) { + r.task.run(); + } + let now = js_sys::Date::now(); + if now >= deadline { + yield_to_browser().await; + deadline = js_sys::Date::now() + YIELD_DEADLINE_MS; + } + } + set_scheduled(false); - super::start_now(); }); } } From 0157b6f0ef5907080cee17ac8b091dc896c615e0 Mon Sep 17 00:00:00 2001 From: Matt Yan Date: Tue, 3 Mar 2026 01:56:35 +0900 Subject: [PATCH 2/4] fix: use Closure::once_into_js instead of JsFuture to reduce wasm size Replaces the async/await yield mechanism (JsFuture + Promise) with a synchronous callback approach (Closure::once_into_js + setTimeout). This avoids pulling in the JsFuture state machine and web_sys::window() binding, cutting the wasm size overhead roughly in half (~0.9KB vs ~1.8KB). --- packages/yew/src/scheduler.rs | 55 ++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/packages/yew/src/scheduler.rs b/packages/yew/src/scheduler.rs index 08c174cb3f7..33792b579b1 100644 --- a/packages/yew/src/scheduler.rs +++ b/packages/yew/src/scheduler.rs @@ -249,7 +249,7 @@ pub(crate) fn start_now() { mod arch { use std::sync::atomic::{AtomicBool, Ordering}; - use wasm_bindgen_futures::JsFuture; + use wasm_bindgen::prelude::*; use crate::platform::spawn_local; @@ -272,13 +272,32 @@ mod arch { const YIELD_DEADLINE_MS: f64 = 50.0; - async fn yield_to_browser() { - let promise = js_sys::Promise::new(&mut |resolve, _| { - let _ = web_sys::window() - .expect("should be in a browser environment") - .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, 0); - }); - let _ = JsFuture::from(promise).await; + #[wasm_bindgen] + extern "C" { + #[wasm_bindgen(js_name = setTimeout)] + fn set_timeout(handler: &js_sys::Function, timeout: i32) -> i32; + } + + fn run_scheduler(mut queue: Vec) { + let mut deadline = js_sys::Date::now() + YIELD_DEADLINE_MS; + + loop { + super::with(|s| s.fill_queue(&mut queue)); + if queue.is_empty() { + break; + } + for r in queue.drain(..) { + r.task.run(); + } + let now = js_sys::Date::now(); + if now >= deadline { + let cb = Closure::once_into_js(move || run_scheduler(queue)); + set_timeout(cb.unchecked_ref(), 0); + return; + } + } + + set_scheduled(false); } /// We delay the start of the scheduler to the end of the micro task queue. @@ -290,25 +309,7 @@ mod arch { } set_scheduled(true); spawn_local(async { - let mut queue = vec![]; - let mut deadline = js_sys::Date::now() + YIELD_DEADLINE_MS; - - loop { - super::with(|s| s.fill_queue(&mut queue)); - if queue.is_empty() { - break; - } - for r in queue.drain(..) { - r.task.run(); - } - let now = js_sys::Date::now(); - if now >= deadline { - yield_to_browser().await; - deadline = js_sys::Date::now() + YIELD_DEADLINE_MS; - } - } - - set_scheduled(false); + run_scheduler(vec![]); }); } } From 62f78d754a627940f937be23eefc9053d9b9f6d6 Mon Sep 17 00:00:00 2001 From: "Matt \"Siyuan\" Yan" Date: Wed, 4 Mar 2026 11:31:10 +0900 Subject: [PATCH 3/4] fix: only yield when DOM is in a consistent state Yield to the browser only when no DOM-mutating work (destroy, create, render_first, render, render_priority) is pending, so event handlers that fire during the yield never see a partially-rendered tree. Also lower yield deadline from 50ms to 16ms (~60fps) and gate start_now() to non-wasm/test targets where it is actually used. --- packages/yew/src/scheduler.rs | 44 ++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/yew/src/scheduler.rs b/packages/yew/src/scheduler.rs index 33792b579b1..3c41cd50940 100644 --- a/packages/yew/src/scheduler.rs +++ b/packages/yew/src/scheduler.rs @@ -211,6 +211,12 @@ mod feat_hydration { pub(crate) use feat_hydration::*; /// Execute any pending [Runnable]s +#[cfg(any( + not(target_arch = "wasm32"), + target_os = "wasi", + feature = "not_browser_env", + test +))] pub(crate) fn start_now() { #[tracing::instrument(level = tracing::Level::DEBUG)] fn scheduler_loop() { @@ -270,7 +276,7 @@ mod arch { check_scheduled() } - const YIELD_DEADLINE_MS: f64 = 50.0; + const YIELD_DEADLINE_MS: f64 = 16.0; #[wasm_bindgen] extern "C" { @@ -279,7 +285,7 @@ mod arch { } fn run_scheduler(mut queue: Vec) { - let mut deadline = js_sys::Date::now() + YIELD_DEADLINE_MS; + let deadline = js_sys::Date::now() + YIELD_DEADLINE_MS; loop { super::with(|s| s.fill_queue(&mut queue)); @@ -289,20 +295,27 @@ mod arch { for r in queue.drain(..) { r.task.run(); } - let now = js_sys::Date::now(); - if now >= deadline { - let cb = Closure::once_into_js(move || run_scheduler(queue)); - set_timeout(cb.unchecked_ref(), 0); - return; + if js_sys::Date::now() >= deadline { + // Only yield when no DOM-mutating work is pending, so event + // handlers that fire during the yield see a consistent DOM. + let can_yield = super::with(|s| s.can_yield()); + if can_yield { + let cb = Closure::once_into_js(move || run_scheduler(queue)); + set_timeout(cb.unchecked_ref(), 0); + return; + } } } set_scheduled(false); + #[cfg(any(test, feature = "test"))] + super::flush_wakers::wake_all(); } /// We delay the start of the scheduler to the end of the micro task queue. /// So any messages that needs to be queued can be queued. - /// Once running, we yield to the browser every ~50ms to avoid long tasks. + /// Once running, we yield to the browser every ~16ms, but only at points + /// where the DOM is in a consistent state (no pending renders/destroys). pub(crate) fn start() { if check_scheduled() { return; @@ -378,6 +391,21 @@ pub async fn flush() { } impl Scheduler { + /// Returns true when no DOM-mutating work is pending, meaning it's safe to + /// yield to the browser without leaving the DOM in an inconsistent state. + #[cfg(all( + target_arch = "wasm32", + not(target_os = "wasi"), + not(feature = "not_browser_env") + ))] + fn can_yield(&self) -> bool { + self.destroy.inner.is_empty() + && self.create.inner.is_empty() + && self.render_first.inner.is_empty() + && self.render.inner.is_empty() + && self.render_priority.inner.is_empty() + } + /// Fill vector with tasks to be executed according to Runnable type execution priority /// /// This method is optimized for typical usage, where possible, but does not break on From 46ce0d8b268b31110e1393f33108df7d44250c3b Mon Sep 17 00:00:00 2001 From: Matt Yan Date: Sun, 8 Mar 2026 00:00:47 +0900 Subject: [PATCH 4/4] fix: remove incorrect cfg gate on start_now --- packages/yew/src/scheduler.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/yew/src/scheduler.rs b/packages/yew/src/scheduler.rs index 3c41cd50940..0a710b95419 100644 --- a/packages/yew/src/scheduler.rs +++ b/packages/yew/src/scheduler.rs @@ -211,12 +211,6 @@ mod feat_hydration { pub(crate) use feat_hydration::*; /// Execute any pending [Runnable]s -#[cfg(any( - not(target_arch = "wasm32"), - target_os = "wasi", - feature = "not_browser_env", - test -))] pub(crate) fn start_now() { #[tracing::instrument(level = tracing::Level::DEBUG)] fn scheduler_loop() {