diff --git a/packages/react-native/Libraries/Components/View/View.js b/packages/react-native/Libraries/Components/View/View.js index e9d370ffc5b1..5e5660c913f5 100644 --- a/packages/react-native/Libraries/Components/View/View.js +++ b/packages/react-native/Libraries/Components/View/View.js @@ -39,6 +39,66 @@ export type ViewInstance = HostInstance; component View(ref?: React.RefSetter, ...props: ViewProps) { const hasTextAncestor = use(TextAncestorContext); + // Fast path: most views use none of the props that need transforming, so + // skip copying and rewriting their props. + let resolvedProps = needsPropTransforms(props) + ? transformProps(props) + : props; + + if (__DEV__) { + const onSafeAreaInsetsChange = + resolvedProps.experimental_onSafeAreaInsetsChange; + if (onSafeAreaInsetsChange != null) { + // Copy rather than assign: on the fast path this is the incoming props. + resolvedProps = { + ...resolvedProps, + experimental_onSafeAreaInsetsChange: + warnOnRepeatedSafeAreaInsetsChanges(onSafeAreaInsetsChange), + }; + } + } + + const actualView = + ref == null ? ( + + ) : ( + + ); + + if (hasTextAncestor) { + return ( + {actualView} + ); + } + return actualView; +} + +// Must check every prop that `transformProps` reads. Compares against +// `undefined` rather than `null` so that `null` values still go through +// `transformProps`, which strips them. +function needsPropTransforms(props: ViewProps): boolean { + return ( + props.accessibilityState !== undefined || + props.accessibilityValue !== undefined || + props['aria-busy'] !== undefined || + props['aria-checked'] !== undefined || + props['aria-disabled'] !== undefined || + props['aria-expanded'] !== undefined || + props['aria-hidden'] !== undefined || + props['aria-label'] !== undefined || + props['aria-labelledby'] !== undefined || + props['aria-live'] !== undefined || + props['aria-selected'] !== undefined || + props['aria-valuemax'] !== undefined || + props['aria-valuemin'] !== undefined || + props['aria-valuenow'] !== undefined || + props['aria-valuetext'] !== undefined || + props.id !== undefined || + props.tabIndex !== undefined + ); +} + +function transformProps(props: ViewProps): ViewProps { const { accessibilityState, accessibilityValue, @@ -123,28 +183,7 @@ component View(ref?: React.RefSetter, ...props: ViewProps) { }; } - if (__DEV__) { - const onSafeAreaInsetsChange = - resolvedProps.experimental_onSafeAreaInsetsChange; - if (onSafeAreaInsetsChange != null) { - resolvedProps.experimental_onSafeAreaInsetsChange = - warnOnRepeatedSafeAreaInsetsChanges(onSafeAreaInsetsChange); - } - } - - const actualView = - ref == null ? ( - - ) : ( - - ); - - if (hasTextAncestor) { - return ( - {actualView} - ); - } - return actualView; + return resolvedProps; } View.displayName = 'View'; diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-propTransforms-benchmark-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-propTransforms-benchmark-itest.js new file mode 100644 index 000000000000..fb5ddd7e7199 --- /dev/null +++ b/packages/react-native/Libraries/Components/View/__tests__/View-propTransforms-benchmark-itest.js @@ -0,0 +1,110 @@ +/** + * 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 '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import measureRenderTime from '../../../../src/private/__tests__/utilities/measureRenderTime'; +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {View} from 'react-native'; + +const COUNT = 100; + +let root: Fantom.Root; +let initial: React.MixedElement; +let next: React.MixedElement; + +type Variant = Readonly<{ + name: string, + update: boolean, + render: (i: number, gen: number) => React.Node, +}>; + +const variants: ReadonlyArray = [ + { + name: 'mount, plain (fast path)', + update: false, + render: i => , + }, + { + name: 'update, plain (fast path)', + update: true, + render: (i, gen) => , + }, + { + name: 'mount, testID + pointerEvents (fast path)', + update: false, + render: i => ( + + ), + }, + { + name: 'mount, aria-label (slow path)', + update: false, + render: i => ( + + ), + }, + { + name: 'update, aria-label (slow path)', + update: true, + render: (i, gen) => ( + + ), + }, + { + name: 'mount, accessibilityState (slow path, like Pressable)', + update: false, + render: i => ( + + ), + }, +]; + +function build(variant: Variant, gen: number): React.MixedElement { + const children = []; + for (let i = 0; i < COUNT; i++) { + children.push(variant.render(i, gen)); + } + return <>{children}; +} + +Fantom.unstable_benchmark + .suite('View prop transforms', {minIterations: 1000}) + .test.each( + variants, + variant => `${variant.name}, ${COUNT} views`, + () => ({overriddenDuration: measureRenderTime(root, next)}), + variant => ({ + beforeAll: () => { + initial = build(variant, 1); + next = variant.update ? build(variant, 2) : initial; + }, + beforeEach: () => { + root = Fantom.createRoot(); + if (variant.update) { + // Same wrapper as the measured render, so React updates in place. + measureRenderTime(root, initial); + } + }, + afterEach: () => { + root.destroy(); + }, + }), + ); diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-propTransforms-test.js b/packages/react-native/Libraries/Components/View/__tests__/View-propTransforms-test.js new file mode 100644 index 000000000000..f2d6d711532f --- /dev/null +++ b/packages/react-native/Libraries/Components/View/__tests__/View-propTransforms-test.js @@ -0,0 +1,98 @@ +/** + * 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. + * + * @format + * @noflow + */ + +'use strict'; + +const {parse} = require('flow-parser'); +const fs = require('node:fs'); +const path = require('node:path'); + +function getFunction(ast, name) { + const fn = ast.body.find( + node => node.type === 'FunctionDeclaration' && node.id.name === name, + ); + if (fn == null) { + throw new Error(`Could not find \`${name}\` in View.js`); + } + return fn; +} + +function keyName(node) { + return node.type === 'Identifier' ? node.name : String(node.value); +} + +// Every `props.x` / `props['x']` read in `node`. +function collectPropReads(node, keys) { + if (node == null || typeof node !== 'object') { + return keys; + } + if ( + node.type === 'MemberExpression' && + node.object.type === 'Identifier' && + node.object.name === 'props' + ) { + keys.add(keyName(node.property)); + } + for (const key of Object.keys(node)) { + if (key !== 'loc' && key !== 'range') { + const child = node[key]; + if (Array.isArray(child)) { + child.forEach(c => collectPropReads(c, keys)); + } else { + collectPropReads(child, keys); + } + } + } + return keys; +} + +// The named keys `transformProps` destructures out of `props`. +function collectDestructuredKeys(fn) { + for (const statement of fn.body.body) { + if (statement.type !== 'VariableDeclaration') { + continue; + } + for (const declaration of statement.declarations) { + if ( + declaration.id.type === 'ObjectPattern' && + declaration.init?.type === 'Identifier' && + declaration.init.name === 'props' + ) { + return new Set( + declaration.id.properties + .filter(property => property.type === 'Property') + .map(property => keyName(property.key)), + ); + } + } + } + throw new Error('Could not find the props destructure in `transformProps`'); +} + +describe('View prop transforms', () => { + it('skips the transform only when none of the transformed props are set', () => { + const source = fs.readFileSync(path.join(__dirname, '../View.js'), 'utf8'); + const ast = parse(source, {components: true}); + + const checked = collectPropReads( + getFunction(ast, 'needsPropTransforms'), + new Set(), + ); + const transformed = collectDestructuredKeys( + getFunction(ast, 'transformProps'), + ); + + // If this fails, a prop was added to (or removed from) `transformProps` + // without updating `needsPropTransforms`, so Views that only set that prop + // would skip the transform. + expect([...checked].sort()).toEqual([...transformed].sort()); + expect(transformed.size).toBeGreaterThan(0); + }); +});