[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;