From 6aa8254bb7353fe3096289edc669cf168e9fd190 Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Wed, 12 Mar 2025 10:32:11 -0400 Subject: [PATCH] Add ref to Fragment (#32465) *This API is experimental and subject to change or removal.* This PR is an alternative to https://github.com/facebook/react/pull/32421 based on feedback: https://github.com/facebook/react/pull/32421#pullrequestreview-2625382015 . The difference here is that we traverse from the Fragment's fiber at operation time instead of keeping a set of children on the `FragmentInstance`. We still need to handle newly added or removed child nodes to apply event listeners and observers, so we treat those updates as effects. **Fragment Refs** This PR extends React's Fragment component to accept a `ref` prop. The Fragment's ref will attach to a custom host instance, which will provide an Element-like API for working with the Fragment's host parent and host children. Here I've implemented `addEventListener`, `removeEventListener`, and `focus` to get started but we'll be iterating on this by adding additional APIs in future PRs. This sets up the mechanism to attach refs and perform operations on children. The FragmentInstance is implemented in `react-dom` here but is planned for Fabric as well. The API works by targeting the first level of host children and proxying Element-like APIs to allow developers to manage groups of elements or elements that cannot be easily accessed such as from a third-party library or deep in a tree of Functional Component wrappers. ```javascript import {Fragment, useRef} from 'react'; const fragmentRef = useRef(null);
``` In this case, calling `fragmentRef.current.addEventListener()` would apply an event listener to `A`, `B`, and `D`. `C` is skipped because it is nested under the first level of Host Component. If another Host Component was appended as a sibling to `A`, `B`, or `D`, the event listener would be applied to that element as well and any other APIs would also affect the newly added child. This is an implementation of the basic feature as a starting point for feedback and further iteration. --- packages/react-art/src/ReactFiberConfigART.js | 21 + .../src/client/ReactFiberConfigDOM.js | 230 +++++++ .../__tests__/ReactDOMFragmentRefs-test.js | 620 ++++++++++++++++++ .../src/ReactFiberConfigFabric.js | 29 + .../src/ReactFiberConfigNative.js | 29 + .../src/createReactNoop.js | 12 + .../react-reconciler/src/ReactChildFiber.js | 50 +- .../src/ReactFiberBeginWork.js | 4 + .../src/ReactFiberCommitEffects.js | 18 +- .../src/ReactFiberCommitHostEffects.js | 153 ++++- .../src/ReactFiberCommitWork.js | 59 +- .../src/forks/ReactFiberConfig.custom.js | 8 + .../src/ReactFiberConfigTestHost.js | 29 + .../ReactElementValidator-test.internal.js | 10 +- .../ReactJSXElementValidator-test.js | 24 +- packages/shared/ReactFeatureFlags.js | 3 +- .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 2 + .../forks/ReactFeatureFlags.test-renderer.js | 2 + ...actFeatureFlags.test-renderer.native-fb.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 2 + .../forks/ReactFeatureFlags.www-dynamic.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 23 files changed, 1259 insertions(+), 50 deletions(-) create mode 100644 packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js diff --git a/packages/react-art/src/ReactFiberConfigART.js b/packages/react-art/src/ReactFiberConfigART.js index 1ca506d022..5b8788453f 100644 --- a/packages/react-art/src/ReactFiberConfigART.js +++ b/packages/react-art/src/ReactFiberConfigART.js @@ -318,6 +318,27 @@ export function cloneMutableTextInstance(textInstance) { return textInstance; } +export type FragmentInstanceType = null; + +export function createFragmentInstance(fiber): null { + return null; +} + +export function updateFragmentInstanceFiber(fiber, instance): void { + // Noop +} + +export function commitNewChildToFragmentInstance( + child, + fragmentInstance, +): void { + // Noop +} + +export function deleteChildFromFragmentInstance(child, fragmentInstance): void { + // Noop +} + export function finalizeInitialChildren(domElement, type, props) { return false; } diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index b20055d4f9..4b42c032d1 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -34,6 +34,7 @@ import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostCo import hasOwnProperty from 'shared/hasOwnProperty'; import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion'; import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols'; +import {OffscreenComponent} from 'react-reconciler/src/ReactWorkTags'; export { setCurrentUpdatePriority, @@ -2159,6 +2160,235 @@ export function subscribeToGestureDirection( } } +type EventListenerOptionsOrUseCapture = + | boolean + | { + capture?: boolean, + once?: boolean, + passive?: boolean, + signal?: AbortSignal, + ... + }; + +type StoredEventListener = { + type: string, + listener: EventListener, + optionsOrUseCapture: void | EventListenerOptionsOrUseCapture, +}; + +export type FragmentInstanceType = { + _fragmentFiber: Fiber, + _eventListeners: null | Array, + addEventListener( + type: string, + listener: EventListener, + optionsOrUseCapture?: EventListenerOptionsOrUseCapture, + ): void, + removeEventListener( + type: string, + listener: EventListener, + optionsOrUseCapture?: EventListenerOptionsOrUseCapture, + ): void, + focus(): void, +}; + +function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) { + this._fragmentFiber = fragmentFiber; + this._eventListeners = null; +} +// $FlowFixMe[prop-missing] +FragmentInstance.prototype.addEventListener = function ( + this: FragmentInstanceType, + type: string, + listener: EventListener, + optionsOrUseCapture?: EventListenerOptionsOrUseCapture, +): void { + if (this._eventListeners === null) { + this._eventListeners = []; + } + + const listeners = this._eventListeners; + // Element.addEventListener will only apply uniquely new event listeners by default. Since we + // need to collect the listeners to apply to appended children, we track them ourselves and use + // custom equality check for the options. + const isNewEventListener = + indexOfEventListener(listeners, type, listener, optionsOrUseCapture) === -1; + if (isNewEventListener) { + listeners.push({type, listener, optionsOrUseCapture}); + traverseFragmentInstanceChildren( + this, + this._fragmentFiber.child, + addEventListenerToChild, + type, + listener, + optionsOrUseCapture, + ); + } + this._eventListeners = listeners; +}; +function addEventListenerToChild( + child: Instance, + type: string, + listener: EventListener, + optionsOrUseCapture?: EventListenerOptionsOrUseCapture, +): boolean { + child.addEventListener(type, listener, optionsOrUseCapture); + return false; +} +// $FlowFixMe[prop-missing] +FragmentInstance.prototype.removeEventListener = function ( + this: FragmentInstanceType, + type: string, + listener: EventListener, + optionsOrUseCapture?: EventListenerOptionsOrUseCapture, +): void { + const listeners = this._eventListeners; + if (listeners === null) { + return; + } + if (typeof listeners !== 'undefined' && listeners.length > 0) { + traverseFragmentInstanceChildren( + this, + this._fragmentFiber.child, + removeEventListenerFromChild, + type, + listener, + optionsOrUseCapture, + ); + const index = indexOfEventListener( + listeners, + type, + listener, + optionsOrUseCapture, + ); + if (this._eventListeners !== null) { + this._eventListeners.splice(index, 1); + } + } +}; +function removeEventListenerFromChild( + child: Instance, + type: string, + listener: EventListener, + optionsOrUseCapture?: EventListenerOptionsOrUseCapture, +): boolean { + child.removeEventListener(type, listener, optionsOrUseCapture); + return false; +} +// $FlowFixMe[prop-missing] +FragmentInstance.prototype.focus = function (this: FragmentInstanceType) { + traverseFragmentInstanceChildren( + this, + this._fragmentFiber.child, + setFocusIfFocusable, + ); +}; + +function traverseFragmentInstanceChildren( + fragmentInstance: FragmentInstanceType, + child: Fiber | null, + fn: (Instance, A, B, C) => boolean, + a: A, + b: B, + c: C, +): void { + while (child !== null) { + if (child.tag === HostComponent) { + if (fn(child.stateNode, a, b, c)) { + return; + } + } else if ( + child.tag === OffscreenComponent && + child.memoizedState !== null + ) { + // Skip hidden subtrees + } else { + traverseFragmentInstanceChildren( + fragmentInstance, + child.child, + fn, + a, + b, + c, + ); + } + child = child.sibling; + } +} + +function normalizeListenerOptions( + opts: ?EventListenerOptionsOrUseCapture, +): string { + if (opts == null) { + return '0'; + } + + if (typeof opts === 'boolean') { + return `c=${opts ? '1' : '0'}`; + } + + return `c=${opts.capture ? '1' : '0'}&o=${opts.once ? '1' : '0'}&p=${opts.passive ? '1' : '0'}`; +} + +function indexOfEventListener( + eventListeners: Array, + type: string, + listener: EventListener, + optionsOrUseCapture: void | EventListenerOptionsOrUseCapture, +): number { + for (let i = 0; i < eventListeners.length; i++) { + const item = eventListeners[i]; + if ( + item.type === type && + item.listener === listener && + normalizeListenerOptions(item.optionsOrUseCapture) === + normalizeListenerOptions(optionsOrUseCapture) + ) { + return i; + } + } + return -1; +} + +export function createFragmentInstance( + fragmentFiber: Fiber, +): FragmentInstanceType { + return new (FragmentInstance: any)(fragmentFiber); +} + +export function updateFragmentInstanceFiber( + fragmentFiber: Fiber, + instance: FragmentInstanceType, +): void { + instance._fragmentFiber = fragmentFiber; +} + +export function commitNewChildToFragmentInstance( + childElement: Instance, + fragmentInstance: FragmentInstanceType, +): void { + const eventListeners = fragmentInstance._eventListeners; + if (eventListeners !== null) { + for (let i = 0; i < eventListeners.length; i++) { + const {type, listener, optionsOrUseCapture} = eventListeners[i]; + childElement.addEventListener(type, listener, optionsOrUseCapture); + } + } +} + +export function deleteChildFromFragmentInstance( + childElement: Instance, + fragmentInstance: FragmentInstanceType, +): void { + const eventListeners = fragmentInstance._eventListeners; + if (eventListeners !== null) { + for (let i = 0; i < eventListeners.length; i++) { + const {type, listener, optionsOrUseCapture} = eventListeners[i]; + childElement.removeEventListener(type, listener, optionsOrUseCapture); + } + } +} + export function clearContainer(container: Container): void { const nodeType = container.nodeType; if (nodeType === DOCUMENT_NODE) { diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js new file mode 100644 index 0000000000..727fd30142 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js @@ -0,0 +1,620 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails reactcore + */ + +'use strict'; + +let React; +let ReactDOMClient; +let act; +let container; +let Fragment; +let Activity; + +describe('FragmentRefs', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + Fragment = React.Fragment; + Activity = React.unstable_Activity; + ReactDOMClient = require('react-dom/client'); + act = require('internal-test-utils').act; + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + // @gate enableFragmentRefs + it('attaches a ref to Fragment', async () => { + const fragmentRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + + await act(() => + root.render( +
+ +
Hi
+
+
, + ), + ); + expect(container.innerHTML).toEqual( + '
Hi
', + ); + + expect(fragmentRef.current).not.toBe(null); + }); + + // @gate enableFragmentRefs + it('accepts a ref callback', async () => { + let fragmentRef; + const root = ReactDOMClient.createRoot(container); + + await act(() => { + root.render( + (fragmentRef = ref)}> +
Hi
+
, + ); + }); + + expect(fragmentRef._fragmentFiber).toBeTruthy(); + }); + + // @gate enableFragmentRefs + it('is available in effects', async () => { + function Test() { + const fragmentRef = React.useRef(null); + React.useLayoutEffect(() => { + expect(fragmentRef.current).not.toBe(null); + }); + React.useEffect(() => { + expect(fragmentRef.current).not.toBe(null); + }); + return ( + +
+ + ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + }); + + describe('focus()', () => { + // @gate enableFragmentRefs + it('focuses the first focusable child', async () => { + const fragmentRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + + function Test() { + return ( +
+ +
+ + + B + + + C + + +
+ ); + } + + await act(() => { + root.render(); + }); + + await act(() => { + fragmentRef.current.focus(); + }); + expect(document.activeElement.id).toEqual('child-b'); + document.activeElement.blur(); + }); + + // @gate enableFragmentRefs + it('preserves document order when adding and removing children', async () => { + const fragmentRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + + function Test({showA, showB}) { + return ( + + {showA && } + {showB && } + + ); + } + + // Render with A as the first focusable child + await act(() => { + root.render(); + }); + await act(() => { + fragmentRef.current.focus(); + }); + expect(document.activeElement.id).toEqual('child-a'); + document.activeElement.blur(); + // A is still the first focusable child, but B is also tracked + await act(() => { + root.render(); + }); + await act(() => { + fragmentRef.current.focus(); + }); + expect(document.activeElement.id).toEqual('child-a'); + document.activeElement.blur(); + + // B is now the first focusable child + await act(() => { + root.render(); + }); + await act(() => { + fragmentRef.current.focus(); + }); + expect(document.activeElement.id).toEqual('child-b'); + document.activeElement.blur(); + }); + }); + + describe('event listeners', () => { + // @gate enableFragmentRefs + it('adds and removes event listeners from children', async () => { + const parentRef = React.createRef(); + const fragmentRef = React.createRef(); + const childARef = React.createRef(); + const childBRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + + let logs = []; + + function handleFragmentRefClicks() { + logs.push('fragmentRef'); + } + + function Test() { + React.useEffect(() => { + fragmentRef.current.addEventListener( + 'click', + handleFragmentRefClicks, + ); + + return () => { + fragmentRef.current.removeEventListener( + 'click', + handleFragmentRefClicks, + ); + }; + }, []); + return ( +
+ + <>Text +
A
+ <> +
B
+ +
+
+ ); + } + + await act(() => { + root.render(); + }); + + childARef.current.addEventListener('click', () => { + logs.push('A'); + }); + + childBRef.current.addEventListener('click', () => { + logs.push('B'); + }); + + // Clicking on the parent should not trigger any listeners + parentRef.current.click(); + expect(logs).toEqual([]); + + // Clicking a child triggers its own listeners and the Fragment's + childARef.current.click(); + expect(logs).toEqual(['fragmentRef', 'A']); + + logs = []; + + childBRef.current.click(); + expect(logs).toEqual(['fragmentRef', 'B']); + + logs = []; + + fragmentRef.current.removeEventListener('click', handleFragmentRefClicks); + + childARef.current.click(); + expect(logs).toEqual(['A']); + + logs = []; + + childBRef.current.click(); + expect(logs).toEqual(['B']); + }); + + // @gate enableFragmentRefs + it('adds and removes event listeners from children with multiple fragments', async () => { + const fragmentRef = React.createRef(); + const nestedFragmentRef = React.createRef(); + const nestedFragmentRef2 = React.createRef(); + const childARef = React.createRef(); + const childBRef = React.createRef(); + const childCRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + + await act(() => { + root.render( +
+ +
A
+
+ +
B
+
+
+ +
C
+
+
+
, + ); + }); + + let logs = []; + + function handleFragmentRefClicks() { + logs.push('fragmentRef'); + } + + function handleNestedFragmentRefClicks() { + logs.push('nestedFragmentRef'); + } + + function handleNestedFragmentRef2Clicks() { + logs.push('nestedFragmentRef2'); + } + + fragmentRef.current.addEventListener('click', handleFragmentRefClicks); + nestedFragmentRef.current.addEventListener( + 'click', + handleNestedFragmentRefClicks, + ); + nestedFragmentRef2.current.addEventListener( + 'click', + handleNestedFragmentRef2Clicks, + ); + + childBRef.current.click(); + // Event bubbles to the parent fragment + expect(logs).toEqual(['nestedFragmentRef', 'fragmentRef']); + + logs = []; + + childARef.current.click(); + expect(logs).toEqual(['fragmentRef']); + + logs = []; + childCRef.current.click(); + expect(logs).toEqual(['fragmentRef', 'nestedFragmentRef2']); + + logs = []; + + fragmentRef.current.removeEventListener('click', handleFragmentRefClicks); + nestedFragmentRef.current.removeEventListener( + 'click', + handleNestedFragmentRefClicks, + ); + childCRef.current.click(); + expect(logs).toEqual(['nestedFragmentRef2']); + }); + + // @gate enableFragmentRefs + it('adds an event listener to a newly added child', async () => { + const fragmentRef = React.createRef(); + const childRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + let showChild; + + function Component() { + const [shouldShowChild, setShouldShowChild] = React.useState(false); + showChild = () => { + setShouldShowChild(true); + }; + + return ( +
+ +
A
+ {shouldShowChild && ( +
+ B +
+ )} +
+
+ ); + } + + await act(() => { + root.render(); + }); + + expect(fragmentRef.current).not.toBe(null); + expect(childRef.current).toBe(null); + + let hasClicked = false; + fragmentRef.current.addEventListener('click', () => { + hasClicked = true; + }); + + await act(() => { + showChild(); + }); + expect(childRef.current).not.toBe(null); + + childRef.current.click(); + expect(hasClicked).toBe(true); + }); + + // @gate enableFragmentRefs + it('applies event listeners to host children nested within non-host children', async () => { + const fragmentRef = React.createRef(); + const childRef = React.createRef(); + const nestedChildRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + + function Wrapper({children}) { + return children; + } + + await act(() => { + root.render( +
+ +
Host A
+ + + +
Host B
+
+
+
+
+
, + ); + }); + const logs = []; + fragmentRef.current.addEventListener('click', e => { + logs.push(e.target.textContent); + }); + + expect(logs).toEqual([]); + childRef.current.click(); + expect(logs).toEqual(['Host A']); + nestedChildRef.current.click(); + expect(logs).toEqual(['Host A', 'Host B']); + }); + + // @gate enableFragmentRefs + it('allows adding and cleaning up listeners in effects', async () => { + const root = ReactDOMClient.createRoot(container); + + let logs = []; + function logClick(e) { + logs.push(e.currentTarget.id); + } + + let rerender; + let removeEventListeners; + + function Test() { + const fragmentRef = React.useRef(null); + // eslint-disable-next-line no-unused-vars + const [_, setState] = React.useState(0); + rerender = () => { + setState(p => p + 1); + }; + removeEventListeners = () => { + fragmentRef.current.removeEventListener('click', logClick); + }; + React.useEffect(() => { + fragmentRef.current.addEventListener('click', logClick); + + return removeEventListeners; + }); + + return ( + +
+ + ); + } + + // The event listener was applied + await act(() => root.render()); + expect(logs).toEqual([]); + document.querySelector('#child-a').click(); + expect(logs).toEqual(['child-a']); + + // The event listener can be removed and re-added + logs = []; + await act(rerender); + document.querySelector('#child-a').click(); + expect(logs).toEqual(['child-a']); + }); + + // @gate enableFragmentRefs + it('does not apply removed event listeners to new children', async () => { + const root = ReactDOMClient.createRoot(container); + const fragmentRef = React.createRef(null); + function Test() { + return ( + +
+ + ); + } + + let logs = []; + function logClick(e) { + logs.push(e.currentTarget.id); + } + await act(() => { + root.render(); + }); + fragmentRef.current.addEventListener('click', logClick); + const childA = document.querySelector('#child-a'); + childA.click(); + expect(logs).toEqual(['child-a']); + + logs = []; + fragmentRef.current.removeEventListener('click', logClick); + childA.click(); + expect(logs).toEqual([]); + }); + + describe('with activity', () => { + // @gate enableFragmentRefs && enableActivity + it('does not apply event listeners to hidden trees', async () => { + const parentRef = React.createRef(); + const fragmentRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + + function Test() { + return ( +
+ +
Child 1
+ +
Child 2
+
+
Child 3
+
+
+ ); + } + + await act(() => { + root.render(); + }); + + const logs = []; + fragmentRef.current.addEventListener('click', e => { + logs.push(e.target.textContent); + }); + + const [child1, child2, child3] = parentRef.current.children; + child1.click(); + child2.click(); + child3.click(); + expect(logs).toEqual(['Child 1', 'Child 3']); + }); + + // @gate enableFragmentRefs && enableActivity + it('applies event listeners to visible trees', async () => { + const parentRef = React.createRef(); + const fragmentRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + + function Test() { + return ( +
+ +
Child 1
+ +
Child 2
+
+
Child 3
+
+
+ ); + } + + await act(() => { + root.render(); + }); + + const logs = []; + fragmentRef.current.addEventListener('click', e => { + logs.push(e.target.textContent); + }); + + const [child1, child2, child3] = parentRef.current.children; + child1.click(); + child2.click(); + child3.click(); + expect(logs).toEqual(['Child 1', 'Child 2', 'Child 3']); + }); + + // @gate enableFragmentRefs && enableActivity + it('handles Activity modes switching', async () => { + const fragmentRef = React.createRef(); + const fragmentRef2 = React.createRef(); + const parentRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + + function Test({mode}) { + return ( +
+ + +
Child
+ +
Child 2
+
+
+
+
+ ); + } + + await act(() => { + root.render(); + }); + + let logs = []; + fragmentRef.current.addEventListener('click', () => { + logs.push('clicked 1'); + }); + fragmentRef2.current.addEventListener('click', () => { + logs.push('clicked 2'); + }); + parentRef.current.lastChild.click(); + expect(logs).toEqual(['clicked 1', 'clicked 2']); + + logs = []; + await act(() => { + root.render(); + }); + parentRef.current.firstChild.click(); + parentRef.current.lastChild.click(); + expect(logs).toEqual([]); + + logs = []; + await act(() => { + root.render(); + }); + parentRef.current.lastChild.click(); + // Event order is flipped here because the nested child re-registers first + expect(logs).toEqual(['clicked 2', 'clicked 1']); + }); + }); + }); +}); diff --git a/packages/react-native-renderer/src/ReactFiberConfigFabric.js b/packages/react-native-renderer/src/ReactFiberConfigFabric.js index 6db223e4b4..c3b8ac4d35 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigFabric.js +++ b/packages/react-native-renderer/src/ReactFiberConfigFabric.js @@ -591,6 +591,35 @@ export function waitForCommitToBeReady(): null { return null; } +export type FragmentInstanceType = null; + +export function createFragmentInstance( + fragmentFiber: Fiber, +): FragmentInstanceType { + return null; +} + +export function updateFragmentInstanceFiber( + fragmentFiber: Fiber, + instance: FragmentInstanceType, +): void { + // Noop +} + +export function commitNewChildToFragmentInstance( + child: PublicInstance, + fragmentInstance: FragmentInstanceType, +): void { + // Noop +} + +export function deleteChildFromFragmentInstance( + child: PublicInstance, + fragmentInstance: FragmentInstanceType, +): void { + // Noop +} + export const NotPendingTransition: TransitionStatus = null; export const HostTransitionContext: ReactContext = { $$typeof: REACT_CONTEXT_TYPE, diff --git a/packages/react-native-renderer/src/ReactFiberConfigNative.js b/packages/react-native-renderer/src/ReactFiberConfigNative.js index 0155232f0c..c08e1f0f82 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigNative.js +++ b/packages/react-native-renderer/src/ReactFiberConfigNative.js @@ -202,6 +202,35 @@ export function cloneMutableTextInstance( throw new Error('Not yet implemented.'); } +export type FragmentInstanceType = null; + +export function createFragmentInstance( + fragmentFiber: Fiber, +): FragmentInstanceType { + return null; +} + +export function updateFragmentInstanceFiber( + fragmentFiber: Fiber, + instance: FragmentInstanceType, +): void { + // Noop +} + +export function commitNewChildToFragmentInstance( + child: PublicInstance, + fragmentInstance: FragmentInstanceType, +): void { + // Noop +} + +export function deleteChildFromFragmentInstance( + child: PublicInstance, + fragmentInstance: FragmentInstanceType, +): void { + // Noop +} + export function finalizeInitialChildren( parentInstance: Instance, type: string, diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index c157c0119a..2ddbea7928 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -512,6 +512,18 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { throw new Error('Not yet implemented.'); }, + createFragmentInstance(parentInstance) { + return null; + }, + + commitNewChildToFragmentInstance(child, fragmentInstance) { + // Noop + }, + + deleteChildFromFragmentInstance(child, fragmentInstance) { + // Noop + }, + scheduleTimeout: setTimeout, cancelTimeout: clearTimeout, noTimeout: -1, diff --git a/packages/react-reconciler/src/ReactChildFiber.js b/packages/react-reconciler/src/ReactChildFiber.js index 9ced8912b9..6af8c1356f 100644 --- a/packages/react-reconciler/src/ReactChildFiber.js +++ b/packages/react-reconciler/src/ReactChildFiber.js @@ -47,6 +47,7 @@ import isArray from 'shared/isArray'; import { enableAsyncIterableChildren, disableLegacyMode, + enableFragmentRefs, } from 'shared/ReactFeatureFlags'; import { @@ -214,10 +215,14 @@ function validateFragmentProps( const keys = Object.keys(element.props); for (let i = 0; i < keys.length; i++) { const key = keys[i]; - if (key !== 'children' && key !== 'key') { + if ( + key !== 'children' && + key !== 'key' && + (enableFragmentRefs ? key !== 'ref' : true) + ) { if (fiber === null) { - // For unkeyed root fragments there's no Fiber. We create a fake one just for - // error stack handling. + // For unkeyed root fragments without refs (enableFragmentRefs), + // there's no Fiber. We create a fake one just for error stack handling. fiber = createFiberFromElement(element, returnFiber.mode, 0); if (__DEV__) { fiber._debugInfo = currentDebugInfo; @@ -227,11 +232,19 @@ function validateFragmentProps( runWithFiberInDEV( fiber, erroredKey => { - console.error( - 'Invalid prop `%s` supplied to `React.Fragment`. ' + - 'React.Fragment can only have `key` and `children` props.', - erroredKey, - ); + if (enableFragmentRefs) { + console.error( + 'Invalid prop `%s` supplied to `React.Fragment`. ' + + 'React.Fragment can only have `key`, `ref`, and `children` props.', + erroredKey, + ); + } else { + console.error( + 'Invalid prop `%s` supplied to `React.Fragment`. ' + + 'React.Fragment can only have `key` and `children` props.', + erroredKey, + ); + } }, key, ); @@ -517,6 +530,9 @@ function createChildReconciler( lanes, element.key, ); + if (enableFragmentRefs) { + coerceRef(updated, element); + } validateFragmentProps(element, updated, returnFiber); return updated; } @@ -1619,6 +1635,9 @@ function createChildReconciler( if (child.tag === Fragment) { deleteRemainingChildren(returnFiber, child.sibling); const existing = useFiber(child, element.props.children); + if (enableFragmentRefs) { + coerceRef(existing, element); + } existing.return = returnFiber; if (__DEV__) { existing._debugOwner = element._owner; @@ -1670,6 +1689,9 @@ function createChildReconciler( lanes, element.key, ); + if (enableFragmentRefs) { + coerceRef(created, element); + } created.return = returnFiber; if (__DEV__) { // We treat the parent as the owner for stack purposes. @@ -1742,17 +1764,19 @@ function createChildReconciler( // not as a fragment. Nested arrays on the other hand will be treated as // fragment nodes. Recursion happens at the normal flow. - // Handle top level unkeyed fragments as if they were arrays. - // This leads to an ambiguity between <>{[...]} and <>.... + // Handle top level unkeyed fragments without refs (enableFragmentRefs) + // as if they were arrays. This leads to an ambiguity between <>{[...]} and <>.... // We treat the ambiguous cases above the same. // We don't use recursion here because a fragment inside a fragment // is no longer considered "top level" for these purposes. - const isUnkeyedTopLevelFragment = + const isUnkeyedUnrefedTopLevelFragment = typeof newChild === 'object' && newChild !== null && newChild.type === REACT_FRAGMENT_TYPE && - newChild.key === null; - if (isUnkeyedTopLevelFragment) { + newChild.key === null && + (enableFragmentRefs ? newChild.props.ref === undefined : true); + + if (isUnkeyedUnrefedTopLevelFragment) { validateFragmentProps(newChild, null, returnFiber); newChild = newChild.props.children; } diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 9c88a5de87..b37bd4ea3f 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -116,6 +116,7 @@ import { disableDefaultPropsExceptForClasses, enableHydrationLaneScheduling, enableViewTransition, + enableFragmentRefs, } from 'shared/ReactFeatureFlags'; import isArray from 'shared/isArray'; import shallowEqual from 'shared/shallowEqual'; @@ -987,6 +988,9 @@ function updateFragment( renderLanes: Lanes, ) { const nextChildren = workInProgress.pendingProps; + if (enableFragmentRefs) { + markRef(current, workInProgress); + } reconcileChildren(current, workInProgress, nextChildren, renderLanes); return workInProgress.child; } diff --git a/packages/react-reconciler/src/ReactFiberCommitEffects.js b/packages/react-reconciler/src/ReactFiberCommitEffects.js index 05cf17a342..a0f6b54780 100644 --- a/packages/react-reconciler/src/ReactFiberCommitEffects.js +++ b/packages/react-reconciler/src/ReactFiberCommitEffects.js @@ -11,6 +11,7 @@ import type {Fiber} from './ReactInternalTypes'; import type {UpdateQueue} from './ReactFiberClassUpdateQueue'; import type {FunctionComponentUpdateQueue} from './ReactFiberHooks'; import type {HookFlags} from './ReactHookEffectTags'; +import type {FragmentInstanceType} from './ReactFiberConfig'; import { getViewTransitionName, type ViewTransitionState, @@ -24,9 +25,11 @@ import { enableSchedulingProfiler, enableUseEffectCRUDOverload, enableViewTransition, + enableFragmentRefs, } from 'shared/ReactFeatureFlags'; import { ClassComponent, + Fragment, HostComponent, HostHoistable, HostSingleton, @@ -48,6 +51,7 @@ import { import { getPublicInstance, createViewTransitionInstance, + createFragmentInstance, } from './ReactFiberConfig'; import { captureCommitPhaseError, @@ -877,7 +881,7 @@ function commitAttachRef(finishedWork: Fiber) { case HostComponent: instanceToUse = getPublicInstance(finishedWork.stateNode); break; - case ViewTransitionComponent: + case ViewTransitionComponent: { if (enableViewTransition) { const instance: ViewTransitionState = finishedWork.stateNode; const props: ViewTransitionProps = finishedWork.memoizedProps; @@ -888,6 +892,18 @@ function commitAttachRef(finishedWork: Fiber) { instanceToUse = instance.ref; break; } + instanceToUse = finishedWork.stateNode; + break; + } + case Fragment: + if (enableFragmentRefs) { + const instance: null | FragmentInstanceType = finishedWork.stateNode; + if (instance === null) { + finishedWork.stateNode = createFragmentInstance(finishedWork); + } + instanceToUse = finishedWork.stateNode; + break; + } // Fallthrough default: instanceToUse = finishedWork.stateNode; diff --git a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js index c104c2a846..7dad8b330d 100644 --- a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js +++ b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js @@ -13,6 +13,7 @@ import type { SuspenseInstance, Container, ChildSet, + FragmentInstanceType, } from './ReactFiberConfig'; import type {Fiber, FiberRoot} from './ReactInternalTypes'; @@ -24,6 +25,7 @@ import { HostText, HostPortal, DehydratedFragment, + Fragment, } from './ReactWorkTags'; import {ContentReset, Placement} from './ReactFiberFlags'; import { @@ -50,11 +52,14 @@ import { acquireSingletonInstance, releaseSingletonInstance, isSingletonScope, + commitNewChildToFragmentInstance, + deleteChildFromFragmentInstance, } from './ReactFiberConfig'; import {captureCommitPhaseError} from './ReactFiberWorkLoop'; import {trackHostMutation} from './ReactFiberMutationTracking'; import {runWithFiberInDEV} from './ReactCurrentFiber'; +import {enableFragmentRefs} from 'shared/ReactFeatureFlags'; export function commitHostMount(finishedWork: Fiber) { const type = finishedWork.type; @@ -199,19 +204,46 @@ export function commitShowHideHostTextInstance(node: Fiber, isHidden: boolean) { } } -function getHostParentFiber(fiber: Fiber): Fiber { +export function commitNewChildToFragmentInstances( + fiber: Fiber, + parentFragmentInstances: Array, +): void { + for (let i = 0; i < parentFragmentInstances.length; i++) { + const fragmentInstance = parentFragmentInstances[i]; + commitNewChildToFragmentInstance(fiber.stateNode, fragmentInstance); + } +} + +export function commitFragmentInstanceInsertionEffects(fiber: Fiber): void { let parent = fiber.return; while (parent !== null) { - if (isHostParent(parent)) { - return parent; + if (isFragmentInstanceParent(parent)) { + const fragmentInstance: FragmentInstanceType = parent.stateNode; + commitNewChildToFragmentInstance(fiber.stateNode, fragmentInstance); } + + if (isHostParent(parent)) { + return; + } + parent = parent.return; } +} - throw new Error( - 'Expected to find a host parent. This error is likely caused by a bug ' + - 'in React. Please file an issue.', - ); +export function commitFragmentInstanceDeletionEffects(fiber: Fiber): void { + let parent = fiber.return; + while (parent !== null) { + if (isFragmentInstanceParent(parent)) { + const fragmentInstance: FragmentInstanceType = parent.stateNode; + deleteChildFromFragmentInstance(fiber.stateNode, fragmentInstance); + } + + if (isHostParent(parent)) { + return; + } + + parent = parent.return; + } } function isHostParent(fiber: Fiber): boolean { @@ -226,6 +258,10 @@ function isHostParent(fiber: Fiber): boolean { ); } +function isFragmentInstanceParent(fiber: Fiber): boolean { + return fiber && fiber.tag === Fragment && fiber.stateNode !== null; +} + 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 @@ -288,6 +324,7 @@ function insertOrAppendPlacementNodeIntoContainer( node: Fiber, before: ?Instance, parent: Container, + parentFragmentInstances: null | Array, ): void { const {tag} = node; const isHost = tag === HostComponent || tag === HostText; @@ -298,6 +335,16 @@ function insertOrAppendPlacementNodeIntoContainer( } else { appendChildToContainer(parent, stateNode); } + // TODO: Enable HostText for RN + if ( + enableFragmentRefs && + tag === HostComponent && + // Only run fragment insertion effects for initial insertions + node.alternate === null && + parentFragmentInstances !== null + ) { + commitNewChildToFragmentInstances(node, parentFragmentInstances); + } trackHostMutation(); return; } else if (tag === HostPortal) { @@ -319,10 +366,20 @@ function insertOrAppendPlacementNodeIntoContainer( const child = node.child; if (child !== null) { - insertOrAppendPlacementNodeIntoContainer(child, before, parent); + insertOrAppendPlacementNodeIntoContainer( + child, + before, + parent, + parentFragmentInstances, + ); let sibling = child.sibling; while (sibling !== null) { - insertOrAppendPlacementNodeIntoContainer(sibling, before, parent); + insertOrAppendPlacementNodeIntoContainer( + sibling, + before, + parent, + parentFragmentInstances, + ); sibling = sibling.sibling; } } @@ -332,6 +389,7 @@ function insertOrAppendPlacementNode( node: Fiber, before: ?Instance, parent: Instance, + parentFragmentInstances: null | Array, ): void { const {tag} = node; const isHost = tag === HostComponent || tag === HostText; @@ -342,6 +400,16 @@ function insertOrAppendPlacementNode( } else { appendChild(parent, stateNode); } + // TODO: Enable HostText for RN + if ( + enableFragmentRefs && + tag === HostComponent && + // Only run fragment insertion effects for initial insertions + node.alternate === null && + parentFragmentInstances !== null + ) { + commitNewChildToFragmentInstances(node, parentFragmentInstances); + } trackHostMutation(); return; } else if (tag === HostPortal) { @@ -362,10 +430,15 @@ function insertOrAppendPlacementNode( const child = node.child; if (child !== null) { - insertOrAppendPlacementNode(child, before, parent); + insertOrAppendPlacementNode(child, before, parent, parentFragmentInstances); let sibling = child.sibling; while (sibling !== null) { - insertOrAppendPlacementNode(sibling, before, parent); + insertOrAppendPlacementNode( + sibling, + before, + parent, + parentFragmentInstances, + ); sibling = sibling.sibling; } } @@ -377,40 +450,78 @@ function commitPlacement(finishedWork: Fiber): void { } // Recursively insert all host nodes into the parent. - const parentFiber = getHostParentFiber(finishedWork); + let hostParentFiber; + let parentFragmentInstances = null; + let parentFiber = finishedWork.return; + while (parentFiber !== null) { + if (enableFragmentRefs && isFragmentInstanceParent(parentFiber)) { + const fragmentInstance: FragmentInstanceType = parentFiber.stateNode; + if (parentFragmentInstances === null) { + parentFragmentInstances = [fragmentInstance]; + } else { + parentFragmentInstances.push(fragmentInstance); + } + } + if (isHostParent(parentFiber)) { + hostParentFiber = parentFiber; + break; + } + parentFiber = parentFiber.return; + } + if (hostParentFiber == null) { + throw new Error( + 'Expected to find a host parent. This error is likely caused by a bug ' + + 'in React. Please file an issue.', + ); + } - switch (parentFiber.tag) { + switch (hostParentFiber.tag) { case HostSingleton: { if (supportsSingletons) { - const parent: Instance = parentFiber.stateNode; + const parent: Instance = hostParentFiber.stateNode; const before = getHostSibling(finishedWork); // We only have the top Fiber that was inserted but we need to recurse down its // children to find all the terminal nodes. - insertOrAppendPlacementNode(finishedWork, before, parent); + insertOrAppendPlacementNode( + finishedWork, + before, + parent, + parentFragmentInstances, + ); break; } // Fall through } case HostComponent: { - const parent: Instance = parentFiber.stateNode; - if (parentFiber.flags & ContentReset) { + const parent: Instance = hostParentFiber.stateNode; + if (hostParentFiber.flags & ContentReset) { // Reset the text content of the parent before doing any insertions resetTextContent(parent); // Clear ContentReset from the effect tag - parentFiber.flags &= ~ContentReset; + hostParentFiber.flags &= ~ContentReset; } const before = getHostSibling(finishedWork); // We only have the top Fiber that was inserted but we need to recurse down its // children to find all the terminal nodes. - insertOrAppendPlacementNode(finishedWork, before, parent); + insertOrAppendPlacementNode( + finishedWork, + before, + parent, + parentFragmentInstances, + ); break; } case HostRoot: case HostPortal: { - const parent: Container = parentFiber.stateNode.containerInfo; + const parent: Container = hostParentFiber.stateNode.containerInfo; const before = getHostSibling(finishedWork); - insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent); + insertOrAppendPlacementNodeIntoContainer( + finishedWork, + before, + parent, + parentFragmentInstances, + ); break; } default: diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index b9ea97bf56..8b82e6a7e7 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -61,6 +61,7 @@ import { disableLegacyMode, enableComponentPerformanceTrack, enableViewTransition, + enableFragmentRefs, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -85,6 +86,7 @@ import { CacheComponent, TracingMarkerComponent, ViewTransitionComponent, + Fragment, } from './ReactWorkTags'; import { NoFlags, @@ -164,6 +166,7 @@ import { cancelRootViewTransitionName, restoreRootViewTransitionName, isSingletonScope, + updateFragmentInstanceFiber, } from './ReactFiberConfig'; import { captureCommitPhaseError, @@ -235,6 +238,8 @@ import { commitHostRemoveChild, commitHostSingletonAcquisition, commitHostSingletonRelease, + commitFragmentInstanceDeletionEffects, + commitFragmentInstanceInsertionEffects, } from './ReactFiberCommitHostEffects'; import { commitEnterViewTransitions, @@ -767,8 +772,15 @@ function commitLayoutEffectOnFiber( } break; } - // Fallthrough + break; } + case Fragment: + if (enableFragmentRefs) { + if (flags & Ref) { + safelyAttachRef(finishedWork, finishedWork.return); + } + } + // Fallthrough default: { recursivelyTraverseLayoutEffects( finishedRoot, @@ -1353,6 +1365,9 @@ function commitDeletionEffectsOnFiber( if (!offscreenSubtreeWasHidden) { safelyDetachRef(deletedFiber, nearestMountedAncestor); } + if (enableFragmentRefs && deletedFiber.tag === HostComponent) { + commitFragmentInstanceDeletionEffects(deletedFiber); + } // Intentional fallthrough to next branch } case HostText: { @@ -1563,6 +1578,14 @@ function commitDeletionEffectsOnFiber( } break; } + case Fragment: { + if (enableFragmentRefs) { + if (!offscreenSubtreeWasHidden) { + safelyDetachRef(deletedFiber, nearestMountedAncestor); + } + } + // Fallthrough + } default: { recursivelyTraverseDeletionEffects( finishedRoot, @@ -1947,6 +1970,7 @@ function commitMutationEffectsOnFiber( } case HostComponent: { recursivelyTraverseMutationEffects(root, finishedWork, lanes); + commitReconciliationEffects(finishedWork, lanes); if (flags & Ref) { @@ -2270,7 +2294,7 @@ function commitMutationEffectsOnFiber( } break; } - case ViewTransitionComponent: + case ViewTransitionComponent: { if (enableViewTransition) { if (flags & Ref) { if (!offscreenSubtreeWasHidden && current !== null) { @@ -2298,7 +2322,8 @@ function commitMutationEffectsOnFiber( popMutationContext(prevMutationContext); break; } - // Fallthrough + break; + } case ScopeComponent: { if (enableScopeAPI) { recursivelyTraverseMutationEffects(root, finishedWork, lanes); @@ -2321,6 +2346,13 @@ function commitMutationEffectsOnFiber( } break; } + case Fragment: + if (enableFragmentRefs) { + if (current && current.stateNode !== null) { + updateFragmentInstanceFiber(finishedWork, current.stateNode); + } + } + // Fallthrough default: { recursivelyTraverseMutationEffects(root, finishedWork, lanes); commitReconciliationEffects(finishedWork, lanes); @@ -2638,6 +2670,10 @@ export function disappearLayoutEffects(finishedWork: Fiber) { // TODO (Offscreen) Check: flags & RefStatic safelyDetachRef(finishedWork, finishedWork.return); + if (enableFragmentRefs && finishedWork.tag === HostComponent) { + commitFragmentInstanceDeletionEffects(finishedWork); + } + recursivelyTraverseDisappearLayoutEffects(finishedWork); break; } @@ -2658,6 +2694,13 @@ export function disappearLayoutEffects(finishedWork: Fiber) { if (enableViewTransition) { safelyDetachRef(finishedWork, finishedWork.return); } + recursivelyTraverseDisappearLayoutEffects(finishedWork); + break; + } + case Fragment: { + if (enableFragmentRefs) { + safelyDetachRef(finishedWork, finishedWork.return); + } // Fallthrough } default: { @@ -2765,6 +2808,10 @@ export function reappearLayoutEffects( } case HostHoistable: case HostComponent: { + // TODO: Enable HostText for RN + if (enableFragmentRefs && finishedWork.tag === HostComponent) { + commitFragmentInstanceInsertionEffects(finishedWork); + } recursivelyTraverseReappearLayoutEffects( finishedRoot, finishedWork, @@ -2857,6 +2904,12 @@ export function reappearLayoutEffects( safelyAttachRef(finishedWork, finishedWork.return); break; } + break; + } + case Fragment: { + if (enableFragmentRefs) { + safelyAttachRef(finishedWork, finishedWork.return); + } // Fallthrough } default: { diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index e978249e18..2be7d18b87 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -45,6 +45,7 @@ export type ViewTransitionInstance = null | {name: string, ...}; export opaque type InstanceMeasurement = mixed; export type EventResponder = any; export type GestureTimeline = any; +export type FragmentInstanceType = null; export const rendererVersion = $$$config.rendererVersion; export const rendererPackageName = $$$config.rendererPackageName; @@ -160,6 +161,13 @@ export const subscribeToGestureDirection = export const createViewTransitionInstance = $$$config.createViewTransitionInstance; export const clearContainer = $$$config.clearContainer; +export const createFragmentInstance = $$$config.createFragmentInstance; +export const updateFragmentInstanceFiber = + $$$config.updateFragmentInstanceFiber; +export const commitNewChildToFragmentInstance = + $$$config.commitNewChildToFragmentInstance; +export const deleteChildFromFragmentInstance = + $$$config.deleteChildFromFragmentInstance; // ------------------- // Persistence diff --git a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js index 3804fe22ce..d9a45550fa 100644 --- a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js +++ b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js @@ -449,6 +449,35 @@ export function createViewTransitionInstance( return null; } +export type FragmentInstanceType = null; + +export function createFragmentInstance( + fragmentFiber: Object, +): FragmentInstanceType { + return null; +} + +export function updateFragmentInstanceFiber( + fragmentFiber: Object, + instance: FragmentInstanceType, +): void { + // Noop +} + +export function commitNewChildToFragmentInstance( + child: Instance, + fragmentInstance: FragmentInstanceType, +): void { + // noop +} + +export function deleteChildFromFragmentInstance( + child: Instance, + fragmentInstance: FragmentInstanceType, +): void { + // Noop +} + export function getInstanceFromNode(mockNode: Object): Object | null { const instance = nodeToInstanceMap.get(mockNode); if (instance !== undefined) { diff --git a/packages/react/src/__tests__/ReactElementValidator-test.internal.js b/packages/react/src/__tests__/ReactElementValidator-test.internal.js index bbb06411b0..f1ee702634 100644 --- a/packages/react/src/__tests__/ReactElementValidator-test.internal.js +++ b/packages/react/src/__tests__/ReactElementValidator-test.internal.js @@ -427,9 +427,13 @@ describe('ReactElementValidator', () => { const root = ReactDOMClient.createRoot(document.createElement('div')); await act(() => root.render(React.createElement(Foo))); assertConsoleErrorDev([ - 'Invalid prop `a` supplied to `React.Fragment`. React.Fragment ' + - 'can only have `key` and `children` props.\n' + - ' in Foo (at **)', + gate('enableFragmentRefs') + ? 'Invalid prop `a` supplied to `React.Fragment`. React.Fragment ' + + 'can only have `key`, `ref`, and `children` props.\n' + + ' in Foo (at **)' + : 'Invalid prop `a` supplied to `React.Fragment`. React.Fragment ' + + 'can only have `key` and `children` props.\n' + + ' in Foo (at **)', ]); }); diff --git a/packages/react/src/__tests__/ReactJSXElementValidator-test.js b/packages/react/src/__tests__/ReactJSXElementValidator-test.js index 041191e2ab..41ee720478 100644 --- a/packages/react/src/__tests__/ReactJSXElementValidator-test.js +++ b/packages/react/src/__tests__/ReactJSXElementValidator-test.js @@ -221,9 +221,13 @@ describe('ReactJSXElementValidator', () => { root.render(); }); assertConsoleErrorDev([ - 'Invalid prop `a` supplied to `React.Fragment`. React.Fragment ' + - 'can only have `key` and `children` props.\n' + - ' in Foo (at **)', + gate('enableFragmentRefs') + ? 'Invalid prop `a` supplied to `React.Fragment`. React.Fragment ' + + 'can only have `key`, `ref`, and `children` props.\n' + + ' in Foo (at **)' + : 'Invalid prop `a` supplied to `React.Fragment`. React.Fragment ' + + 'can only have `key` and `children` props.\n' + + ' in Foo (at **)', ]); }); @@ -246,11 +250,15 @@ describe('ReactJSXElementValidator', () => { await act(() => { root.render(); }); - assertConsoleErrorDev([ - 'Invalid prop `ref` supplied to `React.Fragment`.' + - ' React.Fragment can only have `key` and `children` props.\n' + - ' in Foo (at **)', - ]); + assertConsoleErrorDev( + gate('enableFragmentRefs') + ? [] + : [ + 'Invalid prop `ref` supplied to `React.Fragment`.' + + ' React.Fragment can only have `key` and `children` props.\n' + + ' in Foo (at **)', + ], + ); }); it('does not warn for fragments of multiple elements without keys', async () => { diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 17b037cc04..6bed7187dd 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -160,9 +160,10 @@ export const enableInfiniteRenderLoopDetection = false; export const enableUseEffectCRUDOverload = false; export const enableFastAddPropertiesInDiffing = true; - export const enableLazyPublicInstanceInFabric = false; +export const enableFragmentRefs = false; + // ----------------------------------------------------------------------------- // Ready for next major. // diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 08c5781345..b9e9bae96c 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -83,6 +83,7 @@ export const enableThrottledScheduling = false; export const enableViewTransition = false; export const enableSwipeTransition = false; export const enableScrollEndPolyfill = true; +export const enableFragmentRefs = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 9b87875473..baeef0b564 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -76,6 +76,8 @@ export const enableFastAddPropertiesInDiffing = false; export const enableLazyPublicInstanceInFabric = false; export const enableScrollEndPolyfill = true; +export const enableFragmentRefs = false; + // Profiling Only export const enableProfilerTimer = __PROFILE__; export const enableProfilerCommitHooks = __PROFILE__; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index f5deddae73..1e3eedc9e2 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -76,6 +76,8 @@ export const enableFastAddPropertiesInDiffing = true; export const enableLazyPublicInstanceInFabric = false; export const enableScrollEndPolyfill = true; +export const enableFragmentRefs = false; + // TODO: This must be in sync with the main ReactFeatureFlags file because // the Test Renderer's value must be the same as the one used by the // react package. diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 4df92a5c90..f2fa119800 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -71,6 +71,7 @@ export const enableSwipeTransition = false; export const enableFastAddPropertiesInDiffing = false; export const enableLazyPublicInstanceInFabric = false; export const enableScrollEndPolyfill = true; +export const enableFragmentRefs = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index b15f54484b..2342b959f9 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -87,5 +87,7 @@ export const enableFastAddPropertiesInDiffing = false; export const enableLazyPublicInstanceInFabric = false; export const enableScrollEndPolyfill = true; +export const enableFragmentRefs = false; + // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index 3fe3b7a0a3..0116e160b6 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -41,6 +41,7 @@ export const enableLazyPublicInstanceInFabric = false; export const enableViewTransition = __VARIANT__; export const enableComponentPerformanceTrack = __VARIANT__; export const enableScrollEndPolyfill = __VARIANT__; +export const enableFragmentRefs = __VARIANT__; // TODO: These flags are hard-coded to the default values used in open source. // Update the tests so that they pass in either mode, then set these diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 07e7e1f51a..0584af1f81 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -39,6 +39,7 @@ export const { enableViewTransition, enableComponentPerformanceTrack, enableScrollEndPolyfill, + enableFragmentRefs, } = dynamicFeatureFlags; // On WWW, __EXPERIMENTAL__ is used for a new modern build.