Allow passing refs where nodes are expected (#54221)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/54221

Changelog: [internal]

Small change in the Fantom API to accept refs and reduce boilerplate. See next diff for examples

Reviewed By: javache

Differential Revision: D85143177

fbshipit-source-id: fd65abcc1b107df8726fee172b284f5dc96e88a6
This commit is contained in:
Rubén Norte
2025-10-21 09:34:49 -07:00
committed by meta-codesync[bot]
parent bd004e0c12
commit e58bbbcd8e
2 changed files with 194 additions and 34 deletions
@@ -584,6 +584,31 @@ describe('Fantom', () => {
expect(focusEvent).toHaveBeenCalledTimes(1);
});
it('sends event without payload (with ref)', () => {
const root = Fantom.createRoot();
let focusEvent = jest.fn();
const ref = createRef<HostInstance>();
Fantom.runTask(() => {
root.render(<TextInput onFocus={focusEvent} ref={ref} />);
});
expect(focusEvent).toHaveBeenCalledTimes(0);
Fantom.runOnUIThread(() => {
Fantom.enqueueNativeEvent(ref, 'focus');
});
// The tasks have not run.
expect(focusEvent).toHaveBeenCalledTimes(0);
Fantom.runWorkLoop();
expect(focusEvent).toHaveBeenCalledTimes(1);
});
it('sends event with payload', () => {
const root = Fantom.createRoot();
const ref = createRef<HostInstance>();
@@ -692,6 +717,23 @@ describe('Fantom', () => {
expect(focusEvent).toHaveBeenCalledTimes(1);
});
it('flushes the event and runs the work loop (with ref)', () => {
const root = Fantom.createRoot();
const ref = createRef<HostInstance>();
let focusEvent = jest.fn();
Fantom.runTask(() => {
root.render(<TextInput onFocus={focusEvent} ref={ref} />);
});
expect(focusEvent).toHaveBeenCalledTimes(0);
Fantom.dispatchNativeEvent(ref, 'focus');
expect(focusEvent).toHaveBeenCalledTimes(1);
});
});
describe('enqueueScrollEvent', () => {
@@ -783,6 +825,36 @@ describe('Fantom', () => {
root.destroy();
});
it('delivers onScroll event (with ref)', () => {
const root = Fantom.createRoot();
const viewRef = createRef<HostInstance>();
const scrollViewRef = createRef<HostInstance>();
const onScroll = jest.fn();
Fantom.runTask(() => {
root.render(
<ScrollView
onScroll={event => {
onScroll(event.nativeEvent);
}}
ref={scrollViewRef}>
<View style={{width: 1, height: 2, top: 3}} ref={viewRef} />
</ScrollView>,
);
});
Fantom.runOnUIThread(() => {
Fantom.enqueueScrollEvent(scrollViewRef, {
x: 0,
y: 1,
});
});
Fantom.runWorkLoop();
expect(onScroll).toHaveBeenCalledTimes(1);
});
});
describe('scrollTo', () => {
@@ -872,6 +944,39 @@ describe('Fantom', () => {
root.destroy();
});
it('delivers onScroll event and affects position of elements on screen (with ref)', () => {
const root = Fantom.createRoot();
const scrollViewRef = createRef<HostInstance>();
const viewRef = createRef<HostInstance>();
const onScroll = jest.fn();
Fantom.runTask(() => {
root.render(
<ScrollView
onScroll={event => {
onScroll(event.nativeEvent);
}}
ref={scrollViewRef}>
<View style={{width: 1, height: 2, top: 3}} ref={viewRef} />
</ScrollView>,
);
});
const scrollViewElement = ensureInstance(
scrollViewRef.current,
ReactNativeElement,
);
expect(scrollViewElement.scrollTop).toBe(0);
Fantom.scrollTo(scrollViewRef, {
x: 0,
y: 1,
});
expect(scrollViewElement.scrollTop).toBe(1);
});
});
describe('flushAllNativeEvents', () => {
@@ -956,5 +1061,37 @@ describe('Fantom', () => {
expect(boundingClientRect.height).toBe(25);
expect(boundingClientRect.width).toBe(50);
});
it('change size of <Modal /> (with ref)', () => {
const root = Fantom.createRoot();
const modalNodeRef = createRef<HostInstance>();
const viewNodeRef = createRef<HostInstance>();
Fantom.runTask(() => {
root.render(
<Modal ref={modalNodeRef}>
<View style={{width: '50%', height: '25%'}} ref={viewNodeRef} />
</Modal>,
);
});
Fantom.runOnUIThread(() => {
Fantom.enqueueModalSizeUpdate(modalNodeRef, {
width: 100,
height: 100,
});
});
Fantom.runWorkLoop();
const viewElement = ensureInstance(
viewNodeRef.current,
ReactNativeElement,
);
const boundingClientRect = viewElement.getBoundingClientRect();
expect(boundingClientRect.height).toBe(25);
expect(boundingClientRect.width).toBe(50);
});
});
});
+57 -34
View File
@@ -15,7 +15,6 @@ import type {
import type {MixedElement} from 'react';
import type {RootTag} from 'react-native';
import type ReactNativeDocument from 'react-native/src/private/webapis/dom/nodes/ReactNativeDocument';
import type ReadOnlyNode from 'react-native/src/private/webapis/dom/nodes/ReadOnlyNode';
import * as Benchmark from './Benchmark';
import {getConstants} from './Constants';
@@ -25,11 +24,14 @@ import NativeFantom, {
NativeEventCategory,
} from 'react-native/src/private/testing/fantom/specs/NativeFantom';
import {getNativeNodeReference} from 'react-native/src/private/webapis/dom/nodes/internals/NodeInternals';
import ReadOnlyNode from 'react-native/src/private/webapis/dom/nodes/ReadOnlyNode';
const nativeRuntimeScheduler = global.nativeRuntimeScheduler;
const {unstable_scheduleCallback, unstable_ImmediatePriority} =
nativeRuntimeScheduler;
type NodeOrRef = ReadOnlyNode | $ReadOnly<{current: ?ReadOnlyNode}>;
export type RootConfig = {
viewportWidth?: number,
viewportHeight?: number,
@@ -218,17 +220,19 @@ export function unstable_produceFramesForDuration(milliseconds: number) {
* Note: This API is marked as unstable and may change in future versions.
*/
export function unstable_getDirectManipulationProps(
node: ReadOnlyNode,
nodeOrRef: NodeOrRef,
): $ReadOnly<{
[string]: mixed,
}> {
const node = getNode(nodeOrRef);
const shadowNode = getNativeNodeReference(node);
return NativeFantom.getDirectManipulationProps(shadowNode);
}
export function unstable_getFabricUpdateProps(node: ReadOnlyNode): $ReadOnly<{
export function unstable_getFabricUpdateProps(nodeOrRef: NodeOrRef): $ReadOnly<{
[string]: mixed,
}> {
const node = getNode(nodeOrRef);
const shadowNode = getNativeNodeReference(node);
return NativeFantom.getFabricUpdateProps(shadowNode);
}
@@ -394,11 +398,12 @@ export function createRoot(rootConfig?: RootConfig): Root {
* ```
*/
export function enqueueNativeEvent(
node: ReadOnlyNode,
nodeOrRef: NodeOrRef,
type: string,
payload?: $ReadOnly<{[key: string]: mixed}>,
options?: $ReadOnly<{category?: NativeEventCategory, isUnique?: boolean}>,
) {
const node = getNode(nodeOrRef);
const shadowNode = getNativeNodeReference(node);
NativeFantom.enqueueNativeEvent(
shadowNode,
@@ -425,11 +430,13 @@ export function enqueueNativeEvent(
* ```
*/
export function dispatchNativeEvent(
node: ReadOnlyNode,
nodeOrRef: NodeOrRef,
type: string,
payload?: $ReadOnly<{[key: string]: mixed}>,
options?: $ReadOnly<{category?: NativeEventCategory, isUnique?: boolean}>,
) {
const node = getNode(nodeOrRef);
runOnUIThread(() => {
enqueueNativeEvent(node, type, payload, options);
});
@@ -487,9 +494,10 @@ export type ScrollEventOptions = {
* ```
*/
export function enqueueScrollEvent(
node: ReadOnlyNode,
nodeOrRef: NodeOrRef,
options: ScrollEventOptions,
) {
const node = getNode(nodeOrRef);
const shadowNode = getNativeNodeReference(node);
NativeFantom.enqueueScrollEvent(shadowNode, options);
}
@@ -525,7 +533,9 @@ export function enqueueScrollEvent(
* // Assert that changes from Fantom.scrollTo are in effect.
* ```
*/
export function scrollTo(node: ReadOnlyNode, options: ScrollEventOptions) {
export function scrollTo(nodeOrRef: NodeOrRef, options: ScrollEventOptions) {
const node = getNode(nodeOrRef);
runOnUIThread(() => {
enqueueScrollEvent(node, options);
});
@@ -558,9 +568,10 @@ export function scrollTo(node: ReadOnlyNode, options: ScrollEventOptions) {
* ```
*/
export function enqueueModalSizeUpdate(
node: ReadOnlyNode,
nodeOrRef: NodeOrRef,
size: $ReadOnly<{width: number, height: number}>,
) {
const node = getNode(nodeOrRef);
const shadowNode = getNativeNodeReference(node);
NativeFantom.enqueueModalSizeUpdate(shadowNode, size.width, size.height);
}
@@ -572,28 +583,6 @@ export type {
TestOptions as BenchmarkTestOptions,
} from './Benchmark';
/**
* Quick and dirty polyfills required by tinybench.
*/
if (typeof global.Event === 'undefined') {
global.Event =
require('react-native/src/private/webapis/dom/events/Event').default;
} else {
console.warn(
'The global Event class is already defined. If this API is already defined by React Native, you might want to remove this logic.',
);
}
if (typeof global.EventTarget === 'undefined') {
global.EventTarget =
require('react-native/src/private/webapis/dom/events/EventTarget').default;
} else {
console.warn(
'The global Event class is already defined. If this API is already defined by React Native, you might want to remove this logic.',
);
}
/**
* Returns a function that returns the current reference count for the supplied
* element's shadow node. If the reference count is zero, that means the shadow
@@ -602,9 +591,10 @@ if (typeof global.EventTarget === 'undefined') {
* @param node The node for which to create a reference counting function.
*/
export function createShadowNodeReferenceCounter(
node: ReadOnlyNode,
nodeOrRef: NodeOrRef,
): () => number {
let shadowNode = getNativeNodeReference(node);
const node = getNode(nodeOrRef);
const shadowNode = getNativeNodeReference(node);
return NativeFantom.createShadowNodeReferenceCounter(shadowNode);
}
@@ -615,9 +605,10 @@ export function createShadowNodeReferenceCounter(
* @param node The node for which to create a revision getter.
*/
export function createShadowNodeRevisionGetter(
node: ReadOnlyNode,
nodeOrRef: NodeOrRef,
): () => ?number {
let shadowNode = getNativeNodeReference(node);
const node = getNode(nodeOrRef);
const shadowNode = getNativeNodeReference(node);
return NativeFantom.createShadowNodeRevisionGetter(shadowNode);
}
@@ -681,4 +672,36 @@ function runLogBoxCheck() {
}
}
function getNode(nodeOrRef: NodeOrRef): ReadOnlyNode {
if (nodeOrRef instanceof ReadOnlyNode) {
return nodeOrRef;
} else if (nodeOrRef.current != null) {
return nodeOrRef.current;
} else {
throw new TypeError('Could not get node from ref');
}
}
/**
* Quick and dirty polyfills required by tinybench.
*/
if (typeof global.Event === 'undefined') {
global.Event =
require('react-native/src/private/webapis/dom/events/Event').default;
} else {
console.warn(
'The global Event class is already defined. If this API is already defined by React Native, you might want to remove this logic.',
);
}
if (typeof global.EventTarget === 'undefined') {
global.EventTarget =
require('react-native/src/private/webapis/dom/events/EventTarget').default;
} else {
console.warn(
'The global Event class is already defined. If this API is already defined by React Native, you might want to remove this logic.',
);
}
global.__FANTOM_PACKAGE_LOADED__ = true;