From 039a333df57e20133af3ec77e995ec8fe4dc7f5c Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Wed, 14 May 2025 09:02:00 -0700 Subject: [PATCH] Remove any props set by default on View (#51225) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/51225 We process a number of props by default in View, so we can provide some amount of compatibility with web API's. The way we then pass these to React means we end up setting a number of props with 'undefined' values. These props need to be diffed, sent to native (so string keys copied via JSI) and serialized to folly::dynamic (on Android) which is just wasteful. Instead we can mutate the destructured props object and update/insert keys only as necessary to reduce the props payload size. Changelog: [General][Breaking] View no longer sets any default accessibility props, which should not result in visible changes in behaviour but may affect snapshot tests. Reviewed By: yungsters Differential Revision: D74472767 fbshipit-source-id: 462a4495c0672d4bf1752a532acff49b14598e8e --- .../Libraries/Components/View/View.js | 252 ++++++++++++------ .../ReactNativeFeatureFlags.config.js | 11 + .../featureflags/ReactNativeFeatureFlags.js | 8 +- 3 files changed, 192 insertions(+), 79 deletions(-) diff --git a/packages/react-native/Libraries/Components/View/View.js b/packages/react-native/Libraries/Components/View/View.js index 77e44899ede..4bb84994919 100644 --- a/packages/react-native/Libraries/Components/View/View.js +++ b/packages/react-native/Libraries/Components/View/View.js @@ -10,12 +10,18 @@ import type {ViewProps} from './ViewPropTypes'; +import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags'; import TextAncestor from '../../Text/TextAncestor'; import ViewNativeComponent from './ViewNativeComponent'; import * as React from 'react'; export type Props = ViewProps; +type PropsWithRef = $ReadOnly<{ + ref?: React.RefSetter>, + ...ViewProps, +}>; + /** * The most fundamental component for building a UI, View is a container that * supports layout with flexbox, style, some touch handling, and accessibility @@ -23,93 +29,183 @@ export type Props = ViewProps; * * @see https://reactnative.dev/docs/view */ -function View({ - accessibilityElementsHidden, - accessibilityLabel, - accessibilityLabelledBy, - accessibilityLiveRegion, - accessibilityState, - accessibilityValue, - 'aria-busy': ariaBusy, - 'aria-checked': ariaChecked, - 'aria-disabled': ariaDisabled, - 'aria-expanded': ariaExpanded, - 'aria-hidden': ariaHidden, - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledBy, - 'aria-live': ariaLive, - 'aria-selected': ariaSelected, - 'aria-valuemax': ariaValueMax, - 'aria-valuemin': ariaValueMin, - 'aria-valuenow': ariaValueNow, - 'aria-valuetext': ariaValueText, - focusable, - id, - importantForAccessibility, - nativeID, - tabIndex, - ...otherProps -}: $ReadOnly<{ - ref?: React.RefSetter>, - ...ViewProps, -}>): React.Node { +function View(props: PropsWithRef): React.Node { const hasTextAncestor = React.use(TextAncestor); - const _accessibilityLabelledBy = - ariaLabelledBy?.split(/\s*,\s*/g) ?? accessibilityLabelledBy; - const _accessibilityState = - accessibilityState != null || - ariaBusy != null || - ariaChecked != null || - ariaDisabled != null || - ariaExpanded != null || - ariaSelected != null - ? { - busy: ariaBusy ?? accessibilityState?.busy, - checked: ariaChecked ?? accessibilityState?.checked, - disabled: ariaDisabled ?? accessibilityState?.disabled, - expanded: ariaExpanded ?? accessibilityState?.expanded, - selected: ariaSelected ?? accessibilityState?.selected, - } - : undefined; + let actualView; + if (ReactNativeFeatureFlags.reduceDefaultPropsInView()) { + const { + accessibilityState, + accessibilityValue, + 'aria-busy': ariaBusy, + 'aria-checked': ariaChecked, + 'aria-disabled': ariaDisabled, + 'aria-expanded': ariaExpanded, + 'aria-hidden': ariaHidden, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + 'aria-live': ariaLive, + 'aria-selected': ariaSelected, + 'aria-valuemax': ariaValueMax, + 'aria-valuemin': ariaValueMin, + 'aria-valuenow': ariaValueNow, + 'aria-valuetext': ariaValueText, + id, + tabIndex, + ...otherProps + } = props; - const _accessibilityValue = - accessibilityValue != null || - ariaValueMax != null || - ariaValueMin != null || - ariaValueNow != null || - ariaValueText != null - ? { - max: ariaValueMax ?? accessibilityValue?.max, - min: ariaValueMin ?? accessibilityValue?.min, - now: ariaValueNow ?? accessibilityValue?.now, - text: ariaValueText ?? accessibilityValue?.text, - } - : undefined; + // Since we destructured props, we can now treat it as mutable + const processedProps = otherProps as {...PropsWithRef}; - const actualView = ( - - ); + } + + if (id !== undefined) { + processedProps.nativeID = id; + } + + if (tabIndex !== undefined) { + processedProps.focusable = !tabIndex; + } + + if ( + accessibilityState != null || + ariaBusy != null || + ariaChecked != null || + ariaDisabled != null || + ariaExpanded != null || + ariaSelected != null + ) { + processedProps.accessibilityState = { + busy: ariaBusy ?? accessibilityState?.busy, + checked: ariaChecked ?? accessibilityState?.checked, + disabled: ariaDisabled ?? accessibilityState?.disabled, + expanded: ariaExpanded ?? accessibilityState?.expanded, + selected: ariaSelected ?? accessibilityState?.selected, + }; + } + + if ( + accessibilityValue != null || + ariaValueMax != null || + ariaValueMin != null || + ariaValueNow != null || + ariaValueText != null + ) { + processedProps.accessibilityValue = { + max: ariaValueMax ?? accessibilityValue?.max, + min: ariaValueMin ?? accessibilityValue?.min, + now: ariaValueNow ?? accessibilityValue?.now, + text: ariaValueText ?? accessibilityValue?.text, + }; + } + + actualView = ; + } else { + const { + accessibilityElementsHidden, + accessibilityLabel, + accessibilityLabelledBy, + accessibilityLiveRegion, + accessibilityState, + accessibilityValue, + 'aria-busy': ariaBusy, + 'aria-checked': ariaChecked, + 'aria-disabled': ariaDisabled, + 'aria-expanded': ariaExpanded, + 'aria-hidden': ariaHidden, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + 'aria-live': ariaLive, + 'aria-selected': ariaSelected, + 'aria-valuemax': ariaValueMax, + 'aria-valuemin': ariaValueMin, + 'aria-valuenow': ariaValueNow, + 'aria-valuetext': ariaValueText, + focusable, + id, + importantForAccessibility, + nativeID, + tabIndex, + ...otherProps + } = props; + const _accessibilityLabelledBy = + ariaLabelledBy?.split(/\s*,\s*/g) ?? accessibilityLabelledBy; + + const _accessibilityState = + accessibilityState != null || + ariaBusy != null || + ariaChecked != null || + ariaDisabled != null || + ariaExpanded != null || + ariaSelected != null + ? { + busy: ariaBusy ?? accessibilityState?.busy, + checked: ariaChecked ?? accessibilityState?.checked, + disabled: ariaDisabled ?? accessibilityState?.disabled, + expanded: ariaExpanded ?? accessibilityState?.expanded, + selected: ariaSelected ?? accessibilityState?.selected, + } + : undefined; + + const _accessibilityValue = + accessibilityValue != null || + ariaValueMax != null || + ariaValueMin != null || + ariaValueNow != null || + ariaValueText != null + ? { + max: ariaValueMax ?? accessibilityValue?.max, + min: ariaValueMin ?? accessibilityValue?.min, + now: ariaValueNow ?? accessibilityValue?.now, + text: ariaValueText ?? accessibilityValue?.text, + } + : undefined; + + actualView = ( + + ); + } if (hasTextAncestor) { return {actualView}; } - return actualView; } diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 2afc7b97fe0..36b17fbcf43 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -653,6 +653,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + reduceDefaultPropsInView: { + defaultValue: true, + metadata: { + dateAdded: '2025-5-12', + description: + 'Optimize how default (accessibility) props are processed in View to avoid unnecessary keys.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, scheduleAnimatedCleanupInMicrotask: { defaultValue: true, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index 8ffecac90b8..815d0801079 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<84786200dbf1e0d95605152913b73423>> + * @generated SignedSource<<2dc9ec2ea173f10586a67940720f1d37>> * @flow strict */ @@ -37,6 +37,7 @@ export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{ enableVirtualViewDoubleStateHidden: Getter, fixVirtualizeListCollapseWindowSize: Getter, isLayoutAnimationEnabled: Getter, + reduceDefaultPropsInView: Getter, scheduleAnimatedCleanupInMicrotask: Getter, shouldUseAnimatedObjectForTransform: Getter, shouldUseRemoveClippedSubviewsAsDefaultOnIOS: Getter, @@ -149,6 +150,11 @@ export const fixVirtualizeListCollapseWindowSize: Getter = createJavaSc */ export const isLayoutAnimationEnabled: Getter = createJavaScriptFlagGetter('isLayoutAnimationEnabled', true); +/** + * Optimize how default (accessibility) props are processed in View to avoid unnecessary keys. + */ +export const reduceDefaultPropsInView: Getter = createJavaScriptFlagGetter('reduceDefaultPropsInView', true); + /** * Changes the cleanup of `AnimatedProps` to occur in a microtask instead of synchronously during effect cleanup (for unmount) or subsequent mounts (for updates). */