From 4e25b28e7fa3f1da03c6eb9e7bd8a7d3ae3b343b Mon Sep 17 00:00:00 2001 From: Samuel Susla Date: Thu, 23 Jan 2025 02:18:50 -0800 Subject: [PATCH] add event category to Fantom.dispatchNativeEvent (#48855) Summary: changelog: [internal] add event category argument to Fantom.dispatchNativeEvent. This gives tests option to control whether an event is continuous, discrete etc. Reviewed By: rubennorte Differential Revision: D68413879 --- packages/react-native-fantom/src/index.js | 14 +- .../__tests__/InterruptibleRendering-itest.js | 259 ++++++++++++++++++ .../__snapshots__/public-api-test.js.snap | 10 +- .../src/private/specs/modules/NativeFantom.js | 34 +++ 4 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 packages/react-native/Libraries/ReactNative/__tests__/InterruptibleRendering-itest.js diff --git a/packages/react-native-fantom/src/index.js b/packages/react-native-fantom/src/index.js index eba4a0726313..2ade04c6b61c 100644 --- a/packages/react-native-fantom/src/index.js +++ b/packages/react-native-fantom/src/index.js @@ -19,7 +19,9 @@ import {getShadowNode} from '../../react-native/src/private/webapis/dom/nodes/Re import * as Benchmark from './Benchmark'; import getFantomRenderedOutput from './getFantomRenderedOutput'; import ReactFabric from 'react-native/Libraries/Renderer/shims/ReactFabric'; -import NativeFantom from 'react-native/src/private/specs/modules/NativeFantom'; +import NativeFantom, { + NativeEventCategory, +} from 'react-native/src/private/specs/modules/NativeFantom'; let globalSurfaceIdCounter = 1; @@ -153,9 +155,17 @@ export function dispatchNativeEvent( node: ReactNativeElement, type: string, payload?: {[key: string]: mixed}, + options?: { + category?: NativeEventCategory, + }, ) { const shadowNode = getShadowNode(node); - NativeFantom.dispatchNativeEvent(shadowNode, type, payload); + NativeFantom.dispatchNativeEvent( + shadowNode, + type, + payload, + options?.category, + ); } export const unstable_benchmark = Benchmark; diff --git a/packages/react-native/Libraries/ReactNative/__tests__/InterruptibleRendering-itest.js b/packages/react-native/Libraries/ReactNative/__tests__/InterruptibleRendering-itest.js new file mode 100644 index 000000000000..19edf96e4bfb --- /dev/null +++ b/packages/react-native/Libraries/ReactNative/__tests__/InterruptibleRendering-itest.js @@ -0,0 +1,259 @@ +/** + * 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 + * @oncall react_native + * @fantom_flags enableAccessToHostTreeInFabric:true + */ + +import {NativeEventCategory} from '../../../src/private/specs/modules/NativeFantom'; +import ensureInstance from '../../../src/private/utilities/ensureInstance'; +import ReactNativeElement from '../../../src/private/webapis/dom/nodes/ReactNativeElement'; +import TextInput from '../../Components/TextInput/TextInput'; +import Text from '../../Text/Text'; +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {startTransition, useDeferredValue, useEffect, useState} from 'react'; + +function ensureReactNativeElement(value: mixed): ReactNativeElement { + return ensureInstance(value, ReactNativeElement); +} + +describe('discrete event category', () => { + it('interrupts React rendering and higher priority update is committed first', () => { + const root = Fantom.createRoot(); + let maybeTextInputNode; + let importantTextNode; + let deferredTextNode; + let interruptRendering = false; + let effectMock = jest.fn(); + let afterUpdate; + + function App(props: {text: string}) { + const [text, setText] = useState('initial text'); + + let deferredText = useDeferredValue(props.text); + + if (interruptRendering) { + interruptRendering = false; + const element = ensureReactNativeElement(maybeTextInputNode); + Fantom.runOnUIThread(() => { + Fantom.dispatchNativeEvent( + element, + 'change', + { + text: 'update from native', + }, + { + category: NativeEventCategory.Discrete, + }, + ); + }); + // We must schedule a task that is run right after the above native event is + // processed to be able to observe the results of rendering. + Fantom.scheduleTask(afterUpdate); + } + + useEffect(() => { + effectMock({text, deferredText}); + }, [text, deferredText]); + + return ( + <> + { + maybeTextInputNode = node; + }} + /> + { + importantTextNode = node; + }}> + Important text: {text} + + { + deferredTextNode = node; + }}> + Deferred text: {deferredText} + + + ); + } + + Fantom.runTask(() => { + root.render(); + }); + + const importantTextNativeElement = + ensureReactNativeElement(importantTextNode); + const deferredTextNativeElement = + ensureReactNativeElement(deferredTextNode); + + expect(importantTextNativeElement.textContent).toBe( + 'Important text: initial text', + ); + expect(deferredTextNativeElement.textContent).toBe( + 'Deferred text: first render', + ); + + interruptRendering = true; + + let isImportantTextUpdatedBeforeDeferred = false; + + afterUpdate = () => { + isImportantTextUpdatedBeforeDeferred = + importantTextNativeElement.textContent === + 'Important text: update from native' && + deferredTextNativeElement.textContent === 'Deferred text: first render'; + }; + + Fantom.runTask(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(isImportantTextUpdatedBeforeDeferred).toBe(true); + + expect(effectMock).toHaveBeenCalledTimes(3); + expect(effectMock.mock.calls[0][0]).toEqual({ + text: 'initial text', + deferredText: 'first render', + }); + expect(effectMock.mock.calls[1][0]).toEqual({ + text: 'update from native', + deferredText: 'first render', + }); + expect(effectMock.mock.calls[2][0]).toEqual({ + text: 'update from native', + deferredText: 'transition', + }); + expect(importantTextNativeElement.textContent).toBe( + 'Important text: update from native', + ); + expect(deferredTextNativeElement.textContent).toBe( + 'Deferred text: transition', + ); + + root.destroy(); + }); +}); + +describe('continuous event category', () => { + it('interrupts React rendering but update from continous event is delayed', () => { + const root = Fantom.createRoot(); + let maybeTextInputNode; + let importantTextNode; + let deferredTextNode; + let interruptRendering = false; + let effectMock = jest.fn(); + + function App(props: {text: string}) { + const [text, setText] = useState('initial text'); + + let deferredText = useDeferredValue(props.text); + + if (interruptRendering) { + interruptRendering = false; + const element = ensureReactNativeElement(maybeTextInputNode); + Fantom.runOnUIThread(() => { + Fantom.dispatchNativeEvent( + element, + 'selectionChange', + { + selection: { + start: 1, + end: 5, + }, + }, + { + category: NativeEventCategory.Continuous, + }, + ); + }); + } + useEffect(() => { + effectMock({text, deferredText}); + }, [text, deferredText]); + + return ( + <> + { + setText( + `start: ${event.nativeEvent.selection.start}, end: ${event.nativeEvent.selection.end}`, + ); + }} + ref={node => { + maybeTextInputNode = node; + }} + /> + { + importantTextNode = node; + }}> + Important text: {text} + + { + deferredTextNode = node; + }}> + Deferred text: {deferredText} + + + ); + } + + Fantom.runTask(() => { + root.render(); + }); + + const importantTextNativeElement = + ensureReactNativeElement(importantTextNode); + const deferredTextNativeElement = + ensureReactNativeElement(deferredTextNode); + + expect(importantTextNativeElement.textContent).toBe( + 'Important text: initial text', + ); + expect(deferredTextNativeElement.textContent).toBe( + 'Deferred text: first render', + ); + + interruptRendering = true; + + Fantom.runTask(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(effectMock).toHaveBeenCalledTimes(3); + expect(effectMock.mock.calls[0][0]).toEqual({ + text: 'initial text', + deferredText: 'first render', + }); + expect(effectMock.mock.calls[1][0]).toEqual({ + text: 'initial text', + deferredText: 'transition', + }); + expect(effectMock.mock.calls[2][0]).toEqual({ + text: 'start: 1, end: 5', + deferredText: 'transition', + }); + expect(importantTextNativeElement.textContent).toBe( + 'Important text: start: 1, end: 5', + ); + expect(deferredTextNativeElement.textContent).toBe( + 'Deferred text: transition', + ); + + root.destroy(); + }); +}); diff --git a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index 3279c8492e3c..228509ffd56a 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -10463,6 +10463,13 @@ exports[`public API should not change unintentionally src/private/specs/modules/ includeRoot: boolean, includeLayoutMetrics: boolean, }; +export enum NativeEventCategory { + ContinuousStart = 0, + ContinuousEnd = 1, + Unspecified = 2, + Discrete = 3, + Continuous = 4, +} interface Spec extends TurboModule { startSurface: ( surfaceId: number, @@ -10474,7 +10481,8 @@ interface Spec extends TurboModule { dispatchNativeEvent: ( shadowNode: mixed, type: string, - payload?: mixed + payload?: mixed, + category?: NativeEventCategory ) => void; getMountingManagerLogs: (surfaceId: number) => Array; flushMessageQueue: () => void; diff --git a/packages/react-native/src/private/specs/modules/NativeFantom.js b/packages/react-native/src/private/specs/modules/NativeFantom.js index d5a724d58c58..d2772110403d 100644 --- a/packages/react-native/src/private/specs/modules/NativeFantom.js +++ b/packages/react-native/src/private/specs/modules/NativeFantom.js @@ -18,6 +18,39 @@ export type RenderFormatOptions = { includeLayoutMetrics: boolean, }; +// match RawEvent.h +export enum NativeEventCategory { + /* + * Start of a continuous event. To be used with touchStart. + */ + ContinuousStart = 0, + + /* + * End of a continuous event. To be used with touchEnd. + */ + ContinuousEnd = 1, + + /* + * Priority for this event will be determined from other events in the + * queue. If it is triggered by continuous event, its priority will be + * default. If it is not triggered by continuous event, its priority will be + * discrete. + */ + Unspecified = 2, + + /* + * Forces discrete type for the event. Regardless if continuous event is + * ongoing. + */ + Discrete = 3, + + /* + * Forces continuous type for the event. Regardless if continuous event + * isn't ongoing. + */ + Continuous = 4, +} + interface Spec extends TurboModule { startSurface: ( surfaceId: number, @@ -30,6 +63,7 @@ interface Spec extends TurboModule { shadowNode: mixed /* ShadowNode */, type: string, payload?: mixed, + category?: NativeEventCategory, ) => void; getMountingManagerLogs: (surfaceId: number) => Array; flushMessageQueue: () => void;