@samitouri / QOS-React-2 / commits / fdaa617ce5

[DOM] Apply Fragment listeners to children inserted into portals later (#37166)

Commit-time fragment ancestry stopped at HostPortal via isHostParent, so a child added after listeners were registered never received them even though existing portal siblings did. Collect fragment parents past portals and other non-HostComponent host parents so commit bookkeeping matches HostComponent/HostRoot fragment ancestry.

Jack Pope committed Aug 11, 2026 at 18:38 UTC fdaa617ce526e359f2cfa0d800646bdbd07b7123
2 files changed +77 -21
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+50
@@ -1075,6 +1075,56 @@ describe('FragmentRefs', () => {
1075 expect(logs).toEqual(['child-b']);
1076 });
1077
1078 + // @gate enableFragmentRefs
1079 + it('applies event listeners to children portaled in after registration', async () => {
1080 + const fragmentRef = React.createRef();
1081 + const childARef = React.createRef();
1082 + const childBRef = React.createRef();
1083 + const root = ReactDOMClient.createRoot(container);
1084 + let showChildB;
1085 +
1086 + function Test() {
1087 + const [shouldShowChildB, setShouldShowChildB] = React.useState(false);
1088 + showChildB = () => {
1089 + setShouldShowChildB(true);
1090 + };
1091 +
1092 + return (
1093 + <Fragment ref={fragmentRef}>
1094 + {createPortal(
1095 + <>
1096 + <div id="child-a" ref={childARef} />
1097 + {shouldShowChildB && <div id="child-b" ref={childBRef} />}
1098 + </>,
1099 + document.body,
1100 + )}
1101 + </Fragment>
1102 + );
1103 + }
1104 +
1105 + await act(() => {
1106 + root.render(<Test />);
1107 + });
1108 +
1109 + const logs = [];
1110 + fragmentRef.current.addEventListener('click', e => {
1111 + logs.push(e.target.id);
1112 + });
1113 +
1114 + childARef.current.click();
1115 + expect(logs).toEqual(['child-a']);
1116 +
1117 + // child-b is inserted into the same portal after the listener was
1118 + // registered, so it should be treated like its sibling child-a.
1119 + await act(() => {
1120 + showChildB();
1121 + });
1122 +
1123 + logs.length = 0;
1124 + childBRef.current.click();
1125 + expect(logs).toEqual(['child-b']);
1126 + });
1127 +
1128 describe('with activity', () => {
1129 // @gate enableFragmentRefs
1130 it('does not apply event listeners to hidden trees', async () => {
packages/react-reconciler/src/ReactFiberCommitHostEffects.js
+27 -21
@@ -284,7 +284,7 @@ export function commitFragmentInstanceInsertionEffects(fiber: Fiber): void {
284 commitNewChildToFragmentInstance(fiber.stateNode, fragmentInstance);
285 }
286
287 - if (isFragmentInstanceHostParent(parent)) {
287 + if (isFragmentInstanceHostBoundary(parent)) {
288 return;
289 }
290
@@ -300,7 +300,7 @@ export function commitFragmentInstanceDeletionEffects(fiber: Fiber): void {
300 deleteChildFromFragmentInstance(fiber.stateNode, fragmentInstance);
301 }
302
303 - if (isFragmentInstanceHostParent(parent)) {
303 + if (isFragmentInstanceHostBoundary(parent)) {
304 return;
305 }
306
@@ -322,23 +322,24 @@ function isHostParent(fiber: Fiber): boolean {
322 );
323 }
324
325 -function isFragmentInstanceParent(fiber: Fiber): boolean {
326 - return fiber && fiber.tag === Fragment && fiber.stateNode !== null;
327 -}
328 -
329 -// Fragments collect HostSingleton children regardless of whether the
330 -// singleton is a scope for placement, so their host parent boundary is
331 -// wider than `isHostParent`.
332 -function isFragmentInstanceHostParent(fiber: Fiber): boolean {
325 +// HostPortal / HostHoistable are host parents for placement, but not for
326 +// fragment instance ancestry — commit bookkeeping walks past them so it
327 +// matches getFragmentParentInstanceOrContainerFiber. HostSingleton is a
328 +// fragment host boundary (and a collected child) even when it is not a
329 +// placement scope.
330 +function isFragmentInstanceHostBoundary(fiber: Fiber): boolean {
331 return (
332 fiber.tag === HostComponent ||
335 - // $FlowFixMe[constant-condition]
336 - (supportsSingletons ? fiber.tag === HostSingleton : false) ||
333 fiber.tag === HostRoot ||
338 - fiber.tag === HostPortal
334 + // $FlowFixMe[constant-condition]
335 + (supportsSingletons ? fiber.tag === HostSingleton : false)
336 );
337 }
338
339 +function isFragmentInstanceParent(fiber: Fiber): boolean {
340 + return fiber && fiber.tag === Fragment && fiber.stateNode !== null;
341 +}
342 +
343 function getHostSibling(fiber: Fiber): ?Instance {
344 // We're going to search forward into the tree until we find a sibling host
345 // node. Unfortunately, if multiple insertions are done in a row we have to
@@ -535,16 +536,21 @@ function commitPlacement(finishedWork: Fiber): void {
536 parentFragmentInstances.push(fragmentInstance);
537 }
538 }
538 - if (collectFragmentInstances && isFragmentInstanceHostParent(parentFiber)) {
539 - // Fragments collect children only down to the nearest host fiber.
540 - // The search for the placement parent can continue past host fibers
541 - // that are not valid placement parents, like HostSingletons outside
542 - // a singleton scope, but fragments above them own that host fiber
543 - // as a child, not the placed node.
539 + if (hostParentFiber === undefined && isHostParent(parentFiber)) {
540 + // Nearest host parent for placement. Portals still win here so
541 + // children insert into the portal container.
542 + hostParentFiber = parentFiber;
543 + }
544 + if (
545 + collectFragmentInstances &&
546 + isFragmentInstanceHostBoundary(parentFiber)
547 + ) {
548 + // Stop collecting at HostComponent / HostRoot / HostSingleton.
549 + // Placement can continue past non-scope singletons to HostRoot, and
550 + // past portals (already recorded above) to fragment ancestors.
551 collectFragmentInstances = false;
552 }
546 - if (isHostParent(parentFiber)) {
547 - hostParentFiber = parentFiber;
553 + if (hostParentFiber !== undefined && !collectFragmentInstances) {
554 break;
555 }
556 parentFiber = parentFiber.return;