Files
Rubén NorteandFacebook GitHub Bot 78f089906c Use React Native built-in definitions for Event and EventTarget in Fantom (#53362)
Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/53362

Changelog: [internal]

This just replaces the polyfills for `Event` and `EventTarget` that we're defining inline in Fantom with the implementations that already exist in RN.

Reviewed By: javache

Differential Revision: D80612067

fbshipit-source-id: 047c8f12cbb1f4afea2d05a5a1235d9dff2e25f9
2025-08-20 06:01:22 -07:00

685 lines
20 KiB
JavaScript

/**
* 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.
*
* @flow strict-local
* @format
*/
import type {
FantomRenderedOutput,
RenderOutputConfig,
} from './getFantomRenderedOutput';
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';
import getFantomRenderedOutput from './getFantomRenderedOutput';
import {LogBox} from 'react-native';
import NativeFantom, {
NativeEventCategory,
} from 'react-native/src/private/testing/fantom/specs/NativeFantom';
import {getNativeNodeReference} from 'react-native/src/private/webapis/dom/nodes/internals/NodeInternals';
const nativeRuntimeScheduler = global.nativeRuntimeScheduler;
const {unstable_scheduleCallback, unstable_ImmediatePriority} =
nativeRuntimeScheduler;
export type RootConfig = {
viewportWidth?: number,
viewportHeight?: number,
devicePixelRatio?: number,
viewportOffsetX?: number,
viewportOffsetY?: number,
};
export {getConstants} from './Constants';
// Defaults use iPhone 14 values (very common device).
const DEFAULT_VIEWPORT_WIDTH = 390;
const DEFAULT_VIEWPORT_HEIGHT = 844;
const DEFAULT_DEVICE_PIXEL_RATIO = 3;
class Root {
#surfaceId: RootTag;
#viewportWidth: number;
#viewportHeight: number;
#viewportOffsetX: number;
#viewportOffsetY: number;
#devicePixelRatio: number;
#document: ?ReactNativeDocument;
constructor(config?: RootConfig) {
this.#viewportWidth = config?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH;
this.#viewportHeight = config?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT;
this.#devicePixelRatio =
config?.devicePixelRatio ?? DEFAULT_DEVICE_PIXEL_RATIO;
this.#viewportOffsetX = config?.viewportOffsetX ?? 0;
this.#viewportOffsetY = config?.viewportOffsetY ?? 0;
this.#surfaceId = NativeFantom.startSurface(
this.#viewportWidth,
this.#viewportHeight,
this.#devicePixelRatio,
this.#viewportOffsetX,
this.#viewportOffsetY,
);
}
// $FlowExpectedError[unsafe-getters-setters]
get document(): ReactNativeDocument {
if (this.#document == null) {
throw new Error(
'Cannot get `document` from root because it has not been rendered.',
);
}
return this.#document;
}
render(element: MixedElement): void {
if (!flushingQueue) {
throw new Error(
'Unexpected call to `render` outside of the event loop. Please call `render` within a `runTask` callback.',
);
}
// Require Fabric lazily to prevent it from running InitializeCore before the test
// has a change to do its environment setup.
const ReactFabric =
require('react-native/Libraries/Renderer/shims/ReactFabric').default;
// $FlowExpectedError[incompatible-type]
const surfaceIdIsNumber = this.#surfaceId as number;
ReactFabric.render(element, surfaceIdIsNumber, null, true);
if (this.#document == null) {
this.#document =
// $FlowExpectedError[incompatible-type] We know that `getPublicInstanceFromRootTag` returns `ReactNativeDocument | null` in Fantom.
ReactFabric.getPublicInstanceFromRootTag(surfaceIdIsNumber);
}
}
takeMountingManagerLogs(): Array<string> {
return NativeFantom.takeMountingManagerLogs(this.#surfaceId);
}
destroy() {
// TODO: check for leaks.
NativeFantom.stopSurface(this.#surfaceId);
NativeFantom.flushMessageQueue();
this.#document = null;
}
getRenderedOutput(config: RenderOutputConfig = {}): FantomRenderedOutput {
return getFantomRenderedOutput(this.#surfaceId, config);
}
getRootTag(): RootTag {
return this.#surfaceId;
}
// TODO: add an API to check if all surfaces were deallocated when tests are finished.
}
export type {Root};
export {NativeEventCategory} from 'react-native/src/private/testing/fantom/specs/NativeFantom';
const DEFAULT_TASK_PRIORITY = unstable_ImmediatePriority;
/**
* Schedules a task to run on the event loop.
* If the work loop is running, it will be executed according to its priority.
* Otherwise, it will wait in the queue until the work loop runs.
*
* @param task - The task to be scheduled.
*
* @example
* ```
* Fantom.scheduleTask(() => {
* // Task to be run within React Native's scheduling.
* });
*
* // The task has not run yet.
*
* Fantom.runWorkLoop(); // Trigger work loop.
*
* // The task has been executed.
* ```
*/
export function scheduleTask(task: () => void | Promise<void>) {
unstable_scheduleCallback(DEFAULT_TASK_PRIORITY, task);
}
let flushingQueue = false;
let isLogBoxCheckEnabled = true;
/**
* Runs a task on the event loop.
* React must run inside of event loop to ensure scheduling environment is closer to production.
*
* @param task - The task to run.
*
* @example
* ```
* const root = Fantom.createRoot();
* Fantom.runTask(() => {
* root.render(<View />);
* });
* ```
*/
export function runTask(task: () => void | Promise<void>) {
if (flushingQueue) {
throw new Error(
'Nested `runTask` calls are not allowed. If you want to schedule a task from inside another task, use `scheduleTask` instead.',
);
}
scheduleTask(task);
runWorkLoop();
}
/**
* Simulates the production of animation frames for a specified duration.
* This function is useful for testing animations or time-dependent behaviors
* by advancing the animation frame timeline without waiting for real time to pass.
*
* @param milliseconds - The duration in milliseconds for which to produce animation frames
*
* @example
* ```
* // Simulate 500ms of animation frames
* Fantom.unstable_produceFramesForDuration(500);
*
* // Now you can test the state of your UI after those frames have been produced
* ```
*
* Note: This API is marked as unstable and may change in future versions.
*/
export function unstable_produceFramesForDuration(milliseconds: number) {
NativeFantom.produceFramesForDuration(milliseconds);
}
/**
* Returns props appplied via direct manipulation to a view represented by shadow node.
* Direct manipulation is used by C++ Animated to change view properties on UI tick
* while the animation is in progress. Once animation finishes, the final state is committed
* to the shadow tree and result is observable through other JavaScript APIs, like `measure`.
*
* @param node - The node for which to retrieve direct manipulation props.
* @returns Mixed type data containing the direct manipulation properties
*
* Note: This API is marked as unstable and may change in future versions.
*/
export function unstable_getDirectManipulationProps(
node: ReadOnlyNode,
): $ReadOnly<{
[string]: mixed,
}> {
const shadowNode = getNativeNodeReference(node);
return NativeFantom.getDirectManipulationProps(shadowNode);
}
export function unstable_getFabricUpdateProps(node: ReadOnlyNode): $ReadOnly<{
[string]: mixed,
}> {
const shadowNode = getNativeNodeReference(node);
return NativeFantom.getFabricUpdateProps(shadowNode);
}
/**
* Simulates running a task on the UI thread and forces side effect to drain
* the event queue, scheduling events to be dispatched to JavaScript.
* To be used when enqueuing native events.
*
* @param task - The task to run on the UI thread.
*
* @example
* ```
* Fantom.runOnUIThread(() => {
* Fantom.enqueueNativeEvent(element, 'focus');
* });
*
* // The effects of `focus` event are *not* yet observable.
*
* Fantom.runWorkLoop();
*
* // The effects of `focus` event are now observable.
* ```
*/
export function runOnUIThread(task: () => void) {
task();
NativeFantom.flushEventQueue();
}
/**
* Runs a side effect to drain the event queue and dispatches events to JavaScript.
* Useful to flash out all tasks.
*/
export function flushAllNativeEvents() {
NativeFantom.flushEventQueue();
runWorkLoop();
}
/**
* Runs the event loop until all tasks are executed.
* To be used with `Fantom.enqueueNativeEvent` and `Fantom.scheduleTask`.
*
* @example
* ```
* Fantom.scheduleTask(() => {
* // Task to be run within React Native's scheduling.
* });
*
* // The task has not run yet.
*
* Fantom.runWorkLoop();
*
* // The task has been executed.
* ```
*/
export function runWorkLoop(): void {
if (flushingQueue) {
throw new Error(
'Cannot start the work loop because it is already running. If you want to schedule a task from inside another task, use `scheduleTask` instead.',
);
}
if (__DEV__) {
// We don't want to run these checks in optimized mode
// to avoid the small performance overhead in benchmarks.
runLogBoxCheck();
}
try {
flushingQueue = true;
NativeFantom.flushMessageQueue();
} finally {
flushingQueue = false;
}
if (__DEV__) {
// We also do it after because a task might trigger the initialization of the environment that enables LogBox,
// which could be equally dangerous.
runLogBoxCheck();
}
}
/**
* Set this flag to `false` to let Fantom run tasks with LogBox installed
* (necessary only if you are testing LogBox specifically).
*
* Otherwise, it will throw an error when running its work loop,
* as LogBox would intercept all errors in tasks instead of making them throw.
*
* @example
* ```
* // In LogBox tests:
* Fantom.setLogBoxCheckEnabled(false);
* ```
*/
export function setLogBoxCheckEnabled(enabled: boolean) {
isLogBoxCheckEnabled = enabled;
}
/**
* Indicates if the current function is being executed within the Event Loop
* (as a task or microtask).
*
* @example
* ```
* Fantom.isInWorkLoop(); // false
*
* Fantom.runTask(() => {
* Fantom.isInWorkLoop(); // true
* });
*
* Fantom.isInWorkLoop(); // false
* ```
*/
export function isInWorkLoop(): boolean {
return flushingQueue;
}
/**
* Create a Root that can render a React component tree.
*
* Accepts an optional RootConfig with the following optional options:
* @param devicePixelRatio - Numeric value, defaults to 3 (iPhone 14).
* @param viewportHeight - Numeric value, defaults to 844 (iPhone 14).
* @param viewportWidth - Numeric value, defaults to 390 (iPhone 14).
*
* @example
* ```
* const root = Fantom.createRoot({
* viewportWidth: 200, // default is 390
* viewportHeight: 600, // default is 844
* devicePixelRatio: 2, // default is 3
* });
* ```
*/
export function createRoot(rootConfig?: RootConfig): Root {
return new Root(rootConfig);
}
/**
* This is a low level method to enqueue a native event to a node.
* It does not wait for it to be flushed in the UI thread or for it to be
* processed by JS.
*
* When you simply need to dispatch a native event and observe its effects, use `dispatchNativeEvent`.
*
* @param node - The node to which the event will be dispatched. You must make sure the event is appropriate for the provided node. For example, if sending a scroll event, you must make sure the node is of type <ScrollView />.
* @param type - The type of the event. e.g 'focus', 'blur', 'change', 'scroll', etc.
* @param payload - The data associated with the event. What is delivered as `event.nativeEvent` on a component.
* @param options - Object describing what priority the event is and whether it gets coalesced. For event priority, see `NativeEventCategory`.
*
* @example
* ```
* Fantom.runOnUIThread(() => {
* Fantom.enqueueNativeEvent(element, 'focus');
* });
*
* // The effects of `focus` event are *not* yet observable.
*
* Fantom.runWorkLoop();
*
* // The effects of `focus` event are observable.
* ```
*/
export function enqueueNativeEvent(
node: ReadOnlyNode,
type: string,
payload?: $ReadOnly<{[key: string]: mixed}>,
options?: $ReadOnly<{category?: NativeEventCategory, isUnique?: boolean}>,
) {
const shadowNode = getNativeNodeReference(node);
NativeFantom.enqueueNativeEvent(
shadowNode,
type,
payload,
options?.category,
options?.isUnique,
);
}
/**
* Dispatches a native event and makes sure its effects are observable after calling this method.
*
* @param node - The node to which the event will be dispatched. You must make sure the event is appropriate for the provided node. For example, if sending a scroll event, you must make sure the node is of type <ScrollView />.
* @param type - The type of the event. e.g 'focus', 'blur', 'change', 'scroll', etc.
* @param payload - The data associated with the event. What is delivered as `event.nativeEvent` on a component.
* @param options - Object describing what priority the event is and whether it gets coalesced. For event priority, see `NativeEventCategory`.
*
* @example
* ```
* Fantom.dispatchNativeEvent(element, 'focus');
*
* // The effects of `focus` are immediately observable.
* ```
*/
export function dispatchNativeEvent(
node: ReadOnlyNode,
type: string,
payload?: $ReadOnly<{[key: string]: mixed}>,
options?: $ReadOnly<{category?: NativeEventCategory, isUnique?: boolean}>,
) {
runOnUIThread(() => {
enqueueNativeEvent(node, type, payload, options);
});
if (!flushingQueue) {
runWorkLoop();
}
}
export type ScrollEventOptions = {
x: number,
y: number,
zoomScale?: number,
};
/**
* Enqueues an event to scroll a <ScrollView /> node to the given coordinates.
* It does not wait for it to be flushed in the UI thread or for it to be
* processed by JS.
*
* When you need to simply scroll a <ScrollView /> and observe effects immediately, use `Fantom.scrollTo`.
*
* @params node - A node to be scrolled. Must be of type <ScrollView />.
* @params options - Object describing the scroll position and zoom level. See `ScrollEventOptions` for more details.
*
* @example
* ```
* const root = Fantom.createRoot();
* let maybeScrollViewNode;
*
* Fantom.runTask(() => {
* root.render(
* <ScrollView
* ref={node => {
* maybeScrollViewNode = node;
* }} />
* <ScrollViewContent />
* </ScrollView>,
* );
* });
*
* const element = ensureInstance(maybeScrollViewNode, ReactNativeElement);
*
* Fantom.runOnUIThread(() => {
* Fantom.enqueueScrollEvent(element, {
* x: 20,
* y: 10,
* });
*
* // The changes from scroll event are *not* yet observable.
*
* Fantom.runWorkLoop();
*
* // The changes from scroll event are observable.
* ```
*/
export function enqueueScrollEvent(
node: ReadOnlyNode,
options: ScrollEventOptions,
) {
const shadowNode = getNativeNodeReference(node);
NativeFantom.enqueueScrollEvent(shadowNode, options);
}
/**
* Scrolls the specified ScrollView node to the given coordinates on the UI thread.
* The call is immediately observable unlike `Fantom.enqueueScrollEvent` where the
* event is queued and not processed.
*
* @params node - A node to be scrolled. Must be of type <ScrollView />.
* @params options - Object describing the scroll position and zoom level. See `ScrollEventOptions` for more details.
*
* @example
* ```
* const root = Fantom.createRoot();
* let maybeScrollViewNode;
*
* Fantom.runTask(() => {
* root.render(
* <ScrollView
* ref={node => {
* maybeScrollViewNode = node;
* }} />
* <ScrollViewContent />
* </ScrollView>,
* );
* });
*
* const element = ensureInstance(maybeScrollViewNode, ReactNativeElement);
*
* Fantom.scrollTo(element, {x: 0, y: 20});
*
* // Assert that changes from Fantom.scrollTo are in effect.
* ```
*/
export function scrollTo(node: ReadOnlyNode, options: ScrollEventOptions) {
runOnUIThread(() => {
enqueueScrollEvent(node, options);
});
runWorkLoop();
}
/**
* Enqueues modal size update for a given node.
* It does not wait for it to be processed and effects are not observable after calling this method.
* Can only be called on <Modal />.
*
* @params node - A node to have its size updated. Must be of type <Modal />.
* @params size - New size for the node. This would typically be screen size.
*
* @example
* ```
* Fantom.runOnUIThread(() => {
* Fantom.enqueueModalSizeUpdate(modalElement, {
* width: 100,
* height: 100,
* });
* });
*
* // The effects of `enqueueModalSizeUpdate` are *not* yet observable.
*
* Fantom.runWorkLoop();
*
* // The effects of `enqueueModalSizeUpdate` are yet observable.
* ```
*/
export function enqueueModalSizeUpdate(
node: ReadOnlyNode,
size: $ReadOnly<{width: number, height: number}>,
) {
const shadowNode = getNativeNodeReference(node);
NativeFantom.enqueueModalSizeUpdate(shadowNode, size.width, size.height);
}
export const unstable_benchmark = Benchmark;
export type {
SuiteOptions as BenchmarkSuiteOptions,
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
* node has been deallocated.
*
* @param node The node for which to create a reference counting function.
*/
export function createShadowNodeReferenceCounter(
node: ReadOnlyNode,
): () => number {
let shadowNode = getNativeNodeReference(node);
return NativeFantom.createShadowNodeReferenceCounter(shadowNode);
}
/**
* Returns a function that returns the current revision number for the supplied
* element's shadow node.
*
* @param node The node for which to create a revision getter.
*/
export function createShadowNodeRevisionGetter(
node: ReadOnlyNode,
): () => ?number {
let shadowNode = getNativeNodeReference(node);
return NativeFantom.createShadowNodeRevisionGetter(shadowNode);
}
/**
* Saves a heap snapshot after forcing garbage collection.
*
* It prints the location of the saved snapshot file, which can be opened using
* the "Memory" pane in Chrome DevTools.
*/
export function takeJSMemoryHeapSnapshot(): void {
const constants = getConstants();
if (constants.isRunningFromCI) {
throw new Error('Unexpected call to `saveJSMemoryHeapSnapshot` from CI');
}
const filePath = constants.jsHeapSnapshotOutputPathTemplate.replace(
constants.jsHeapSnapshotOutputPathTemplateToken,
new Date().toISOString(),
);
try {
NativeFantom.saveJSMemoryHeapSnapshot(filePath);
} catch (nativeError: mixed) {
let errorMessage = 'Error saving JS heap snapshot.';
if (
nativeError instanceof Error &&
nativeError.message.includes(
"Cannot create heap snapshots if Hermes isn't built with memory instrumentation.",
)
) {
// We would generally use an error with nativeError as cause, but our infra
// doesn't support that yet (it expects a `cause` property on the error with
// a very specific shape).
errorMessage +=
' If you want to take JS heap snapshots in optimized builds, ' +
'please call Fantom with FANTOM_ENABLE_MEMORY_INSTRUMENTATION=1 ' +
'(only works locally with Buck).';
}
throw new Error(errorMessage, {cause: nativeError});
}
console.info(`💾 JS heap snapshot saved to ${filePath}\n`);
}
export * from './HighResTimeStampMock';
function runLogBoxCheck() {
if (isLogBoxCheckEnabled && LogBox.isInstalled()) {
const message =
'Cannot run work loop while LogBox is installed, as LogBox intercepts errors thrown in tests.' +
' If you are installing LogBox unintentionally using `InitializeCore`, replace it with `@react-native/fantom/src/setUpDefaultReactNativeEnvironment` to avoid this problem.';
// This is will go through even if throwing doesn't.
console.error(message);
// Throwing here won't re-throw in the test if LogBox is enabled,
// but will hopefully fail it for some other reason.
throw new Error(message);
}
}
global.__FANTOM_PACKAGE_LOADED__ = true;