From 6f0b96639161132e81b16c3915668ee79dc17b9c Mon Sep 17 00:00:00 2001 From: "Matt \"Siyuan\" Yan" Date: Fri, 19 Dec 2025 13:44:35 +0900 Subject: [PATCH] Fix stale state in callbacks when multiple events fire rapidly --- examples/async_clock/Trunk.toml | 2 +- examples/boids/Trunk.toml | 2 +- .../communication_child_to_parent/Trunk.toml | 2 +- .../Trunk.toml | 2 +- .../Trunk.toml | 2 +- .../communication_parent_to_child/Trunk.toml | 2 +- examples/contexts/Trunk.toml | 2 +- examples/counter/Trunk.toml | 2 +- examples/counter_functional/Trunk.toml | 2 +- examples/dyn_create_destroy_apps/Trunk.toml | 2 +- examples/file_upload/Trunk.toml | 2 +- examples/function_delayed_input/Trunk.toml | 2 + examples/function_memory_game/Trunk.toml | 2 +- examples/function_router/Trunk.toml | 2 +- examples/function_todomvc/Trunk.toml | 2 +- examples/futures/Trunk.toml | 2 +- examples/game_of_life/Trunk.toml | 2 +- examples/immutable/Trunk.toml | 2 +- examples/inner_html/Trunk.toml | 2 +- examples/js_callback/Trunk.toml | 2 +- examples/keyed_list/Trunk.toml | 2 +- examples/mount_point/Trunk.toml | 2 +- examples/nested_list/Trunk.toml | 2 +- examples/node_refs/Trunk.toml | 2 +- examples/password_strength/Trunk.toml | 2 +- examples/portals/Trunk.toml | 2 +- examples/router/Trunk.toml | 2 +- examples/suspense/Trunk.toml | 2 +- examples/timer/Trunk.toml | 2 +- examples/timer_functional/Trunk.toml | 2 +- examples/todomvc/Trunk.toml | 2 +- examples/two_apps/Trunk.toml | 2 +- examples/web_worker_fib/Trunk.toml | 2 +- examples/web_worker_prime/Trunk.toml | 2 +- examples/webgl/Trunk.toml | 2 +- .../yew/src/functional/hooks/use_reducer.rs | 36 ++++-- .../yew/src/functional/hooks/use_state.rs | 4 +- packages/yew/tests/use_state.rs | 110 ++++++++++++++++++ .../build-examples/src/bin/update-wasm-opt.rs | 6 + website/package-lock.json | 6 +- 40 files changed, 186 insertions(+), 46 deletions(-) create mode 100644 examples/function_delayed_input/Trunk.toml diff --git a/examples/async_clock/Trunk.toml b/examples/async_clock/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/async_clock/Trunk.toml +++ b/examples/async_clock/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/boids/Trunk.toml b/examples/boids/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/boids/Trunk.toml +++ b/examples/boids/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/communication_child_to_parent/Trunk.toml b/examples/communication_child_to_parent/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/communication_child_to_parent/Trunk.toml +++ b/examples/communication_child_to_parent/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/communication_grandchild_with_grandparent/Trunk.toml b/examples/communication_grandchild_with_grandparent/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/communication_grandchild_with_grandparent/Trunk.toml +++ b/examples/communication_grandchild_with_grandparent/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/communication_grandparent_to_grandchild/Trunk.toml b/examples/communication_grandparent_to_grandchild/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/communication_grandparent_to_grandchild/Trunk.toml +++ b/examples/communication_grandparent_to_grandchild/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/communication_parent_to_child/Trunk.toml b/examples/communication_parent_to_child/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/communication_parent_to_child/Trunk.toml +++ b/examples/communication_parent_to_child/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/contexts/Trunk.toml b/examples/contexts/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/contexts/Trunk.toml +++ b/examples/contexts/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/counter/Trunk.toml b/examples/counter/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/counter/Trunk.toml +++ b/examples/counter/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/counter_functional/Trunk.toml b/examples/counter_functional/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/counter_functional/Trunk.toml +++ b/examples/counter_functional/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/dyn_create_destroy_apps/Trunk.toml b/examples/dyn_create_destroy_apps/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/dyn_create_destroy_apps/Trunk.toml +++ b/examples/dyn_create_destroy_apps/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/file_upload/Trunk.toml b/examples/file_upload/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/file_upload/Trunk.toml +++ b/examples/file_upload/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/function_delayed_input/Trunk.toml b/examples/function_delayed_input/Trunk.toml new file mode 100644 index 00000000000..2dfde43c7a3 --- /dev/null +++ b/examples/function_delayed_input/Trunk.toml @@ -0,0 +1,2 @@ +[tools] +wasm_opt = "version_125" diff --git a/examples/function_memory_game/Trunk.toml b/examples/function_memory_game/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/function_memory_game/Trunk.toml +++ b/examples/function_memory_game/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/function_router/Trunk.toml b/examples/function_router/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/function_router/Trunk.toml +++ b/examples/function_router/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/function_todomvc/Trunk.toml b/examples/function_todomvc/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/function_todomvc/Trunk.toml +++ b/examples/function_todomvc/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/futures/Trunk.toml b/examples/futures/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/futures/Trunk.toml +++ b/examples/futures/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/game_of_life/Trunk.toml b/examples/game_of_life/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/game_of_life/Trunk.toml +++ b/examples/game_of_life/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/immutable/Trunk.toml b/examples/immutable/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/immutable/Trunk.toml +++ b/examples/immutable/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/inner_html/Trunk.toml b/examples/inner_html/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/inner_html/Trunk.toml +++ b/examples/inner_html/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/js_callback/Trunk.toml b/examples/js_callback/Trunk.toml index ea4fd1f29cf..05324058a3e 100644 --- a/examples/js_callback/Trunk.toml +++ b/examples/js_callback/Trunk.toml @@ -1,5 +1,5 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" [[hooks]] stage = "pre_build" diff --git a/examples/keyed_list/Trunk.toml b/examples/keyed_list/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/keyed_list/Trunk.toml +++ b/examples/keyed_list/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/mount_point/Trunk.toml b/examples/mount_point/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/mount_point/Trunk.toml +++ b/examples/mount_point/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/nested_list/Trunk.toml b/examples/nested_list/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/nested_list/Trunk.toml +++ b/examples/nested_list/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/node_refs/Trunk.toml b/examples/node_refs/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/node_refs/Trunk.toml +++ b/examples/node_refs/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/password_strength/Trunk.toml b/examples/password_strength/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/password_strength/Trunk.toml +++ b/examples/password_strength/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/portals/Trunk.toml b/examples/portals/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/portals/Trunk.toml +++ b/examples/portals/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/router/Trunk.toml b/examples/router/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/router/Trunk.toml +++ b/examples/router/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/suspense/Trunk.toml b/examples/suspense/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/suspense/Trunk.toml +++ b/examples/suspense/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/timer/Trunk.toml b/examples/timer/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/timer/Trunk.toml +++ b/examples/timer/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/timer_functional/Trunk.toml b/examples/timer_functional/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/timer_functional/Trunk.toml +++ b/examples/timer_functional/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/todomvc/Trunk.toml b/examples/todomvc/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/todomvc/Trunk.toml +++ b/examples/todomvc/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/two_apps/Trunk.toml b/examples/two_apps/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/two_apps/Trunk.toml +++ b/examples/two_apps/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/web_worker_fib/Trunk.toml b/examples/web_worker_fib/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/web_worker_fib/Trunk.toml +++ b/examples/web_worker_fib/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/web_worker_prime/Trunk.toml b/examples/web_worker_prime/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/web_worker_prime/Trunk.toml +++ b/examples/web_worker_prime/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/examples/webgl/Trunk.toml b/examples/webgl/Trunk.toml index e2cf24f27a9..2dfde43c7a3 100644 --- a/examples/webgl/Trunk.toml +++ b/examples/webgl/Trunk.toml @@ -1,2 +1,2 @@ [tools] -wasm_opt = "version_122" +wasm_opt = "version_125" diff --git a/packages/yew/src/functional/hooks/use_reducer.rs b/packages/yew/src/functional/hooks/use_reducer.rs index db81c38179d..a3003ee456e 100644 --- a/packages/yew/src/functional/hooks/use_reducer.rs +++ b/packages/yew/src/functional/hooks/use_reducer.rs @@ -35,7 +35,8 @@ pub struct UseReducerHandle where T: Reducible, { - value: Rc, + current_state: Rc>>, + snapshot: Rc, dispatch: DispatchFn, } @@ -63,7 +64,17 @@ where type Target = T; fn deref(&self) -> &Self::Target { - &self.value + // Try to get the latest value from the shared RefCell. + // If it's currently borrowed (e.g., during dispatch/reduce), fall back to snapshot. + if let Ok(rc_ref) = self.current_state.try_borrow() { + unsafe { + let ptr: *const T = Rc::as_ptr(&*rc_ref); + &*ptr + } + } else { + // RefCell is mutably borrowed (during dispatch), use snapshot + &self.snapshot + } } } @@ -73,7 +84,8 @@ where { fn clone(&self) -> Self { Self { - value: Rc::clone(&self.value), + current_state: Rc::clone(&self.current_state), + snapshot: Rc::clone(&self.snapshot), dispatch: Rc::clone(&self.dispatch), } } @@ -84,8 +96,13 @@ where T: Reducible + fmt::Debug, { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let value = if let Ok(rc_ref) = self.current_state.try_borrow() { + format!("{:?}", *rc_ref) + } else { + format!("{:?}", self.snapshot) + }; f.debug_struct("UseReducerHandle") - .field("value", &format!("{:?}", self.value)) + .field("value", &value) .finish() } } @@ -95,7 +112,7 @@ where T: Reducible + PartialEq, { fn eq(&self, rhs: &Self) -> bool { - self.value == rhs.value + **self == **rhs } } @@ -239,10 +256,15 @@ where } }); - let value = state.current_state.borrow().clone(); + let current_state = state.current_state.clone(); + let snapshot = state.current_state.borrow().clone(); let dispatch = state.dispatch.clone(); - UseReducerHandle { value, dispatch } + UseReducerHandle { + current_state, + snapshot, + dispatch, + } } } diff --git a/packages/yew/src/functional/hooks/use_state.rs b/packages/yew/src/functional/hooks/use_state.rs index 61b4c57d71a..66638296a08 100644 --- a/packages/yew/src/functional/hooks/use_state.rs +++ b/packages/yew/src/functional/hooks/use_state.rs @@ -109,7 +109,7 @@ pub struct UseStateHandle { impl fmt::Debug for UseStateHandle { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("UseStateHandle") - .field("value", &format!("{:?}", self.inner.value)) + .field("value", &format!("{:?}", **self)) .finish() } } @@ -132,7 +132,7 @@ impl Deref for UseStateHandle { type Target = T; fn deref(&self) -> &Self::Target { - &(*self.inner).value + &self.inner.value } } diff --git a/packages/yew/tests/use_state.rs b/packages/yew/tests/use_state.rs index 1a04c0ce27d..0da09a9c7b0 100644 --- a/packages/yew/tests/use_state.rs +++ b/packages/yew/tests/use_state.rs @@ -106,3 +106,113 @@ async fn use_state_eq_works() { assert_eq!(result.as_str(), "1"); assert_eq!(RENDER_COUNT.load(Ordering::Relaxed), 2); } + +/// Regression test for issue #3796 +/// Tests that state handles always read the latest value even when accessed +/// from callbacks before a rerender occurs. +/// +/// The bug occurred when: +/// 1. State A is updated via set() +/// 2. State B is updated via set() +/// 3. A callback reads both states before rerender +/// 4. The callback would see stale value for B because the handle was caching a snapshot instead of +/// reading from the RefCell +#[wasm_bindgen_test] +async fn use_state_handles_read_latest_value_issue_3796() { + use std::cell::RefCell; + + use gloo::utils::document; + use wasm_bindgen::JsCast; + use web_sys::HtmlElement; + + // Shared storage for the values read by the submit handler + thread_local! { + static CAPTURED_VALUES: RefCell> = const { RefCell::new(None) }; + } + + #[component(FormComponent)] + fn form_comp() -> Html { + let field_a = use_state(String::new); + let field_b = use_state(String::new); + + let update_a = { + let field_a = field_a.clone(); + Callback::from(move |_| { + field_a.set("value_a".to_string()); + }) + }; + + let update_b = { + let field_b = field_b.clone(); + Callback::from(move |_| { + field_b.set("value_b".to_string()); + }) + }; + + // This callback reads both states - the bug caused field_b to be stale + let submit = { + let field_a = field_a.clone(); + let field_b = field_b.clone(); + Callback::from(move |_| { + let a = (*field_a).clone(); + let b = (*field_b).clone(); + CAPTURED_VALUES.with(|v| { + *v.borrow_mut() = Some((a.clone(), b.clone())); + }); + }) + }; + + html! { +
+ + + +
{format!("a={}, b={}", *field_a, *field_b)}
+
+ } + } + + yew::Renderer::::with_root(document().get_element_by_id("output").unwrap()) + .render(); + sleep(Duration::ZERO).await; + + // Initial state + let result = obtain_result(); + assert_eq!(result.as_str(), "a=, b="); + + // Click update-a, then update-b, then submit WITHOUT waiting for rerender + // This simulates rapid user interaction (like the Firefox bug in issue #3796) + document() + .get_element_by_id("update-a") + .unwrap() + .unchecked_into::() + .click(); + + document() + .get_element_by_id("update-b") + .unwrap() + .unchecked_into::() + .click(); + + document() + .get_element_by_id("submit") + .unwrap() + .unchecked_into::() + .click(); + + // Now wait for rerenders to complete + sleep(Duration::ZERO).await; + + // Check the values captured by the submit handler + // Before the fix, field_b would be empty because the callback captured a stale handle + let captured = CAPTURED_VALUES.with(|v| v.borrow().clone()); + assert_eq!( + captured, + Some(("value_a".to_string(), "value_b".to_string())), + "Submit handler should see latest values for both fields" + ); + + // Also verify the DOM shows correct values after rerender + let result = obtain_result(); + assert_eq!(result.as_str(), "a=value_a, b=value_b"); +} diff --git a/tools/build-examples/src/bin/update-wasm-opt.rs b/tools/build-examples/src/bin/update-wasm-opt.rs index 3d3b5c2b67d..2a3f2c13e3c 100644 --- a/tools/build-examples/src/bin/update-wasm-opt.rs +++ b/tools/build-examples/src/bin/update-wasm-opt.rs @@ -31,6 +31,12 @@ fn main() -> ExitCode { continue; } + // Skip hidden directories (e.g., .cargo) + let file_name = entry.file_name(); + if file_name.to_string_lossy().starts_with('.') { + continue; + } + let example = path .file_name() .expect("Failed to get directory name") diff --git a/website/package-lock.json b/website/package-lock.json index 8c2ab8640d2..caab89a081b 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -13314,9 +13314,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.2.tgz", + "integrity": "sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==", "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0"