Last active
March 18, 2026 11:52
-
-
Save rubennorte/b002d3673b4d83a7181311a96084f9f0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * @jest-environment jsdomc | |
| * | |
| * StaleEventHandlersFromInterruptedRender.test.jsx | |
| * | |
| * React DOM version demonstrating that this bug does NOT exist on the web. | |
| * | |
| * In React Native's Fabric renderer, canonical.currentProps (which stores | |
| * event handlers) is eagerly mutated during completeWork (render phase), | |
| * and is shared between the committed and WIP fiber. This means events | |
| * dispatched mid-render call stale, never-committed handlers. | |
| * | |
| * In React DOM, the DOM node's internal fiber reference still points to the | |
| * current (committed) fiber during render. When an event is dispatched | |
| * mid-render, React's event system traverses the committed fiber tree and | |
| * reads handlers from committed props — the correct behavior. | |
| */ | |
| import * as React from 'react'; | |
| import {createRef, startTransition, useDeferredValue, useState} from 'react'; | |
| import {createRoot} from 'react-dom/client'; | |
| import {act} from 'react'; | |
| describe('stale event handlers from interrupted render', () => { | |
| let container; | |
| beforeEach(() => { | |
| container = document.createElement('div'); | |
| document.body.appendChild(container); | |
| }); | |
| afterEach(() => { | |
| document.body.removeChild(container); | |
| }); | |
| it('correctly calls committed handler, not stale handler from discarded render', async () => { | |
| const handlerCallLog = []; | |
| const buttonRef = createRef(); | |
| let shouldDispatchDuringRender = false; | |
| function App({label}) { | |
| const deferredLabel = useDeferredValue(label); | |
| const [, setInterrupt] = useState(false); | |
| return ( | |
| <> | |
| <button | |
| ref={buttonRef} | |
| onClick={() => { | |
| handlerCallLog.push(deferredLabel); | |
| // Trigger a high-priority update to interrupt the deferred render. | |
| setInterrupt(prev => !prev); | |
| }} | |
| /> | |
| <InterruptTrigger label={label} deferredLabel={deferredLabel} /> | |
| </> | |
| ); | |
| } | |
| // Dispatches a discrete DOM event during render when the deferred | |
| // re-render is in progress (deferredLabel has caught up to label). | |
| // By this point, the sibling button's completeWork has already run, | |
| // but unlike React Native, React DOM does NOT eagerly mutate the | |
| // committed fiber's props — the DOM node still references the | |
| // committed fiber tree. | |
| function InterruptTrigger({label, deferredLabel}) { | |
| if (shouldDispatchDuringRender && deferredLabel === label) { | |
| shouldDispatchDuringRender = false; | |
| // dispatchEvent is synchronous. React's delegated listener on the | |
| // root catches this, processes it as a discrete event, and calls | |
| // the handler from the *committed* fiber props. | |
| buttonRef.current.dispatchEvent( | |
| new MouseEvent('click', {bubbles: true}), | |
| ); | |
| } | |
| return null; | |
| } | |
| const root = createRoot(container); | |
| // Initial render: commits handler capturing deferredLabel="initial". | |
| await act(() => { | |
| root.render(<App label="initial" />); | |
| }); | |
| shouldDispatchDuringRender = true; | |
| // startTransition triggers: | |
| // 1. First transition render: useDeferredValue("transition") returns | |
| // "initial" (deferred) → commits, handler still captures "initial". | |
| // 2. Deferred re-render: useDeferredValue("transition") returns | |
| // "transition" → button's completeWork runs, but on React DOM the | |
| // committed fiber's props are NOT mutated → InterruptTrigger | |
| // dispatches click → React reads handler from committed fiber → | |
| // committed handler is called, correctly logging "initial" → | |
| // setState interrupts and discards the deferred render. | |
| await act(() => { | |
| startTransition(() => { | |
| root.render(<App label="transition" />); | |
| }); | |
| }); | |
| // React DOM correctly calls the committed handler (capturing "initial"), | |
| // because the DOM node's internal fiber reference points to the committed | |
| // fiber, and event handlers are read from committed props. | |
| // | |
| // This is the CORRECT behavior that React Native should also have. | |
| // On React Native, this would incorrectly be ['transition'] because | |
| // canonical.currentProps is eagerly mutated during completeWork. | |
| expect(handlerCallLog).toEqual(['initial']); | |
| }); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment