Add Fragment Refs to Fabric with intersection observer support (#33056)

Adds Fragment Ref support to RN through the Fabric config, starting with
`observeUsing`/`unobserveUsing`. This is mostly a copy from the
implementation on DOM, and some of it can likely be shared in the future
but keeping it separate for now and we can refactor as we add more
features.

Added a basic test with Fabric, but testing specific methods requires so
much mocking that it doesn't seem valuable here.

I built Fabric and ran on the Catalyst app internally to test with
intersection observers end to end.
This commit is contained in:
Jack Pope 2025-04-30 10:47:18 -04:00 committed by GitHub
parent fbf29ccaa3
commit 408d055a3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 159 additions and 12 deletions

View File

@ -24,6 +24,7 @@ import {
} from 'react-reconciler/src/ReactEventPriorities';
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
import {HostText} from 'react-reconciler/src/ReactWorkTags';
import {traverseFragmentInstance} from 'react-reconciler/src/ReactFiberTreeReflection';
// Modules provided by RN:
import {
@ -622,30 +623,91 @@ export function waitForCommitToBeReady(): null {
return null;
}
export type FragmentInstanceType = null;
export type FragmentInstanceType = {
_fragmentFiber: Fiber,
_observers: null | Set<IntersectionObserver>,
observeUsing: (observer: IntersectionObserver) => void,
unobserveUsing: (observer: IntersectionObserver) => void,
};
function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) {
this._fragmentFiber = fragmentFiber;
this._observers = null;
}
// $FlowFixMe[prop-missing]
FragmentInstance.prototype.observeUsing = function (
this: FragmentInstanceType,
observer: IntersectionObserver,
): void {
if (this._observers === null) {
this._observers = new Set();
}
this._observers.add(observer);
traverseFragmentInstance(this._fragmentFiber, observeChild, observer);
};
function observeChild(instance: Instance, observer: IntersectionObserver) {
const publicInstance = getPublicInstance(instance);
if (publicInstance == null) {
throw new Error('Expected to find a host node. This is a bug in React.');
}
// $FlowFixMe[incompatible-call] Element types are behind a flag in RN
observer.observe(publicInstance);
return false;
}
// $FlowFixMe[prop-missing]
FragmentInstance.prototype.unobserveUsing = function (
this: FragmentInstanceType,
observer: IntersectionObserver,
): void {
if (this._observers === null || !this._observers.has(observer)) {
if (__DEV__) {
console.error(
'You are calling unobserveUsing() with an observer that is not being observed with this fragment ' +
'instance. First attach the observer with observeUsing()',
);
}
} else {
this._observers.delete(observer);
traverseFragmentInstance(this._fragmentFiber, unobserveChild, observer);
}
};
function unobserveChild(instance: Instance, observer: IntersectionObserver) {
const publicInstance = getPublicInstance(instance);
if (publicInstance == null) {
throw new Error('Expected to find a host node. This is a bug in React.');
}
// $FlowFixMe[incompatible-call] Element types are behind a flag in RN
observer.unobserve(publicInstance);
return false;
}
export function createFragmentInstance(
fragmentFiber: Fiber,
): FragmentInstanceType {
return null;
return new (FragmentInstance: any)(fragmentFiber);
}
export function updateFragmentInstanceFiber(
fragmentFiber: Fiber,
instance: FragmentInstanceType,
): void {
// Noop
instance._fragmentFiber = fragmentFiber;
}
export function commitNewChildToFragmentInstance(
child: PublicInstance,
child: Instance,
fragmentInstance: FragmentInstanceType,
): void {
// Noop
if (fragmentInstance._observers !== null) {
fragmentInstance._observers.forEach(observer => {
observeChild(child, observer);
});
}
}
export function deleteChildFromFragmentInstance(
child: PublicInstance,
child: Instance,
fragmentInstance: FragmentInstanceType,
): void {
// Noop

View File

@ -0,0 +1,83 @@
/**
* 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 react-core
* @jest-environment node
*/
'use strict';
let React;
let ReactFabric;
let createReactNativeComponentClass;
let act;
let View;
let Text;
describe('Fabric FragmentRefs', () => {
beforeEach(() => {
jest.resetModules();
require('react-native/Libraries/ReactPrivate/InitializeNativeFabricUIManager');
React = require('react');
ReactFabric = require('react-native-renderer/fabric');
createReactNativeComponentClass =
require('react-native/Libraries/ReactPrivate/ReactNativePrivateInterface')
.ReactNativeViewConfigRegistry.register;
({act} = require('internal-test-utils'));
View = createReactNativeComponentClass('RCTView', () => ({
validAttributes: {nativeID: true},
uiViewClassName: 'RCTView',
}));
Text = createReactNativeComponentClass('RCTText', () => ({
validAttributes: {nativeID: true},
uiViewClassName: 'RCTText',
}));
});
// @gate enableFragmentRefs
it('attaches a ref to Fragment', async () => {
const fragmentRef = React.createRef();
await act(() =>
ReactFabric.render(
<View>
<React.Fragment ref={fragmentRef}>
<View>
<Text>Hi</Text>
</View>
</React.Fragment>
</View>,
11,
null,
true,
),
);
expect(fragmentRef.current).not.toBe(null);
});
// @gate enableFragmentRefs
it('accepts a ref callback', async () => {
let fragmentRef;
await act(() => {
ReactFabric.render(
<React.Fragment ref={ref => (fragmentRef = ref)}>
<View nativeID="child">
<Text>Hi</Text>
</View>
</React.Fragment>,
11,
null,
true,
);
});
expect(fragmentRef && fragmentRef._fragmentFiber).toBeTruthy();
});
});

View File

@ -345,9 +345,9 @@ export function doesFiberContain(
return false;
}
export function traverseFragmentInstance<A, B, C>(
export function traverseFragmentInstance<I, A, B, C>(
fragmentFiber: Fiber,
fn: (Instance, A, B, C) => boolean,
fn: (I, A, B, C) => boolean,
a: A,
b: B,
c: C,
@ -355,9 +355,9 @@ export function traverseFragmentInstance<A, B, C>(
traverseFragmentInstanceChildren(fragmentFiber.child, fn, a, b, c);
}
function traverseFragmentInstanceChildren<A, B, C>(
function traverseFragmentInstanceChildren<I, A, B, C>(
child: Fiber | null,
fn: (Instance, A, B, C) => boolean,
fn: (I, A, B, C) => boolean,
a: A,
b: B,
c: C,

View File

@ -28,3 +28,4 @@ export const enableSiblingPrerendering = __VARIANT__;
export const enableFastAddPropertiesInDiffing = __VARIANT__;
export const enableLazyPublicInstanceInFabric = __VARIANT__;
export const renameElementSymbol = __VARIANT__;
export const enableFragmentRefs = __VARIANT__;

View File

@ -30,6 +30,7 @@ export const {
enableFastAddPropertiesInDiffing,
enableLazyPublicInstanceInFabric,
renameElementSymbol,
enableFragmentRefs,
} = dynamicFlags;
// The rest of the flags are static for better dead code elimination.
@ -84,7 +85,6 @@ export const enableGestureTransition = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
export const enableSrcObject = false;
export const enableFragmentRefs = false;
export const ownerStackLimit = 1e4;
// Flow magic to verify the exports of this file match the original version.

View File

@ -543,5 +543,6 @@
"555": "Cannot requestFormReset() inside a startGestureTransition. There should be no side-effects associated with starting a Gesture until its Action is invoked. Move side-effects to the Action instead.",
"556": "Expected prepareToHydrateHostActivityInstance() to never be called. This error is likely caused by a bug in React. Please file an issue.",
"557": "Expected to have a hydrated activity instance. This error is likely caused by a bug in React. Please file an issue.",
"558": "Client rendering an Activity suspended it again. This is a bug in React."
"558": "Client rendering an Activity suspended it again. This is a bug in React.",
"559": "Expected to find a host node. This is a bug in React."
}