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"); +}