@@ -106,3 +106,229 @@ async fn use_state_eq_works() {
106106 assert_eq ! ( result. as_str( ) , "1" ) ;
107107 assert_eq ! ( RENDER_COUNT . load( Ordering :: Relaxed ) , 2 ) ;
108108}
109+
110+ /// Exercises the exact pattern that causes use-after-free in the original PR #3963
111+ /// fix, where `UseReducerHandle::deref()` drops the `Ref` guard but returns a
112+ /// pointer derived from it.
113+ ///
114+ /// The dangerous sequence within a single callback:
115+ /// 1. `handle.set(v1)` — dispatch puts a *new* `Rc` (refcount=1) in the shared `RefCell`,
116+ /// replacing the one from render time.
117+ /// 2. `let r: &T = &*handle` — `deref()` borrows the RefCell, grabs a raw pointer into the Rc
118+ /// (refcount still 1), and **drops the `Ref` guard**.
119+ /// 3. `handle.set(v2)` — dispatch replaces that Rc. Because its refcount was 1, it is freed. `r`
120+ /// is now dangling.
121+ /// 4. Allocate objects of similar size to encourage the allocator to reuse the freed memory,
122+ /// overwriting the old `T`.
123+ /// 5. Read through `r` — **use-after-free**.
124+ ///
125+ /// With the `deref_history` fix, step 2 clones the Rc into a `Vec` kept alive by
126+ /// the handle, bumping the refcount to 2. Step 3 only drops it to 1, so the
127+ /// allocation survives and `r` remains valid.
128+ #[ wasm_bindgen_test]
129+ async fn deref_remains_valid_across_multiple_dispatches_in_callback ( ) {
130+ use std:: cell:: RefCell ;
131+
132+ use gloo:: utils:: document;
133+ use wasm_bindgen:: JsCast ;
134+ use web_sys:: HtmlElement ;
135+
136+ thread_local ! {
137+ static DEREF_RESULT : RefCell <Option <String >> = const { RefCell :: new( None ) } ;
138+ }
139+
140+ #[ component( UBTestComponent ) ]
141+ fn ub_test_comp ( ) -> Html {
142+ let state = use_state ( || "initial" . to_string ( ) ) ;
143+
144+ let trigger = {
145+ let state = state. clone ( ) ;
146+ Callback :: from ( move |_| {
147+ // Step 1: dispatch. The RefCell now contains a *new* Rc whose only
148+ // owner is the RefCell itself (refcount = 1).
149+ state. set ( "first_dispatch" . to_string ( ) ) ;
150+
151+ // Step 2: deref. In the original fix the Ref guard is dropped
152+ // immediately, leaving us with a bare pointer into the refcount-1
153+ // Rc. With deref_history, the Rc is cloned into the Vec so the
154+ // refcount is bumped to 2.
155+ let borrowed: & String = & * state;
156+
157+ // Step 3: dispatch again. The RefCell's old Rc is replaced.
158+ // Original fix: refcount was 1 → drops to 0 → freed → `borrowed`
159+ // dangles.
160+ // deref_history fix: refcount was 2 → drops to 1 (still in Vec)
161+ // → allocation survives → `borrowed` is valid.
162+ state. set ( "second_dispatch" . to_string ( ) ) ;
163+
164+ // Step 4: churn the allocator. Create and drop many heap objects
165+ // of ~32 bytes (the size of the freed Rc+UseStateReducer+String
166+ // struct on wasm32) to maximize the chance that the allocator
167+ // hands out the freed address to one of these, overwriting the
168+ // memory `borrowed` points into.
169+ for _ in 0 ..256 {
170+ // Each Box<[u8; 32]> is roughly the same size as the freed Rc
171+ // allocation containing UseStateReducer<String>.
172+ let overwrite = Box :: new ( [ 0xFFu8 ; 32 ] ) ;
173+ std:: hint:: black_box ( & * overwrite) ;
174+ drop ( overwrite) ;
175+ }
176+
177+ // Also allocate Strings whose *buffers* might reuse the freed
178+ // String buffer from step 1.
179+ let _noise: Vec < String > = ( 0 ..64 ) . map ( |i| format ! ( "noise_{:032}" , i) ) . collect ( ) ;
180+
181+ // Step 5: read through the potentially-dangling reference.
182+ // With the original fix this is UB: the memory behind `borrowed`
183+ // may have been reused by the allocations above, so `.clone()`
184+ // could read a garbage ptr/len/cap triple and trap, or silently
185+ // return corrupted data.
186+ // With deref_history, this always reads "first_dispatch".
187+ let value = borrowed. clone ( ) ;
188+
189+ DEREF_RESULT . with ( |r| {
190+ * r. borrow_mut ( ) = Some ( value) ;
191+ } ) ;
192+ } )
193+ } ;
194+
195+ html ! {
196+ <div>
197+ <button id="ub-trigger" onclick={ trigger} >{ "Trigger" } </button>
198+ <div id="result" >{ ( * state) . clone( ) } </div>
199+ </div>
200+ }
201+ }
202+
203+ yew:: Renderer :: < UBTestComponent > :: with_root ( document ( ) . get_element_by_id ( "output" ) . unwrap ( ) )
204+ . render ( ) ;
205+ sleep ( Duration :: ZERO ) . await ;
206+
207+ // Fire the callback
208+ document ( )
209+ . get_element_by_id ( "ub-trigger" )
210+ . unwrap ( )
211+ . unchecked_into :: < HtmlElement > ( )
212+ . click ( ) ;
213+
214+ sleep ( Duration :: ZERO ) . await ;
215+
216+ // The reference obtained between the two dispatches must still read the
217+ // value from the first dispatch, not garbage or "second_dispatch".
218+ let captured = DEREF_RESULT . with ( |r| r. borrow ( ) . clone ( ) ) ;
219+ assert_eq ! (
220+ captured,
221+ Some ( "first_dispatch" . to_string( ) ) ,
222+ "deref() reference must remain valid across subsequent dispatches"
223+ ) ;
224+ }
225+
226+ /// Regression test for issue #3796
227+ /// Tests that state handles always read the latest value even when accessed
228+ /// from callbacks before a rerender occurs.
229+ ///
230+ /// The bug occurred when:
231+ /// 1. State A is updated via set()
232+ /// 2. State B is updated via set()
233+ /// 3. A callback reads both states before rerender
234+ /// 4. The callback would see stale value for B because the handle was caching a snapshot instead of
235+ /// reading from the shared RefCell
236+ #[ wasm_bindgen_test]
237+ async fn use_state_handles_read_latest_value_issue_3796 ( ) {
238+ use std:: cell:: RefCell ;
239+
240+ use gloo:: utils:: document;
241+ use wasm_bindgen:: JsCast ;
242+ use web_sys:: HtmlElement ;
243+
244+ // Shared storage for the values read by the submit handler
245+ thread_local ! {
246+ static CAPTURED_VALUES : RefCell <Option <( String , String ) >> = const { RefCell :: new( None ) } ;
247+ }
248+
249+ #[ component( FormComponent ) ]
250+ fn form_comp ( ) -> Html {
251+ let field_a = use_state ( String :: new) ;
252+ let field_b = use_state ( String :: new) ;
253+
254+ let update_a = {
255+ let field_a = field_a. clone ( ) ;
256+ Callback :: from ( move |_| {
257+ field_a. set ( "value_a" . to_string ( ) ) ;
258+ } )
259+ } ;
260+
261+ let update_b = {
262+ let field_b = field_b. clone ( ) ;
263+ Callback :: from ( move |_| {
264+ field_b. set ( "value_b" . to_string ( ) ) ;
265+ } )
266+ } ;
267+
268+ // This callback reads both states - the bug caused field_b to be stale
269+ let submit = {
270+ let field_a = field_a. clone ( ) ;
271+ let field_b = field_b. clone ( ) ;
272+ Callback :: from ( move |_| {
273+ let a = ( * field_a) . clone ( ) ;
274+ let b = ( * field_b) . clone ( ) ;
275+ CAPTURED_VALUES . with ( |v| {
276+ * v. borrow_mut ( ) = Some ( ( a. clone ( ) , b. clone ( ) ) ) ;
277+ } ) ;
278+ } )
279+ } ;
280+
281+ html ! {
282+ <div>
283+ <button id="update-a" onclick={ update_a} >{ "Update A" } </button>
284+ <button id="update-b" onclick={ update_b} >{ "Update B" } </button>
285+ <button id="submit" onclick={ submit} >{ "Submit" } </button>
286+ <div id="result" >{ format!( "a={}, b={}" , * field_a, * field_b) } </div>
287+ </div>
288+ }
289+ }
290+
291+ yew:: Renderer :: < FormComponent > :: with_root ( document ( ) . get_element_by_id ( "output" ) . unwrap ( ) )
292+ . render ( ) ;
293+ sleep ( Duration :: ZERO ) . await ;
294+
295+ // Initial state
296+ let result = obtain_result ( ) ;
297+ assert_eq ! ( result. as_str( ) , "a=, b=" ) ;
298+
299+ // Click update-a, then update-b, then submit WITHOUT waiting for rerender.
300+ // This simulates rapid user interaction (like the Firefox bug in issue #3796).
301+ document ( )
302+ . get_element_by_id ( "update-a" )
303+ . unwrap ( )
304+ . unchecked_into :: < HtmlElement > ( )
305+ . click ( ) ;
306+
307+ document ( )
308+ . get_element_by_id ( "update-b" )
309+ . unwrap ( )
310+ . unchecked_into :: < HtmlElement > ( )
311+ . click ( ) ;
312+
313+ document ( )
314+ . get_element_by_id ( "submit" )
315+ . unwrap ( )
316+ . unchecked_into :: < HtmlElement > ( )
317+ . click ( ) ;
318+
319+ // Now wait for rerenders to complete
320+ sleep ( Duration :: ZERO ) . await ;
321+
322+ // Check the values captured by the submit handler.
323+ // Before the fix, field_b would be empty because the callback captured a stale handle.
324+ let captured = CAPTURED_VALUES . with ( |v| v. borrow ( ) . clone ( ) ) ;
325+ assert_eq ! (
326+ captured,
327+ Some ( ( "value_a" . to_string( ) , "value_b" . to_string( ) ) ) ,
328+ "Submit handler should see latest values for both fields"
329+ ) ;
330+
331+ // Also verify the DOM shows correct values after rerender
332+ let result = obtain_result ( ) ;
333+ assert_eq ! ( result. as_str( ) , "a=value_a, b=value_b" ) ;
334+ }
0 commit comments