From 53b1f69ba6c08083e4934373d3f6f2e31b232874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Thu, 9 Feb 2023 18:51:47 +0000 Subject: [PATCH] Implement unstable_getBoundingClientRect in RN Fabric refs (#26137) We're fixing the timing of layout and passive effects in React Native, and adding support for some Web APIs so common use cases for those effects can be implemented with the same code on React and React Native. Let's take this example: ```javascript function MyComponent(props) { const viewRef = useRef(); useLayoutEffect(() => { const rect = viewRef.current?.getBoundingClientRect(); console.log('My view is located at', rect?.toJSON()); }, []); return {props.children}; } ``` This could would work as expected on Web (ignoring the use of `View` and assuming something like `div`) but not on React Native because: 1. Layout is done asynchronously in a background thread in parallel with the execution of layout and passive effects. This is incorrect and it's being fixed in React Native (see https://github.com/facebook/react-native/commit/afec07aca273503b0647dbf1f73c518c6e52e8ba). 2. We don't have an API to access layout information synchronously. The existing `ref.current.measureInWindow` uses callbacks to pass the result. That is asynchronous at the moment in Paper (the legacy renderer in React Native), but it's actually synchronous in Fabric (the new React Native renderer). This fixes point 2) by adding a Web-compatible method to access layout information (on Fabric only). This has 2 dependencies in React Native: 1. Access to `getBoundingClientRect` in Fabric, which was added in https://github.com/facebook/react-native/blob/main/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp#L644- L676 2. Access to `DOMRect`, which was added in https://github.com/facebook/react-native/commit/673c7617bcf90a892a0afc2c0d9cf9c0493fdf27 . As next step, I'll modify the implementation of this and other methods in Fabric to warn when they're accessed during render. We can't do this on Web because we can't (shouldn't) modify built-in DOM APIs, but we can do it in React Native because the refs objects are built by the framework. --- .../src/ReactFabricHostConfig.js | 15 ++++++++++ .../InitializeNativeFabricUIManager.js | 29 +++++++++++++++++++ .../ReactFabricHostComponent-test.internal.js | 27 +++++++++++++++++ scripts/flow/react-native-host-hooks.js | 8 +++++ 4 files changed, 79 insertions(+) diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 7f98be1c9f37b..cbd6e4937e6d3 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -56,6 +56,7 @@ const { unstable_DiscreteEventPriority: FabricDiscretePriority, unstable_getCurrentEventPriority: fabricGetCurrentEventPriority, setNativeProps, + getBoundingClientRect: fabricGetBoundingClientRect, } = nativeFabricUIManager; const {get: getViewConfigForType} = ReactNativeViewConfigRegistry; @@ -210,6 +211,20 @@ class ReactFabricHostComponent { } } + unstable_getBoundingClientRect(): DOMRect { + const {stateNode} = this._internalInstanceHandle; + if (stateNode != null) { + const rect = fabricGetBoundingClientRect(stateNode.node); + + if (rect) { + return new DOMRect(rect[0], rect[1], rect[2], rect[3]); + } + } + + // Empty rect if any of the above failed + return new DOMRect(0, 0, 0, 0); + } + setNativeProps(nativeProps: Object) { if (__DEV__) { warnForStyleProps(nativeProps, this.viewConfig.validAttributes); diff --git a/packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/InitializeNativeFabricUIManager.js b/packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/InitializeNativeFabricUIManager.js index 72f9fa822f493..33dccc1e8fb56 100644 --- a/packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/InitializeNativeFabricUIManager.js +++ b/packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/InitializeNativeFabricUIManager.js @@ -149,6 +149,19 @@ const RCTFabricUIManager = { callback(10, 10, 100, 100); }), + getBoundingClientRect: jest.fn(function getBoundingClientRect(node) { + if (typeof node !== 'object') { + throw new Error( + `Expected node to be an object, was passed "${typeof node}"`, + ); + } + + if (typeof node.viewName !== 'string') { + throw new Error('Expected node to be a host node.'); + } + + return [10, 10, 100, 100]; + }), measureLayout: jest.fn(function measureLayout( node, relativeNode, @@ -181,3 +194,19 @@ const RCTFabricUIManager = { }; global.nativeFabricUIManager = RCTFabricUIManager; + +// DOMRect isn't provided by jsdom, but it's used by `ReactFabricHostComponent`. +// This is a basic implementation for testing. +global.DOMRect = class DOMRect { + constructor(x, y, width, height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + toJSON() { + const {x, y, width, height} = this; + return {x, y, width, height}; + } +}; diff --git a/packages/react-native-renderer/src/__tests__/ReactFabricHostComponent-test.internal.js b/packages/react-native-renderer/src/__tests__/ReactFabricHostComponent-test.internal.js index dcbd07db69108..cace77f902482 100644 --- a/packages/react-native-renderer/src/__tests__/ReactFabricHostComponent-test.internal.js +++ b/packages/react-native-renderer/src/__tests__/ReactFabricHostComponent-test.internal.js @@ -200,6 +200,33 @@ describe('measureLayout', () => { }); }); +describe('unstable_getBoundingClientRect', () => { + test('component.unstable_getBoundingClientRect() returns DOMRect', () => { + const [[fooRef]] = mockRenderKeys([['foo']]); + + const rect = fooRef.unstable_getBoundingClientRect(); + + expect(nativeFabricUIManager.getBoundingClientRect).toHaveBeenCalledTimes( + 1, + ); + expect(rect.toJSON()).toMatchObject({ + x: 10, + y: 10, + width: 100, + height: 100, + }); + }); + + test('unmounted.unstable_getBoundingClientRect() returns empty DOMRect', () => { + const [[fooRef]] = mockRenderKeys([['foo'], null]); + + const rect = fooRef.unstable_getBoundingClientRect(); + + expect(nativeFabricUIManager.getBoundingClientRect).not.toHaveBeenCalled(); + expect(rect.toJSON()).toMatchObject({x: 0, y: 0, width: 0, height: 0}); + }); +}); + describe('setNativeProps', () => { test('setNativeProps(...) invokes setNativeProps on Fabric UIManager', () => { const { diff --git a/scripts/flow/react-native-host-hooks.js b/scripts/flow/react-native-host-hooks.js index e3c98114935fc..5fcee3b57da7c 100644 --- a/scripts/flow/react-native-host-hooks.js +++ b/scripts/flow/react-native-host-hooks.js @@ -201,6 +201,14 @@ declare var nativeFabricUIManager: { onFail: () => void, onSuccess: __MeasureLayoutOnSuccessCallback, ) => void, + getBoundingClientRect: ( + node: Node, + ) => [ + /* x:*/ number, + /* y:*/ number, + /* width:*/ number, + /* height:*/ number, + ], findNodeAtPoint: ( node: Node, locationX: number,