facebook / react · Issue No. 37600
A Fragment listener removed by an earlier callback still executes during the same FragmentInstance.dispatchEvent call. Aborting its signal has the same effect.
Current react/react main: 019019be403c3269e15b8d7ebefb57d30f84086b (React source version 19.3.0). Verified on source, not inferred from an older published release.
The snippet assumes React/createPortal, createRoot and flushSync imports, and a mounted root created with createRoot(container).
const ref = React.createRef();
flushSync(() => root.render(<React.Fragment ref={ref}><button /></React.Fragment>));
let calls = 0;
const later = () => calls++;
ref.current.addEventListener('click', () => {
ref.current.removeEventListener('click', later);
});
ref.current.addEventListener('click', later);
ref.current.dispatchEvent(new MouseEvent('click'));
console.log(calls); // 1, expected 0
A runnable repository regression test is included in the linked fix PR.
The removed callback runs once on the temporary dispatch target even though it was removed from the Fragment registry and host children.
A listener removed before its turn should not run, consistent with native event dispatch. Removing and re-adding the same callback should not reactivate the original registration in the current dispatch.
dispatchEvent copies attached listeners onto a temporary DOM target. removeEventListener walks only Fragment host children; it does not detach the temporary target listener currently participating in dispatch.
Source: packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js.
Severity assessment: Medium. A Fragment listener removed by an earlier callback still executes during the same FragmentInstance.dispatchEvent call. Aborting its signal has the same effect.
Before: remove and abort regressions each call the later listener once instead of zero. After: development and production Fragment suites both pass 102/102 tests. Prettier, ESLint and Flow pass.
Searched all Issue/PR states for Fragment dispatchEvent, removed listeners, abort and dispatch. Inspected #37455 and the full diff of #37456: that PR addresses exceptions or imperative removal of the temporary target after dispatch, not callbacks removed before their turn. This fix is independent, though the cleanup hunk may overlap if #37456 lands first.
The native target still controls propagation, cancellation and callback ordering. The identity guard distinguishes a re-added registration from the removed one; existing once behavior and listener-object receivers are covered. It adds temporary closures and a registry lookup during Fragment dispatch.
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.