Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save rubennorte/b002d3673b4d83a7181311a96084f9f0 to your computer and use it in GitHub Desktop.

Select an option

Save rubennorte/b002d3673b4d83a7181311a96084f9f0 to your computer and use it in GitHub Desktop.
/**
* @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