From 65ffc588fb59c9fbd3f3dccfc99fff975f4a7767 Mon Sep 17 00:00:00 2001 From: Samuel Susla Date: Thu, 13 Mar 2025 07:42:32 -0700 Subject: [PATCH] introduce convenience method Fantom.scrollTo (#50011) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/50011 changelog: [internal] Majority of time, you want to simply scroll. This diff introduces a convenience method Fantom.scrollTo. Previously, you would have to write: ``` Fantom.runOnUIThread(() => { Fantom.enqueueScrollEvent(element, { x: 0, y: 60, }); }); Fantom.runWorkLoop(); ``` Now, you can just do: ``` Fantom.scrollTo(element, { x: 0, y: 60, }); ``` Reviewed By: rubennorte Differential Revision: D71113680 fbshipit-source-id: aed49a2f12be8ab30be549235cc7a2b3e35faadb --- .../src/__tests__/Fantom-itest.js | 100 ++++++++++ packages/react-native-fantom/src/index.js | 45 ++++- .../__tests__/ScrollView-viewCulling-itest.js | 173 ++++++------------ 3 files changed, 202 insertions(+), 116 deletions(-) diff --git a/packages/react-native-fantom/src/__tests__/Fantom-itest.js b/packages/react-native-fantom/src/__tests__/Fantom-itest.js index c6bee4bab81..9375af4568b 100644 --- a/packages/react-native-fantom/src/__tests__/Fantom-itest.js +++ b/packages/react-native-fantom/src/__tests__/Fantom-itest.js @@ -638,6 +638,106 @@ describe('Fantom', () => { }); }); + describe('scrollTo', () => { + it('throws error if called on node that is not scroll view', () => { + const root = Fantom.createRoot(); + let maybeNode; + + Fantom.runTask(() => { + root.render( + { + maybeNode = node; + }} + />, + ); + }); + + const element = ensureInstance(maybeNode, ReactNativeElement); + + expect(() => { + Fantom.runOnUIThread(() => { + Fantom.enqueueScrollEvent(element, { + x: 0, + y: 1, + }); + }); + }).toThrow( + 'Exception in HostFunction: enqueueScrollEvent() can only be called on ', + ); + }); + + it('delivers onScroll event and affects position of elements on screen', () => { + const root = Fantom.createRoot(); + let maybeScrollViewNode; + let maybeNode; + const onScroll = jest.fn(); + + Fantom.runTask(() => { + root.render( + { + onScroll(event.nativeEvent); + }} + ref={node => { + maybeScrollViewNode = node; + }}> + { + maybeNode = node; + }} + /> + , + ); + }); + + const scrollViewElement = ensureInstance( + maybeScrollViewNode, + ReactNativeElement, + ); + + Fantom.scrollTo(scrollViewElement, { + x: 0, + y: 1, + }); + + expect(onScroll).toHaveBeenCalledTimes(1); + + const viewElement = ensureInstance(maybeNode, ReactNativeElement); + + let rect; + + viewElement.measure((x, y, width, height, pageX, pageY) => { + rect = { + x, + y, + width, + height, + pageX, + pageY, + }; + }); + + expect(rect).toEqual({ + x: 0, + y: 3, + width: 1, + height: 2, + pageY: 2, + pageX: 0, + }); + + const boundingClientRect = viewElement.getBoundingClientRect(); + expect(boundingClientRect.x).toBe(0); + expect(boundingClientRect.y).toBe(2); + expect(boundingClientRect.width).toBe(1); + expect(boundingClientRect.height).toBe(2); + + root.destroy(); + }); + }); + describe('flushAllNativeEvents', () => { it('calls events in the event queue', () => { const root = Fantom.createRoot(); diff --git a/packages/react-native-fantom/src/index.js b/packages/react-native-fantom/src/index.js index 8bf263234bd..d263525d95d 100644 --- a/packages/react-native-fantom/src/index.js +++ b/packages/react-native-fantom/src/index.js @@ -229,14 +229,56 @@ function dispatchNativeEvent( runWorkLoop(); } +export type ScrollEventOptions = { + x: number, + y: number, + zoomScale?: number, +}; + function enqueueScrollEvent( node: ReactNativeElement, - options: {x: number, y: number, zoomScale?: number}, + 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. + * + * @example + * ``` + * const root = Fantom.createRoot(); + * let maybeScrollViewNode; + * + * Fantom.runTask(() => { + * root.render( + * { + * maybeScrollViewNode = node; + * }} /> + * + * , + * ); + * }); + * + * const element = ensureInstance(maybeScrollViewNode, ReactNativeElement); + * + * Fantom.scrollTo(element, {x: 0, y: 20}); + * + * // Assert that changes from Fantom.scrollTo are in effect. + * ``` + */ +function scrollTo(node: ReactNativeElement, options: ScrollEventOptions) { + runOnUIThread(() => { + enqueueScrollEvent(node, options); + }); + + runWorkLoop(); +} + function enqueueModalSizeUpdate( node: ReactNativeElement, size: {width: number, height: number}, @@ -347,5 +389,6 @@ export default { enqueueModalSizeUpdate, unstable_benchmark: Benchmark, enqueueScrollEvent, + scrollTo, saveJSMemoryHeapSnapshot, }; diff --git a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-viewCulling-itest.js b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-viewCulling-itest.js index 9e52984c3fa..396db5aab11 100644 --- a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-viewCulling-itest.js +++ b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-viewCulling-itest.js @@ -49,13 +49,10 @@ test('basic culling', () => { const element = ensureInstance(maybeNode, ReactNativeElement); - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 60, - }); + Fantom.scrollTo(element, { + x: 0, + y: 60, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child"}', @@ -65,13 +62,10 @@ test('basic culling', () => { 'Update {type: "ScrollView", nativeID: (N/A)}', ]); - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 0, - }); + Fantom.scrollTo(element, { + x: 0, + y: 0, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', @@ -132,26 +126,21 @@ test('recursive culling', () => { const element = ensureInstance(maybeNode, ReactNativeElement); // === Scroll down to the edge of child AA === - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 30, - }); + Fantom.scrollTo(element, { + x: 0, + y: 30, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', ]); // === Scroll down past child AA === - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 36, - }); + + Fantom.scrollTo(element, { + x: 0, + y: 36, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', @@ -160,13 +149,10 @@ test('recursive culling', () => { ]); // === Scroll down past child AB === - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 51, - }); + Fantom.scrollTo(element, { + x: 0, + y: 51, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', @@ -175,13 +161,10 @@ test('recursive culling', () => { ]); // === Scroll down past element A === - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 56, - }); + Fantom.scrollTo(element, { + x: 0, + y: 56, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', @@ -190,13 +173,10 @@ test('recursive culling', () => { ]); // Scroll element B into viewport. Just child BA should be created. - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 155, - }); + Fantom.scrollTo(element, { + x: 0, + y: 155, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', @@ -207,13 +187,10 @@ test('recursive culling', () => { ]); // Scroll child BA into viewport. - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 165, - }); + Fantom.scrollTo(element, { + x: 0, + y: 165, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', @@ -222,13 +199,10 @@ test('recursive culling', () => { ]); // Scroll back to start - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 0, - }); + Fantom.scrollTo(element, { + x: 0, + y: 0, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', @@ -247,13 +221,10 @@ test('recursive culling', () => { ]); // Scroll past element A - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 85, - }); + Fantom.scrollTo(element, { + x: 0, + y: 85, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', @@ -303,13 +274,10 @@ test('recursive culling when initial offset is negative', () => { const element = ensureInstance(maybeNode, ReactNativeElement); - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 0, - }); + Fantom.scrollTo(element, { + x: 0, + y: 0, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', @@ -373,13 +341,10 @@ test('deep nesting', () => { const element = ensureInstance(maybeNode, ReactNativeElement); - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 40, - }); + Fantom.scrollTo(element, { + x: 0, + y: 40, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', @@ -391,13 +356,10 @@ test('deep nesting', () => { 'Insert {type: "View", parentNativeID: (N/A), index: 1, nativeID: "element B"}', ]); - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 150, - }); + Fantom.scrollTo(element, { + x: 0, + y: 150, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', @@ -542,13 +504,10 @@ test('initial render', () => { const element = ensureInstance(maybeNode, ReactNativeElement); - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 100, - }); + Fantom.scrollTo(element, { + x: 0, + y: 100, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', @@ -621,13 +580,10 @@ test('basic culling smaller ScrollView', () => { const element = ensureInstance(maybeNode, ReactNativeElement); - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 11, - }); + Fantom.scrollTo(element, { + x: 0, + y: 11, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "element 1"}', @@ -691,13 +647,10 @@ test('culling with transform move', () => { const element = ensureInstance(maybeNode, ReactNativeElement); - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 1, - }); + Fantom.scrollTo(element, { + x: 0, + y: 1, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', @@ -743,13 +696,10 @@ test('culling with recursive transform move', () => { const element = ensureInstance(maybeNode, ReactNativeElement); - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 1, - }); + Fantom.scrollTo(element, { + x: 0, + y: 1, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', @@ -794,13 +744,10 @@ test('culling with transform scale', () => { const element = ensureInstance(maybeNode, ReactNativeElement); - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 121, - }); + Fantom.scrollTo(element, { + x: 0, + y: 121, }); - Fantom.runWorkLoop(); expect(root.takeMountingManagerLogs()).toEqual([ 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child"}', @@ -874,15 +821,11 @@ test('view flattening with culling', () => { const element = ensureInstance(maybeNode, ReactNativeElement); - Fantom.runOnUIThread(() => { - Fantom.enqueueScrollEvent(element, { - x: 0, - y: 60, - }); + Fantom.scrollTo(element, { + x: 0, + y: 60, }); - Fantom.runWorkLoop(); - expect(root.takeMountingManagerLogs()).toEqual([ 'Update {type: "ScrollView", nativeID: (N/A)}', 'Create {type: "View", nativeID: "child"}',