diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 2a5936be0f4..f8cc0a80f39 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -510,7 +510,9 @@ function filterDebugInfo( return; } - // Remove any debug info entries that arrived after the defined end time. + // Remove any debug info entries after the defined end time. For async info + // that means we're including anything that was awaited before the end time, + // but it doesn't need to be resolved before the end time. const relativeEndTime = response._debugEndTime - // $FlowFixMe[prop-missing] @@ -521,9 +523,6 @@ function filterDebugInfo( if (typeof info.time === 'number' && info.time > relativeEndTime) { break; } - if (info.awaited != null && info.awaited.end > relativeEndTime) { - break; - } debugInfo.push(info); } value._debugInfo = debugInfo; diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 0af810924bb..bbecbb66356 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 90dfa9d2f30..a5b9eae6fad 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 0f334eea860..f5a4361fd41 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/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index 646735a54b7..d9de7df43d3 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -1197,35 +1197,28 @@ describe('ReactFlightDOMNode', () => { }); it('should use late-arriving I/O debug info to enhance component and owner stacks when aborting a prerender', async () => { - // This test is constructing a scenario where a framework might separate - // I/O into different phases, e.g. runtime I/O and dynamic I/O. The - // framework might choose to define an end time for the Flight client, - // indicating that all I/O info (or any debug info for that matter) that - // arrives after that time should be ignored. When rendering in Fizz is - // then aborted, the late-arriving debug info that's used to enhance the - // owner stack only includes I/O info up to that end time. - let resolveRuntimeData; - let resolveDynamicData; - - async function getRuntimeData() { + let resolveDynamicData1; + let resolveDynamicData2; + + async function getDynamicData1() { return new Promise(resolve => { - resolveRuntimeData = resolve; + resolveDynamicData1 = resolve; }); } - async function getDynamicData() { + async function getDynamicData2() { return new Promise(resolve => { - resolveDynamicData = resolve; + resolveDynamicData2 = resolve; }); } async function Dynamic() { - const runtimeData = await getRuntimeData(); - const dynamicData = await getDynamicData(); + const data1 = await getDynamicData1(); + const data2 = await getDynamicData2(); return (

- {runtimeData} {dynamicData} + {data1} {data2}

); } @@ -1242,45 +1235,40 @@ describe('ReactFlightDOMNode', () => { ); } - const stream = await ReactServerDOMServer.renderToPipeableStream( - ReactServer.createElement(App), - webpackMap, - {filterStackFrame}, - ); - + let staticEndTime = -1; const initialChunks = []; const dynamicChunks = []; - let isDynamic = false; - const passThrough = new Stream.PassThrough(streamOptions); - stream.pipe(passThrough); + await new Promise(resolve => { + setTimeout(async () => { + const stream = ReactServerDOMServer.renderToPipeableStream( + ReactServer.createElement(App), + webpackMap, + {filterStackFrame}, + ); - passThrough.on('data', chunk => { - if (isDynamic) { - dynamicChunks.push(chunk); - } else { - initialChunks.push(chunk); - } - }); + const passThrough = new Stream.PassThrough(streamOptions); + stream.pipe(passThrough); - let endTime; + passThrough.on('data', chunk => { + if (staticEndTime < 0) { + initialChunks.push(chunk); + } else { + dynamicChunks.push(chunk); + } + }); - await new Promise(resolve => { - setTimeout(() => { - resolveRuntimeData('Hi'); + passThrough.on('end', resolve); }); setTimeout(() => { - isDynamic = true; - endTime = performance.now() + performance.timeOrigin; - resolveDynamicData('Josh'); - resolve(); + staticEndTime = performance.now() + performance.timeOrigin; + resolveDynamicData1('Hi'); + setTimeout(() => { + resolveDynamicData2('Josh'); + }); }); }); - await new Promise(resolve => { - passThrough.on('end', resolve); - }); - // Create a new Readable and push all initial chunks immediately. const readable = new Stream.Readable({...streamOptions, read() {}}); for (let i = 0; i < initialChunks.length; i++) { @@ -1311,8 +1299,8 @@ describe('ReactFlightDOMNode', () => { }, { // Debug info arriving after this end time will be ignored, e.g. the - // I/O info for the dynamic data. - endTime, + // I/O info for the second dynamic data. + endTime: staticEndTime, }, ); @@ -1358,12 +1346,12 @@ describe('ReactFlightDOMNode', () => { '\n' + ' in Dynamic' + (gate(flags => flags.enableAsyncDebugInfo) - ? ' (file://ReactFlightDOMNode-test.js:1223:33)\n' + ? ' (file://ReactFlightDOMNode-test.js:1216:27)\n' : '\n') + ' in body\n' + ' in html\n' + - ' in App (file://ReactFlightDOMNode-test.js:1240:25)\n' + - ' in ClientRoot (ReactFlightDOMNode-test.js:1320:16)', + ' in App (file://ReactFlightDOMNode-test.js:1233:25)\n' + + ' in ClientRoot (ReactFlightDOMNode-test.js:1308:16)', ); } else { expect( @@ -1372,7 +1360,7 @@ describe('ReactFlightDOMNode', () => { '\n' + ' in body\n' + ' in html\n' + - ' in ClientRoot (ReactFlightDOMNode-test.js:1320:16)', + ' in ClientRoot (ReactFlightDOMNode-test.js:1308:16)', ); } @@ -1382,8 +1370,8 @@ describe('ReactFlightDOMNode', () => { normalizeCodeLocInfo(ownerStack, {preserveLocation: true}), ).toBe( '\n' + - ' in Dynamic (file://ReactFlightDOMNode-test.js:1223:33)\n' + - ' in App (file://ReactFlightDOMNode-test.js:1240:25)', + ' in Dynamic (file://ReactFlightDOMNode-test.js:1216:27)\n' + + ' in App (file://ReactFlightDOMNode-test.js:1233:25)', ); } else { expect( @@ -1391,7 +1379,7 @@ describe('ReactFlightDOMNode', () => { ).toBe( '' + '\n' + - ' in App (file://ReactFlightDOMNode-test.js:1240:25)', + ' in App (file://ReactFlightDOMNode-test.js:1233:25)', ); } } else { diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 4e08b56d114..68410612b5d 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 0cbcd61a876..f139a3fcf28 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 dd0bd8624f3..8a2e7921bdd 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 555307cef00..e9de8e6b4a4 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 9d2e73024d1..57de815d9c3 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 f652ec4047c..90e24f264d1 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 6ba56d9d54e..9d8be29c658 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);