diff --git a/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js b/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js index 6d615160d8be..b951692ffccd 100644 --- a/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js +++ b/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js @@ -491,7 +491,11 @@ function addTrappedEventListener( targetContainer = enableLegacyFBSupport && isDeferredListenerForLegacyFBSupport - ? (targetContainer as any).ownerDocument + ? // A Document container's ownerDocument is null, so it must be used + // as the deferral target itself. + (targetContainer as any).nodeType === DOCUMENT_NODE + ? targetContainer + : (targetContainer as any).ownerDocument : targetContainer; let unsubscribeListener; diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefsDocument-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefsDocument-test.js index af8bbc86937d..8157b43f3766 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefsDocument-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefsDocument-test.js @@ -30,6 +30,8 @@ describe('FragmentRefs', () => { document = jsdom.window.document; global.window = jsdom.window; global.document = global.window.document; + global.navigator = global.window.navigator; + global.Event = global.window.Event; }); describe('focus methods', () => { @@ -54,7 +56,9 @@ describe('FragmentRefs', () => { }); await act(() => { - fragmentRef.current.focus(); + // focus() would stop at , which is a child of the fragment + // and usually already the activeElement. + document.getElementById('child-a').focus(); }); expect(document.activeElement.id).toEqual('child-a'); @@ -65,4 +69,187 @@ describe('FragmentRefs', () => { }); }); }); + + describe('events', () => { + describe('dispatchEvent()', () => { + // @gate enableFragmentRefs + it('fires events when the fragment is a child of a HostSingleton in a document root', async () => { + const fragmentRef = React.createRef(); + const bodyRef = React.createRef(); + const root = ReactDOMClient.createRoot(document); + + await act(() => { + root.render( + + + + + , + ); + }); + + const fragmentListener = jest.fn(); + fragmentRef.current.addEventListener('custom', fragmentListener); + const bodyListener = jest.fn(); + bodyRef.current.addEventListener('custom', bodyListener); + + // The is the fragment's host parent, so the + // temporary event target is appended there. + fragmentRef.current.dispatchEvent(new Event('custom', {bubbles: true})); + + expect(fragmentListener).toHaveBeenCalledTimes(1); + expect(bodyListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('addEventListener()', () => { + // @gate enableFragmentRefs + it('attaches listeners to the host children inside singletons', async () => { + const fragmentRef = React.createRef(); + const childRef = React.createRef(); + const root = ReactDOMClient.createRoot(document); + + await act(() => { + root.render( + + + +
+ + + , + ); + }); + + const currentTargets = []; + fragmentRef.current.addEventListener('click', event => { + currentTargets.push(event.currentTarget); + }); + + childRef.current.dispatchEvent(new Event('click', {bubbles: true})); + + // The singleton is the fragment's child, so the listener is + // attached there and receives the bubbling event. + expect(currentTargets).toEqual([document.documentElement]); + }); + + // @gate enableFragmentRefs + it('attaches listeners to a singleton mounted into the fragment, but not to its content', async () => { + const fragmentRef = React.createRef(); + const childRef = React.createRef(); + const root = ReactDOMClient.createRoot(document); + + function Test({showShell}) { + return ( + + {showShell && ( + + +
+ + + )} + + ); + } + + await act(() => { + root.render(); + }); + + const currentTargets = []; + fragmentRef.current.addEventListener('click', event => { + currentTargets.push(event.currentTarget); + }); + + await act(() => { + root.render(); + }); + + childRef.current.dispatchEvent(new Event('click', {bubbles: true})); + + // The placed singleton receives the fragment's listener as a + // new child. Its content is not attributed to the fragment, so the + // event only fires once when it bubbles to . + expect(currentTargets).toEqual([document.documentElement]); + }); + + // @gate enableFragmentRefs + it('attributes new children inside a singleton to fragments below it, not above it', async () => { + const outerFragmentRef = React.createRef(); + const innerFragmentRef = React.createRef(); + const lateChildRef = React.createRef(); + const root = ReactDOMClient.createRoot(document); + + function Test({showLateChild}) { + return ( + + + + +
+ {showLateChild && } + + + + + ); + } + + await act(() => { + root.render(); + }); + + const outerCurrentTargets = []; + outerFragmentRef.current.addEventListener('click', event => { + outerCurrentTargets.push(event.currentTarget); + }); + const innerCurrentTargets = []; + innerFragmentRef.current.addEventListener('click', event => { + innerCurrentTargets.push(event.currentTarget); + }); + + await act(() => { + root.render(); + }); + + lateChildRef.current.dispatchEvent(new Event('click', {bubbles: true})); + + // The inner fragment owns the new child directly and attaches its + // listener on insertion. The outer fragment's child is the + // singleton, so the new child inside is not attributed to it + // and its listener only fires once via bubbling. + expect(innerCurrentTargets).toEqual([lateChildRef.current]); + expect(outerCurrentTargets).toEqual([document.documentElement]); + }); + }); + }); + + describe('getClientRects()', () => { + // @gate enableFragmentRefs + it('measures the host children inside singletons', async () => { + const fragmentRef = React.createRef(); + const childRef = React.createRef(); + const root = ReactDOMClient.createRoot(document); + + await act(() => { + root.render( + + + +
+ + + , + ); + }); + + childRef.current.getClientRects = jest.fn(() => ['child-rect']); + document.documentElement.getClientRects = jest.fn(() => ['html-rect']); + + // The singleton is the fragment's child, so it is measured + // instead of the elements inside it + expect(fragmentRef.current.getClientRects()).toEqual(['html-rect']); + }); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js index 75129c1e8c9b..c734882e9c6d 100644 --- a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js +++ b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js @@ -262,6 +262,7 @@ export function commitNewChildToFragmentInstances( ): void { if ( (fiber.tag !== HostComponent && + fiber.tag !== HostSingleton && !(enableFragmentRefsTextNodes && fiber.tag === HostText)) || // Only run fragment insertion effects for initial insertions fiber.alternate !== null || @@ -283,7 +284,7 @@ export function commitFragmentInstanceInsertionEffects(fiber: Fiber): void { commitNewChildToFragmentInstance(fiber.stateNode, fragmentInstance); } - if (isHostParent(parent)) { + if (isFragmentInstanceHostParent(parent)) { return; } @@ -299,7 +300,7 @@ export function commitFragmentInstanceDeletionEffects(fiber: Fiber): void { deleteChildFromFragmentInstance(fiber.stateNode, fragmentInstance); } - if (isHostParent(parent)) { + if (isFragmentInstanceHostParent(parent)) { return; } @@ -325,6 +326,19 @@ function isFragmentInstanceParent(fiber: Fiber): boolean { return fiber && fiber.tag === Fragment && fiber.stateNode !== null; } +// Fragments collect HostSingleton children regardless of whether the +// singleton is a scope for placement, so their host parent boundary is +// wider than `isHostParent`. +function isFragmentInstanceHostParent(fiber: Fiber): boolean { + return ( + fiber.tag === HostComponent || + // $FlowFixMe[constant-condition] + (supportsSingletons ? fiber.tag === HostSingleton : false) || + fiber.tag === HostRoot || + fiber.tag === HostPortal + ); +} + function getHostSibling(fiber: Fiber): ?Instance { // We're going to search forward into the tree until we find a sibling host // node. Unfortunately, if multiple insertions are done in a row we have to @@ -411,15 +425,20 @@ function insertOrAppendPlacementNodeIntoContainer( return; } - if ( - // $FlowFixMe[constant-condition] - (supportsSingletons ? tag === HostSingleton : false) && - isSingletonScope(node.type) - ) { - // This singleton is the parent of deeper nodes and needs to become - // the parent for child insertions and appends - parent = node.stateNode; - before = null; + // $FlowFixMe[constant-condition] + if (supportsSingletons ? tag === HostSingleton : false) { + if (enableFragmentRefs) { + // The singleton is the fragment child. Its own children are not + // attributed to the fragment instances above it. + commitNewChildToFragmentInstances(node, parentFragmentInstances); + parentFragmentInstances = null; + } + if (isSingletonScope(node.type)) { + // This singleton is the parent of deeper nodes and needs to become + // the parent for child insertions and appends + parent = node.stateNode; + before = null; + } } const child = node.child; @@ -470,14 +489,19 @@ function insertOrAppendPlacementNode( return; } - if ( - // $FlowFixMe[constant-condition] - (supportsSingletons ? tag === HostSingleton : false) && - isSingletonScope(node.type) - ) { - // This singleton is the parent of deeper nodes and needs to become - // the parent for child insertions and appends - parent = node.stateNode; + // $FlowFixMe[constant-condition] + if (supportsSingletons ? tag === HostSingleton : false) { + if (enableFragmentRefs) { + // The singleton is the fragment child. Its own children are not + // attributed to the fragment instances above it. + commitNewChildToFragmentInstances(node, parentFragmentInstances); + parentFragmentInstances = null; + } + if (isSingletonScope(node.type)) { + // This singleton is the parent of deeper nodes and needs to become + // the parent for child insertions and appends + parent = node.stateNode; + } } const child = node.child; @@ -500,9 +524,10 @@ function commitPlacement(finishedWork: Fiber): void { // Recursively insert all host nodes into the parent. let hostParentFiber; let parentFragmentInstances = null; + let collectFragmentInstances = enableFragmentRefs; let parentFiber = finishedWork.return; while (parentFiber !== null) { - if (enableFragmentRefs && isFragmentInstanceParent(parentFiber)) { + if (collectFragmentInstances && isFragmentInstanceParent(parentFiber)) { const fragmentInstance: FragmentInstanceType = parentFiber.stateNode; if (parentFragmentInstances === null) { parentFragmentInstances = [fragmentInstance]; @@ -510,6 +535,14 @@ function commitPlacement(finishedWork: Fiber): void { parentFragmentInstances.push(fragmentInstance); } } + if (collectFragmentInstances && isFragmentInstanceHostParent(parentFiber)) { + // Fragments collect children only down to the nearest host fiber. + // The search for the placement parent can continue past host fibers + // that are not valid placement parents, like HostSingletons outside + // a singleton scope, but fragments above them own that host fiber + // as a child, not the placed node. + collectFragmentInstances = false; + } if (isHostParent(parentFiber)) { hostParentFiber = parentFiber; break; @@ -600,8 +633,13 @@ function commitImmutablePlacementNodeToFragmentInstances( if (!enableFragmentRefs) { return; } - const isHost = finishedWork.tag === HostComponent; + const isHost = + finishedWork.tag === HostComponent || + // $FlowFixMe[constant-condition] + (supportsSingletons ? finishedWork.tag === HostSingleton : false); if (isHost) { + // A singleton is the fragment child itself, so its own children are + // not attributed to the fragment instances above it. commitNewChildToFragmentInstances(finishedWork, parentFragmentInstances); return; } else if (finishedWork.tag === HostPortal) { diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index d7a15513e69c..2357bc038281 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -1527,6 +1527,9 @@ function commitDeletionEffectsOnFiber( if (!offscreenSubtreeWasHidden) { safelyDetachRef(deletedFiber, nearestMountedAncestor); } + if (enableFragmentRefs) { + commitFragmentInstanceDeletionEffects(deletedFiber); + } const prevHostParent = hostParent; const prevHostParentIsContainer = hostParentIsContainer; @@ -3102,6 +3105,7 @@ function disappearLayoutEffects( if ( enableFragmentRefs && (finishedWork.tag === HostComponent || + finishedWork.tag === HostSingleton || (enableFragmentRefsTextNodes && finishedWork.tag === HostText)) ) { commitFragmentInstanceDeletionEffects(finishedWork); @@ -3287,7 +3291,11 @@ function reappearLayoutEffects( case HostHoistable: case HostComponent: { // TODO: Enable HostText for RN - if (enableFragmentRefs && finishedWork.tag === HostComponent) { + if ( + enableFragmentRefs && + (finishedWork.tag === HostComponent || + finishedWork.tag === HostSingleton) + ) { commitFragmentInstanceInsertionEffects(finishedWork); } recursivelyTraverseReappearLayoutEffects( diff --git a/packages/react-reconciler/src/ReactFiberTreeReflection.js b/packages/react-reconciler/src/ReactFiberTreeReflection.js index 9d3d3020268a..a3e8fc67b4ee 100644 --- a/packages/react-reconciler/src/ReactFiberTreeReflection.js +++ b/packages/react-reconciler/src/ReactFiberTreeReflection.js @@ -392,6 +392,7 @@ function traverseVisibleInstancesAndTextInstances( while (child !== null) { const isHostNode = child.tag === HostComponent || + child.tag === HostSingleton || (enableFragmentRefsTextNodes && child.tag === HostText); if (isHostNode && fn(child, a, b, c)) { return true; @@ -402,7 +403,8 @@ function traverseVisibleInstancesAndTextInstances( // Skip hidden subtrees } else { if ( - (searchWithinHosts || child.tag !== HostComponent) && + (searchWithinHosts || + (child.tag !== HostComponent && child.tag !== HostSingleton)) && traverseVisibleInstancesAndTextInstances( child.child, searchWithinHosts, @@ -425,7 +427,11 @@ export function getFragmentParentInstanceOrContainerFiber( ): null | Fiber { let parent = fiber.return; while (parent !== null) { - if (parent.tag === HostRoot || parent.tag === HostComponent) { + if ( + parent.tag === HostRoot || + parent.tag === HostComponent || + parent.tag === HostSingleton + ) { return parent; } parent = parent.return; @@ -441,7 +447,11 @@ export function fiberIsPortaledIntoHost(fiber: Fiber): boolean { if (parent.tag === HostPortal) { foundPortalParent = true; } - if (parent.tag === HostRoot || parent.tag === HostComponent) { + if ( + parent.tag === HostRoot || + parent.tag === HostComponent || + parent.tag === HostSingleton + ) { break; } parent = parent.return; @@ -486,6 +496,7 @@ function findFragmentInstanceOrTextInstanceSiblings( } if ( child.tag === HostComponent || + child.tag === HostSingleton || (enableFragmentRefsTextNodes && child.tag === HostText) ) { if (foundSelf) { @@ -521,6 +532,7 @@ export function getInstanceFromHostFiber< >(fiber: Fiber): I { switch (fiber.tag) { case HostComponent: + case HostSingleton: case HostText: return fiber.stateNode; case HostRoot: @@ -589,7 +601,9 @@ export function isFragmentContainedByFiber( getFragmentParentInstanceOrContainerFiber(fragmentFiber); while (current !== null) { if ( - (current.tag === HostComponent || current.tag === HostRoot) && + (current.tag === HostComponent || + current.tag === HostRoot || + current.tag === HostSingleton) && (current === fiberHostParent || current.alternate === fiberHostParent) ) { return true;