diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 0af810924b..bbecbb6635 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -126,6 +126,7 @@ import { enableHydrationChangeEvent, enableFragmentRefsScrollIntoView, enableProfilerTimer, + enableFragmentRefsInstanceHandles, } from 'shared/ReactFeatureFlags'; import { HostComponent, @@ -214,6 +215,10 @@ export type Container = export type Instance = Element; export type TextInstance = Text; +type InstanceWithFragmentHandles = Instance & { + unstable_reactFragments?: Set, +}; + declare class ActivityInterface extends Comment {} declare class SuspenseInterface extends Comment { _reactRetry: void | (() => void); @@ -3390,10 +3395,44 @@ if (enableFragmentRefsScrollIntoView) { }; } +function addFragmentHandleToFiber( + child: Fiber, + fragmentInstance: FragmentInstanceType, +): boolean { + if (enableFragmentRefsInstanceHandles) { + const instance = + getInstanceFromHostFiber(child); + if (instance != null) { + addFragmentHandleToInstance(instance, fragmentInstance); + } + } + return false; +} + +function addFragmentHandleToInstance( + instance: InstanceWithFragmentHandles, + fragmentInstance: FragmentInstanceType, +): void { + if (enableFragmentRefsInstanceHandles) { + if (instance.unstable_reactFragments == null) { + instance.unstable_reactFragments = new Set(); + } + instance.unstable_reactFragments.add(fragmentInstance); + } +} + export function createFragmentInstance( fragmentFiber: Fiber, ): FragmentInstanceType { - return new (FragmentInstance: any)(fragmentFiber); + const fragmentInstance = new (FragmentInstance: any)(fragmentFiber); + if (enableFragmentRefsInstanceHandles) { + traverseFragmentInstance( + fragmentFiber, + addFragmentHandleToFiber, + fragmentInstance, + ); + } + return fragmentInstance; } export function updateFragmentInstanceFiber( @@ -3404,7 +3443,7 @@ export function updateFragmentInstanceFiber( } export function commitNewChildToFragmentInstance( - childInstance: Instance, + childInstance: InstanceWithFragmentHandles, fragmentInstance: FragmentInstanceType, ): void { const eventListeners = fragmentInstance._eventListeners; @@ -3419,17 +3458,25 @@ export function commitNewChildToFragmentInstance( observer.observe(childInstance); }); } + if (enableFragmentRefsInstanceHandles) { + addFragmentHandleToInstance(childInstance, fragmentInstance); + } } export function deleteChildFromFragmentInstance( - childElement: Instance, + childInstance: InstanceWithFragmentHandles, 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); + childInstance.removeEventListener(type, listener, optionsOrUseCapture); + } + } + if (enableFragmentRefsInstanceHandles) { + if (childInstance.unstable_reactFragments != null) { + childInstance.unstable_reactFragments.delete(fragmentInstance); } } } diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js index 90dfa9d2f3..a5b9eae6fa 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js @@ -110,6 +110,53 @@ describe('FragmentRefs', () => { await act(() => root.render()); }); + // @gate enableFragmentRefs && enableFragmentRefsInstanceHandles + it('attaches fragment handles to nodes', async () => { + const fragmentParentRef = React.createRef(); + const fragmentRef = React.createRef(); + + function Test({show}) { + return ( + + +
A
+
B
+
+
C
+ {show &&
D
} +
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + + const childA = document.querySelector('#childA'); + const childB = document.querySelector('#childB'); + const childC = document.querySelector('#childC'); + + expect(childA.unstable_reactFragments.has(fragmentRef.current)).toBe(true); + expect(childB.unstable_reactFragments.has(fragmentRef.current)).toBe(true); + expect(childC.unstable_reactFragments.has(fragmentRef.current)).toBe(false); + expect(childA.unstable_reactFragments.has(fragmentParentRef.current)).toBe( + true, + ); + expect(childB.unstable_reactFragments.has(fragmentParentRef.current)).toBe( + true, + ); + expect(childC.unstable_reactFragments.has(fragmentParentRef.current)).toBe( + true, + ); + + await act(() => root.render()); + + const childD = document.querySelector('#childD'); + expect(childD.unstable_reactFragments.has(fragmentRef.current)).toBe(false); + expect(childD.unstable_reactFragments.has(fragmentParentRef.current)).toBe( + true, + ); + }); + describe('focus methods', () => { describe('focus()', () => { // @gate enableFragmentRefs @@ -1045,6 +1092,126 @@ describe('FragmentRefs', () => { {withoutStack: true}, ); }); + + // @gate enableFragmentRefs && enableFragmentRefsInstanceHandles + it('attaches handles to observed elements to allow caching of observers', async () => { + const targetToCallbackMap = new WeakMap(); + let cachedObserver = null; + function createObserverIfNeeded(fragmentInstance, onIntersection) { + const callbacks = targetToCallbackMap.get(fragmentInstance); + targetToCallbackMap.set( + fragmentInstance, + callbacks ? [...callbacks, onIntersection] : [onIntersection], + ); + if (cachedObserver !== null) { + return cachedObserver; + } + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + const fragmentInstances = entry.target.unstable_reactFragments; + if (fragmentInstances) { + Array.from(fragmentInstances).forEach(fInstance => { + const cbs = targetToCallbackMap.get(fInstance) || []; + cbs.forEach(callback => { + callback(entry); + }); + }); + } + + targetToCallbackMap.get(entry.target)?.forEach(callback => { + callback(entry); + }); + }); + }); + cachedObserver = observer; + return observer; + } + + function IntersectionObserverFragment({onIntersection, children}) { + const fragmentRef = React.useRef(null); + React.useLayoutEffect(() => { + const observer = createObserverIfNeeded( + fragmentRef.current, + onIntersection, + ); + fragmentRef.current.observeUsing(observer); + const lastRefValue = fragmentRef.current; + return () => { + lastRefValue.unobserveUsing(observer); + }; + }, []); + return {children}; + } + + let logs = []; + function logIntersection(id) { + logs.push(`observe: ${id}`); + } + + function ChildWithManualIO({id}) { + const divRef = React.useRef(null); + React.useLayoutEffect(() => { + const observer = createObserverIfNeeded(divRef.current, entry => { + logIntersection(id); + }); + observer.observe(divRef.current); + return () => { + observer.unobserve(divRef.current); + }; + }, []); + return ( +
+ {id} +
+ ); + } + + function Test() { + return ( + <> + logIntersection('grandparent')}> + logIntersection('parentA')}> +
A
+
+
+ logIntersection('parentB')}> +
B
+ +
+ + ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + + simulateIntersection([ + container.querySelector('#childA'), + {y: 0, x: 0, width: 1, height: 1}, + 1, + ]); + expect(logs).toEqual(['observe: grandparent', 'observe: parentA']); + + logs = []; + + simulateIntersection([ + container.querySelector('#childB'), + {y: 0, x: 0, width: 1, height: 1}, + 1, + ]); + expect(logs).toEqual(['observe: parentB']); + + logs = []; + simulateIntersection([ + container.querySelector('#childC'), + {y: 0, x: 0, width: 1, height: 1}, + 1, + ]); + expect(logs).toEqual(['observe: parentB', 'observe: childC']); + }); }); describe('getClientRects', () => { diff --git a/packages/react-native-renderer/src/ReactFiberConfigFabric.js b/packages/react-native-renderer/src/ReactFiberConfigFabric.js index 0f334eea86..f5a4361fd4 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigFabric.js +++ b/packages/react-native-renderer/src/ReactFiberConfigFabric.js @@ -40,6 +40,7 @@ import { type PublicTextInstance, type PublicRootInstance, } from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface'; +import {enableFragmentRefsInstanceHandles} from 'shared/ReactFeatureFlags'; const { createNode, @@ -119,6 +120,9 @@ export type TextInstance = { }; export type HydratableInstance = Instance | TextInstance; export type PublicInstance = ReactNativePublicInstance; +type PublicInstanceWithFragmentHandles = PublicInstance & { + unstable_reactFragments?: Set, +}; export type Container = { containerTag: number, publicInstance: PublicRootInstance | null, @@ -794,10 +798,45 @@ function collectClientRects(child: Fiber, rects: Array): boolean { return false; } +function addFragmentHandleToFiber( + child: Fiber, + fragmentInstance: FragmentInstanceType, +): boolean { + if (enableFragmentRefsInstanceHandles) { + const instance = ((getPublicInstanceFromHostFiber( + child, + ): any): PublicInstanceWithFragmentHandles); + if (instance != null) { + addFragmentHandleToInstance(instance, fragmentInstance); + } + } + return false; +} + +function addFragmentHandleToInstance( + instance: PublicInstanceWithFragmentHandles, + fragmentInstance: FragmentInstanceType, +): void { + if (enableFragmentRefsInstanceHandles) { + if (instance.unstable_reactFragments == null) { + instance.unstable_reactFragments = new Set(); + } + instance.unstable_reactFragments.add(fragmentInstance); + } +} + export function createFragmentInstance( fragmentFiber: Fiber, ): FragmentInstanceType { - return new (FragmentInstance: any)(fragmentFiber); + const fragmentInstance = new (FragmentInstance: any)(fragmentFiber); + if (enableFragmentRefsInstanceHandles) { + traverseFragmentInstance( + fragmentFiber, + addFragmentHandleToFiber, + fragmentInstance, + ); + } + return fragmentInstance; } export function updateFragmentInstanceFiber( @@ -821,13 +860,26 @@ export function commitNewChildToFragmentInstance( observer.observe(publicInstance); }); } + if (enableFragmentRefsInstanceHandles) { + addFragmentHandleToInstance( + ((publicInstance: any): PublicInstanceWithFragmentHandles), + fragmentInstance, + ); + } } export function deleteChildFromFragmentInstance( - child: Instance, + childInstance: Instance, fragmentInstance: FragmentInstanceType, ): void { - // Noop + const publicInstance = ((getPublicInstance( + childInstance, + ): any): PublicInstanceWithFragmentHandles); + if (enableFragmentRefsInstanceHandles) { + if (publicInstance.unstable_reactFragments != null) { + publicInstance.unstable_reactFragments.delete(fragmentInstance); + } + } } export const NotPendingTransition: TransitionStatus = null; diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 4e08b56d11..68410612b5 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -147,6 +147,7 @@ export const enableInfiniteRenderLoopDetection: boolean = false; export const enableFragmentRefs: boolean = true; export const enableFragmentRefsScrollIntoView: boolean = true; +export const enableFragmentRefsInstanceHandles: boolean = false; // ----------------------------------------------------------------------------- // Ready for next major. diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js index 0cbcd61a87..f139a3fcf2 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js @@ -25,4 +25,5 @@ export const passChildrenWhenCloningPersistedNodes = __VARIANT__; export const renameElementSymbol = __VARIANT__; export const enableFragmentRefs = __VARIANT__; export const enableFragmentRefsScrollIntoView = __VARIANT__; +export const enableFragmentRefsInstanceHandles = __VARIANT__; export const enableComponentPerformanceTrack = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index dd0bd8624f..8a2e7921bd 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -27,6 +27,7 @@ export const { renameElementSymbol, enableFragmentRefs, enableFragmentRefsScrollIntoView, + enableFragmentRefsInstanceHandles, } = dynamicFlags; // The rest of the flags are static for better dead code elimination. diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 555307cef0..e9de8e6b4a 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -74,6 +74,7 @@ export const ownerStackLimit = 1e4; export const enableFragmentRefs: boolean = true; export const enableFragmentRefsScrollIntoView: boolean = false; +export const enableFragmentRefsInstanceHandles: boolean = false; // Profiling Only export const enableProfilerTimer: boolean = __PROFILE__; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 9d2e73024d..57de815d9c 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -75,6 +75,7 @@ export const ownerStackLimit = 1e4; export const enableFragmentRefs: boolean = true; export const enableFragmentRefsScrollIntoView: boolean = true; +export const enableFragmentRefsInstanceHandles: boolean = 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 diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index f652ec4047..90e24f264d 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -82,6 +82,7 @@ export const enableDefaultTransitionIndicator: boolean = true; export const enableFragmentRefs: boolean = false; export const enableFragmentRefsScrollIntoView: boolean = false; +export const enableFragmentRefsInstanceHandles: boolean = false; export const ownerStackLimit = 1e4; // Flow magic to verify the exports of this file match the original version. diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 6ba56d9d54..9d8be29c65 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -111,5 +111,7 @@ export const enableDefaultTransitionIndicator: boolean = true; export const ownerStackLimit = 1e4; +export const enableFragmentRefsInstanceHandles: boolean = true; + // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType);