Don't populate default Image prop values on JS side (Android only) (#53225)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/53225

# Changelog:
[Internal] -

Similarly to how it was done for View and Text components ( https://github.com/facebook/react-native/pull/53059), this adds an option to only populate non-default props for Image component.

**This gives up to 50% performance improvement on the benchmark test**, so looks promising.

Reviewed By: rubennorte

Differential Revision: D80090241

fbshipit-source-id: 7dfa6573408794535555e43580e32952fb1d1990
This commit is contained in:
Ruslan Shestopalyuk
2025-08-14 07:55:37 -07:00
committed by Facebook GitHub Bot
parent 1d86fb67ee
commit d2df64d2fc
6 changed files with 443 additions and 162 deletions
@@ -12,8 +12,10 @@ import type {HostInstance} from '../../src/private/types/HostInstance';
import type {ImageStyleProp} from '../StyleSheet/StyleSheet';
import type {RootTag} from '../Types/RootTagTypes';
import type {ImageProps} from './ImageProps';
import type {ImageSourceHeaders} from './ImageSourceUtils';
import type {AbstractImageAndroid, ImageAndroid} from './ImageTypes.flow';
import * as ReactNativeFeatureFlags from '../../src/private/featureflags/ReactNativeFeatureFlags';
import flattenStyle from '../StyleSheet/flattenStyle';
import StyleSheet from '../StyleSheet/StyleSheet';
import TextAncestorContext from '../Text/TextAncestorContext';
@@ -31,6 +33,7 @@ import NativeImageLoaderAndroid, {
import resolveAssetSource from './resolveAssetSource';
import TextInlineImageNativeComponent from './TextInlineImageNativeComponent';
import * as React from 'react';
import {use} from 'react';
let _requestId = 1;
function generateRequestId() {
@@ -121,6 +124,12 @@ async function queryCache(
return NativeImageLoaderAndroid.queryCache(urls);
}
const EMPTY_IMAGE_SOURCE = {
uri: undefined,
width: undefined,
height: undefined,
};
/**
* A React component for displaying different types of images,
* including network images, static resources, temporary local images, and
@@ -128,137 +137,335 @@ async function queryCache(
*
* See https://reactnative.dev/docs/image
*/
let BaseImage: AbstractImageAndroid = ({
ref: forwardedRef,
...props
}: {
ref?: React.RefSetter<HostInstance>,
...ImageProps,
}) => {
let source = getImageSourcesFromImageProps(props) || {
uri: undefined,
width: undefined,
height: undefined,
};
const defaultSource = resolveAssetSource(props.defaultSource);
const loadingIndicatorSource = resolveAssetSource(
props.loadingIndicatorSource,
);
if (props.children != null) {
throw new Error(
'The <Image> component cannot contain children. If you want to render content on top of the image, consider using the <ImageBackground> component or absolute positioning.',
);
}
if (props.defaultSource != null && props.loadingIndicatorSource != null) {
throw new Error(
'The <Image> component cannot have defaultSource and loadingIndicatorSource at the same time. Please use either defaultSource or loadingIndicatorSource.',
);
}
let style: ImageStyleProp;
let sources;
if (Array.isArray(source)) {
style = [styles.base, props.style];
sources = source;
} else {
const {uri} = source;
if (uri === '') {
console.warn('source.uri should not be an empty string');
}
const width = source.width ?? props.width;
const height = source.height ?? props.height;
style = [{width, height}, styles.base, props.style];
sources = [source];
}
const {onLoadStart, onLoad, onLoadEnd, onError} = props;
const nativeProps = {
...props,
let _BaseImage;
if (ReactNativeFeatureFlags.reduceDefaultPropsInImage()) {
let BaseImage: AbstractImageAndroid = ({
ref: forwardedRef,
alt,
accessible,
'aria-labelledby': ariaLabelledBy,
'aria-busy': ariaBusy,
'aria-checked': ariaChecked,
'aria-disabled': ariaDisabled,
'aria-expanded': ariaExpanded,
'aria-label': ariaLabel,
'aria-selected': ariaSelected,
accessibilityLabel,
accessibilityLabelledBy,
accessibilityState,
defaultSource,
loadingIndicatorSource,
children,
source,
src,
style,
shouldNotifyLoadEvents: !!(onLoadStart || onLoad || onLoadEnd || onError),
crossOrigin,
referrerPolicy,
srcSet,
onLoadStart,
onLoad,
onLoadEnd,
onError,
width,
height,
resizeMode,
...restProps
}: {
ref?: React.RefSetter<HostInstance>,
...ImageProps,
}) => {
let source_ =
getImageSourcesFromImageProps({
crossOrigin,
referrerPolicy,
src,
srcSet,
width,
height,
source,
}) || EMPTY_IMAGE_SOURCE;
const defaultSource_ = resolveAssetSource(defaultSource);
const loadingIndicatorSource_ = resolveAssetSource(loadingIndicatorSource);
if (children != null) {
throw new Error(
'The <Image> component cannot contain children. If you want to render content on top of the image, consider using the <ImageBackground> component or absolute positioning.',
);
}
if (defaultSource != null && loadingIndicatorSource != null) {
throw new Error(
'The <Image> component cannot have defaultSource and loadingIndicatorSource at the same time. Please use either defaultSource or loadingIndicatorSource.',
);
}
let style_: ImageStyleProp;
let sources_;
let headers_: ?ImageSourceHeaders;
if (Array.isArray(source_)) {
style_ = [styles.base, style];
sources_ = source_;
headers_ = sources_[0].headers;
} else {
const {uri} = source_;
if (uri === '') {
console.warn('source.uri should not be an empty string');
}
const width_ = source_.width ?? width;
const height_ = source_.height ?? height;
style_ = [{width: width_, height: height_}, styles.base, style];
sources_ = [source_];
}
const nativeProps = restProps as {
...React.PropsOf<ImageViewNativeComponent>,
};
// Both iOS and C++ sides expect to have "source" prop, whereas on Android it's "src"
// (for historical reasons). So in the latter case we populate both "src" and "source",
// in order to have a better alignment between platforms in the future.
src: sources,
source: sources,
/* $FlowFixMe(>=0.78.0 site=react_native_android_fb) This issue was found
* when making Flow check .android.js files. */
headers: (source?.[0]?.headers || source?.headers: ?{[string]: string}),
defaultSource: defaultSource ? defaultSource.uri : null,
loadingIndicatorSrc: loadingIndicatorSource
? loadingIndicatorSource.uri
: null,
accessibilityLabel:
props['aria-label'] ?? props.accessibilityLabel ?? props.alt,
accessibilityLabelledBy:
props?.['aria-labelledby'] ?? props?.accessibilityLabelledBy,
accessible: props.alt !== undefined ? true : props.accessible,
accessibilityState: {
busy: props['aria-busy'] ?? props.accessibilityState?.busy,
checked: props['aria-checked'] ?? props.accessibilityState?.checked,
disabled: props['aria-disabled'] ?? props.accessibilityState?.disabled,
expanded: props['aria-expanded'] ?? props.accessibilityState?.expanded,
selected: props['aria-selected'] ?? props.accessibilityState?.selected,
},
// TODO: `src` should be eventually removed from the API on Android.
nativeProps.src = sources_;
nativeProps.source = sources_;
nativeProps.style = style_;
if (headers_ != null) {
nativeProps.headers = headers_;
}
if (onLoadStart != null) {
nativeProps.shouldNotifyLoadEvents = true;
nativeProps.onLoadStart = onLoadStart;
}
if (onLoad != null) {
nativeProps.shouldNotifyLoadEvents = true;
nativeProps.onLoad = onLoad;
}
if (onLoadEnd != null) {
nativeProps.shouldNotifyLoadEvents = true;
nativeProps.onLoadEnd = onLoadEnd;
}
if (onError != null) {
nativeProps.shouldNotifyLoadEvents = true;
nativeProps.onError = onError;
}
if (defaultSource_ != null && defaultSource_.uri != null) {
nativeProps.defaultSource = defaultSource_.uri;
}
if (
loadingIndicatorSource_ != null &&
loadingIndicatorSource_.uri != null
) {
nativeProps.loadingIndicatorSrc = loadingIndicatorSource_.uri;
}
if (ariaLabel != null) {
nativeProps.accessibilityLabel = ariaLabel;
} else if (accessibilityLabel != null) {
nativeProps.accessibilityLabel = accessibilityLabel;
} else if (alt != null) {
nativeProps.accessibilityLabel = alt;
}
if (ariaLabelledBy != null) {
nativeProps.accessibilityLabelledBy = ariaLabelledBy;
} else if (accessibilityLabelledBy != null) {
nativeProps.accessibilityLabelledBy = accessibilityLabelledBy;
}
if (alt != null) {
nativeProps.accessible = true;
} else if (accessible != null) {
nativeProps.accessible = accessible;
}
if (
accessibilityState != null ||
ariaBusy != null ||
ariaChecked != null ||
ariaDisabled != null ||
ariaExpanded != null ||
ariaSelected != null
) {
nativeProps.accessibilityState = {
busy: ariaBusy ?? accessibilityState?.busy,
checked: ariaChecked ?? accessibilityState?.checked,
disabled: ariaDisabled ?? accessibilityState?.disabled,
expanded: ariaExpanded ?? accessibilityState?.expanded,
selected: ariaSelected ?? accessibilityState?.selected,
};
}
const flattenedStyle_ = flattenStyle<ImageStyleProp>(style);
const objectFit_ = convertObjectFitToResizeMode(flattenedStyle_?.objectFit);
const resizeMode_ =
objectFit_ || resizeMode || flattenedStyle_?.resizeMode || 'cover';
nativeProps.resizeMode = resizeMode_;
const actualRef = useWrapRefWithImageAttachedCallbacks(forwardedRef);
const hasTextAncestor = use(TextAncestorContext);
const analyticTag = use(ImageAnalyticsTagContext);
if (analyticTag !== null) {
nativeProps.internal_analyticTag = analyticTag;
}
return hasTextAncestor ? (
<TextInlineImageNativeComponent
// $FlowFixMe[incompatible-type]
style={style_}
resizeMode={resizeMode_}
headers={headers_}
src={sources_}
ref={actualRef}
/>
) : (
<ImageViewNativeComponent {...nativeProps} ref={actualRef} />
);
};
const flattenedStyle = flattenStyle<ImageStyleProp>(style);
const objectFit = convertObjectFitToResizeMode(flattenedStyle?.objectFit);
const resizeMode =
objectFit || props.resizeMode || flattenedStyle?.resizeMode || 'cover';
_BaseImage = BaseImage;
} else {
let BaseImage: AbstractImageAndroid = ({
ref: forwardedRef,
...props
}: {
ref?: React.RefSetter<HostInstance>,
...ImageProps,
}) => {
let source = getImageSourcesFromImageProps(props) || {
uri: undefined,
width: undefined,
height: undefined,
};
const defaultSource = resolveAssetSource(props.defaultSource);
const loadingIndicatorSource = resolveAssetSource(
props.loadingIndicatorSource,
);
const actualRef = useWrapRefWithImageAttachedCallbacks(forwardedRef);
if (props.children != null) {
throw new Error(
'The <Image> component cannot contain children. If you want to render content on top of the image, consider using the <ImageBackground> component or absolute positioning.',
);
}
if (props.defaultSource != null && props.loadingIndicatorSource != null) {
throw new Error(
'The <Image> component cannot have defaultSource and loadingIndicatorSource at the same time. Please use either defaultSource or loadingIndicatorSource.',
);
}
let style: ImageStyleProp;
let sources;
if (Array.isArray(source)) {
style = [styles.base, props.style];
sources = source;
} else {
const {uri} = source;
if (uri === '') {
console.warn('source.uri should not be an empty string');
}
const width = source.width ?? props.width;
const height = source.height ?? props.height;
style = [{width, height}, styles.base, props.style];
sources = [source];
}
const {onLoadStart, onLoad, onLoadEnd, onError} = props;
const nativeProps = {
...props,
style,
shouldNotifyLoadEvents: !!(onLoadStart || onLoad || onLoadEnd || onError),
// Both iOS and C++ sides expect to have "source" prop, whereas on Android it's "src"
// (for historical reasons). So in the latter case we populate both "src" and "source",
// in order to have a better alignment between platforms in the future.
src: sources,
source: sources,
/* $FlowFixMe(>=0.78.0 site=react_native_android_fb) This issue was found
* when making Flow check .android.js files. */
headers: (source?.[0]?.headers || source?.headers: ?{[string]: string}),
defaultSource: defaultSource ? defaultSource.uri : null,
loadingIndicatorSrc: loadingIndicatorSource
? loadingIndicatorSource.uri
: null,
accessibilityLabel:
props['aria-label'] ?? props.accessibilityLabel ?? props.alt,
accessibilityLabelledBy:
props?.['aria-labelledby'] ?? props?.accessibilityLabelledBy,
accessible: props.alt !== undefined ? true : props.accessible,
accessibilityState: {
busy: props['aria-busy'] ?? props.accessibilityState?.busy,
checked: props['aria-checked'] ?? props.accessibilityState?.checked,
disabled: props['aria-disabled'] ?? props.accessibilityState?.disabled,
expanded: props['aria-expanded'] ?? props.accessibilityState?.expanded,
selected: props['aria-selected'] ?? props.accessibilityState?.selected,
},
};
const flattenedStyle = flattenStyle<ImageStyleProp>(style);
const objectFit = convertObjectFitToResizeMode(flattenedStyle?.objectFit);
const resizeMode =
objectFit || props.resizeMode || flattenedStyle?.resizeMode || 'cover';
const actualRef = useWrapRefWithImageAttachedCallbacks(forwardedRef);
return (
<ImageAnalyticsTagContext.Consumer>
{analyticTag => {
const nativePropsWithAnalytics =
analyticTag !== null
? {
...nativeProps,
internal_analyticTag: analyticTag,
}
: nativeProps;
return (
<TextAncestorContext.Consumer>
{hasTextAncestor => {
if (hasTextAncestor) {
return (
<TextInlineImageNativeComponent
// $FlowFixMe[incompatible-type]
style={style}
resizeMode={resizeMode}
headers={nativeProps.headers}
src={sources}
ref={actualRef}
/>
);
}
return (
<ImageAnalyticsTagContext.Consumer>
{analyticTag => {
const nativePropsWithAnalytics =
analyticTag !== null
? {
...nativeProps,
internal_analyticTag: analyticTag,
}
: nativeProps;
return (
<TextAncestorContext.Consumer>
{hasTextAncestor => {
if (hasTextAncestor) {
return (
<TextInlineImageNativeComponent
// $FlowFixMe[incompatible-type]
style={style}
<ImageViewNativeComponent
{...nativePropsWithAnalytics}
resizeMode={resizeMode}
headers={nativeProps.headers}
src={sources}
ref={actualRef}
/>
);
}
}}
</TextAncestorContext.Consumer>
);
}}
</ImageAnalyticsTagContext.Consumer>
);
};
return (
<ImageViewNativeComponent
{...nativePropsWithAnalytics}
resizeMode={resizeMode}
ref={actualRef}
/>
);
}}
</TextAncestorContext.Consumer>
);
}}
</ImageAnalyticsTagContext.Consumer>
);
};
_BaseImage = BaseImage;
}
const imageComponentDecorator = unstable_getImageComponentDecorator();
if (imageComponentDecorator != null) {
BaseImage = imageComponentDecorator(BaseImage);
_BaseImage = imageComponentDecorator(_BaseImage);
}
// $FlowExpectedError[incompatible-type] Eventually we need to move these functions from statics of the component to exports in the module.
const Image: ImageAndroid = BaseImage;
const Image: ImageAndroid = _BaseImage;
Image.displayName = 'Image';
+8 -2
View File
@@ -15,20 +15,26 @@ import type {ImageProps} from './ImageProps';
import resolveAssetSource from './resolveAssetSource';
export type ImageSourceHeaders = {
[string]: string,
};
/**
* A function which returns the appropriate value for image source
* by resolving the `source`, `src` and `srcSet` props.
*/
export function getImageSourcesFromImageProps(
imageProps: ImageProps,
): ?ResolvedAssetSource | $ReadOnlyArray<{uri: string, ...}> {
):
| ?ResolvedAssetSource
| $ReadOnlyArray<{uri: string, headers: ImageSourceHeaders, ...}> {
let source = resolveAssetSource(imageProps.source);
let sources;
const {crossOrigin, referrerPolicy, src, srcSet, width, height} = imageProps;
const headers: {[string]: string} = {};
const headers: ImageSourceHeaders = {};
if (crossOrigin === 'use-credentials') {
headers['Access-Control-Allow-Credentials'] = 'true';
}
@@ -6,6 +6,7 @@
*
* @flow strict-local
* @format
* @fantom_flags reduceDefaultPropsInImage:*
*/
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
@@ -20,35 +21,61 @@ const IMAGE2 = require('./img/img2.png');
let root;
let testElements: React.MixedElement;
Fantom.unstable_benchmark.suite('Image').test.each(
[100, 1000],
n => `render ${n.toString()} image component instances`,
() => {
Fantom.runTask(() => root.render(testElements));
},
n => ({
beforeAll: () => {
testElements = (
<>
{Array.from({length: n}, (_, i) => (
<Image
id={String(i)}
nativeID={String(i)}
source={i % 2 === 0 ? IMAGE1 : IMAGE2}
style={{
width: i + 1,
height: i + 1,
}}
/>
))}
</>
);
Fantom.unstable_benchmark
.suite('Image')
.test.each(
[100, 1000],
n => `render ${n.toString()} images with no explicit props`,
() => {
Fantom.runTask(() => root.render(testElements));
},
beforeEach: () => {
root = Fantom.createRoot();
n => ({
beforeAll: () => {
testElements = (
<>
{[...Array(n).keys()].map(i => (
<Image />
))}
</>
);
},
beforeEach: () => {
root = Fantom.createRoot();
},
afterEach: () => {
root.destroy();
},
}),
)
.test.each(
[100, 1000],
n => `render ${n.toString()} images`,
() => {
Fantom.runTask(() => root.render(testElements));
},
afterEach: () => {
root.destroy();
},
}),
);
n => ({
beforeAll: () => {
testElements = (
<>
{Array.from({length: n}, (_, i) => (
<Image
id={String(i)}
nativeID={String(i)}
source={i % 2 === 0 ? IMAGE1 : IMAGE2}
style={{
width: i + 1,
height: i + 1,
}}
/>
))}
</>
);
},
beforeEach: () => {
root = Fantom.createRoot();
},
afterEach: () => {
root.destroy();
},
}),
);
@@ -6,12 +6,14 @@
*
* @flow strict-local
* @format
* @fantom_flags reduceDefaultPropsInImage:*
*/
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
import type {AccessibilityProps, HostInstance} from 'react-native';
import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags';
import * as Fantom from '@react-native/fantom';
import * as React from 'react';
import {createRef} from 'react';
@@ -34,29 +36,51 @@ describe('<Image>', () => {
root.render(<Image />);
});
expect(root.getRenderedOutput().toJSX()).toEqual(
<rn-image
accessibilityState="{disabled:false,selected:false,checked:None,busy:false,expanded:null}"
overflow="hidden"
resizeMode="cover"
source-scale="1"
source-type="remote"
/>,
);
if (ReactNativeFeatureFlags.reduceDefaultPropsInImage()) {
expect(root.getRenderedOutput().toJSX()).toEqual(
<rn-image
overflow="hidden"
resizeMode="cover"
source-scale="1"
source-type="remote"
/>,
);
} else {
expect(root.getRenderedOutput().toJSX()).toEqual(
<rn-image
accessibilityState="{disabled:false,selected:false,checked:None,busy:false,expanded:null}"
overflow="hidden"
resizeMode="cover"
source-scale="1"
source-type="remote"
/>,
);
}
Fantom.runTask(() => {
root.render(<Image src="" />);
});
expect(root.getRenderedOutput().toJSX()).toEqual(
<rn-image
accessibilityState="{disabled:false,selected:false,checked:None,busy:false,expanded:null}"
overflow="hidden"
resizeMode="cover"
source-scale="1"
source-type="remote"
/>,
);
if (ReactNativeFeatureFlags.reduceDefaultPropsInImage()) {
expect(root.getRenderedOutput().toJSX()).toEqual(
<rn-image
overflow="hidden"
resizeMode="cover"
source-scale="1"
source-type="remote"
/>,
);
} else {
expect(root.getRenderedOutput().toJSX()).toEqual(
<rn-image
accessibilityState="{disabled:false,selected:false,checked:None,busy:false,expanded:null}"
overflow="hidden"
resizeMode="cover"
source-scale="1"
source-type="remote"
/>,
);
}
});
});
@@ -839,6 +839,17 @@ const definitions: FeatureFlagDefinitions = {
},
ossReleaseStage: 'none',
},
reduceDefaultPropsInImage: {
defaultValue: false,
metadata: {
dateAdded: '2025-7-29',
description:
'Optimize how default props are processed in Image to avoid unnecessary keys.',
expectedReleaseValue: true,
purpose: 'experimentation',
},
ossReleaseStage: 'none',
},
reduceDefaultPropsInText: {
defaultValue: false,
metadata: {
@@ -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<<24a5d3213cbc7cd101d34e059ba5c8c4>>
* @generated SignedSource<<29d1ff8e6948e8c8cf286769d8c1ff81>>
* @flow strict
* @noformat
*/
@@ -35,6 +35,7 @@ export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{
enableAccessToHostTreeInFabric: Getter<boolean>,
fixVirtualizeListCollapseWindowSize: Getter<boolean>,
isLayoutAnimationEnabled: Getter<boolean>,
reduceDefaultPropsInImage: Getter<boolean>,
reduceDefaultPropsInText: Getter<boolean>,
shouldUseAnimatedObjectForTransform: Getter<boolean>,
shouldUseRemoveClippedSubviewsAsDefaultOnIOS: Getter<boolean>,
@@ -151,6 +152,11 @@ export const fixVirtualizeListCollapseWindowSize: Getter<boolean> = createJavaSc
*/
export const isLayoutAnimationEnabled: Getter<boolean> = createJavaScriptFlagGetter('isLayoutAnimationEnabled', true);
/**
* Optimize how default props are processed in Image to avoid unnecessary keys.
*/
export const reduceDefaultPropsInImage: Getter<boolean> = createJavaScriptFlagGetter('reduceDefaultPropsInImage', false);
/**
* Optimize how default props are processed in Text to avoid unnecessary keys.
*/