Add fragment handles to children of FragmentInstances (#34935)

This PR adds a `unstable_reactFragments?: Set<FragmentInstance>`
property to DOM nodes that belong to a Fragment with a ref (top level
host components). This allows you to access a FragmentInstance from a
DOM node.

This is flagged behind `enableFragmentRefsInstanceHandles`.

The primary use case to unblock is reusing IntersectionObserver
instances. A fairly common practice is to cache and reuse
IntersectionObservers that share the same config, with a map of
node->callbacks to run for each entry in the IO callback. Currently this
is not possible with Fragment Ref `observeUsing` because the key in the
cache would have to be the `FragmentInstance` and you can't find it
without a handle from the node. This works now by accessing
`entry.target.fragments`.

This also opens up possibilities to use `FragmentInstance` operations in
other places, such as events. We can do
`event.target.unstable_reactFragments`, then access
`fragmentInstance.getClientRects` for example. In a future PR, we can
assign an event's `currentTarget` as the Fragment Ref for a more direct
handle when the event has been dispatched by the Fragment itself.

The first commit here implemented a handle only on observed elements.
This is awkward because there isn't a good way to document or expose
this temporary property. `element.fragments` is closer to what we would
expect from a DOM API if a standard was implemented here. And by
assigning it to all top-level nodes of a Fragment, it can be used beyond
the cached IntersectionObserver callback.

One tradeoff here is adding extra work during the creation of
FragmentInstances as well as keeping track of adding/removing nodes.
Previously we only track the Fiber on creation but here we add a
traversal which could apply to a large set of top-level host children.
The `element.unstable_reactFragments` Set can also be randomly ordered.
This commit is contained in:
Jack Pope
2025-11-03 17:51:00 -05:00
committed by GitHub
parent 67f7d47a9b
commit edd05f181b
10 changed files with 281 additions and 7 deletions

View File

@@ -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<FragmentInstanceType>,
};
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<InstanceWithFragmentHandles>(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);
}
}
}

View File

@@ -110,6 +110,53 @@ describe('FragmentRefs', () => {
await act(() => root.render(<Test />));
});
// @gate enableFragmentRefs && enableFragmentRefsInstanceHandles
it('attaches fragment handles to nodes', async () => {
const fragmentParentRef = React.createRef();
const fragmentRef = React.createRef();
function Test({show}) {
return (
<Fragment ref={fragmentParentRef}>
<Fragment ref={fragmentRef}>
<div id="childA">A</div>
<div id="childB">B</div>
</Fragment>
<div id="childC">C</div>
{show && <div id="childD">D</div>}
</Fragment>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<Test show={false} />));
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(<Test show={true} />));
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 <React.Fragment ref={fragmentRef}>{children}</React.Fragment>;
}
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 (
<div id={id} ref={divRef}>
{id}
</div>
);
}
function Test() {
return (
<>
<IntersectionObserverFragment
onIntersection={() => logIntersection('grandparent')}>
<IntersectionObserverFragment
onIntersection={() => logIntersection('parentA')}>
<div id="childA">A</div>
</IntersectionObserverFragment>
</IntersectionObserverFragment>
<IntersectionObserverFragment
onIntersection={() => logIntersection('parentB')}>
<div id="childB">B</div>
<ChildWithManualIO id="childC" />
</IntersectionObserverFragment>
</>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<Test />));
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', () => {

View File

@@ -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<FragmentInstanceType>,
};
export type Container = {
containerTag: number,
publicInstance: PublicRootInstance | null,
@@ -794,10 +798,45 @@ function collectClientRects(child: Fiber, rects: Array<DOMRect>): 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;

View File

@@ -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.

View File

@@ -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__;

View File

@@ -27,6 +27,7 @@ export const {
renameElementSymbol,
enableFragmentRefs,
enableFragmentRefsScrollIntoView,
enableFragmentRefsInstanceHandles,
} = dynamicFlags;
// The rest of the flags are static for better dead code elimination.

View File

@@ -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__;

View File

@@ -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

View File

@@ -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.

View File

@@ -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);