From c7c5c6a1f1ef3d46e81c03ca18a6add7e668ec36 Mon Sep 17 00:00:00 2001 From: Adam Ivancza Date: Thu, 25 Jun 2026 16:33:17 +0200 Subject: [PATCH 1/9] feat: add Image optimizer --- .../plugin/__tests__/parity/fuzz/fuzz.test.ts | 3 + .../__tests__/parity/mocks/boost-runtime.ts | 1 + .../plugin/__tests__/parity/parity.test.ts | 3 + .../react-native-boost/src/plugin/index.ts | 2 + .../image/__tests__/fixtures/basic/code.js | 3 + .../image/__tests__/fixtures/basic/output.js | 22 ++ .../fixtures/dynamic-source-bails/code.js | 3 + .../fixtures/dynamic-source-bails/output.js | 2 + .../__tests__/fixtures/event-bails/code.js | 3 + .../__tests__/fixtures/event-bails/output.js | 7 + .../image/__tests__/fixtures/src-prop/code.js | 3 + .../__tests__/fixtures/src-prop/output.js | 22 ++ .../static-style-derived-props/code.js | 3 + .../static-style-derived-props/output.js | 26 ++ .../optimizers/image/__tests__/index.test.ts | 15 + .../src/plugin/optimizers/image/index.ts | 321 ++++++++++++++++++ .../src/plugin/types/index.ts | 7 +- .../src/plugin/utils/common/base.ts | 3 +- .../src/plugin/utils/generate-test-plugin.ts | 2 + .../src/runtime/__tests__/index.test.ts | 4 + .../src/runtime/components/native-image.tsx | 26 ++ .../react-native-boost/src/runtime/index.ts | 1 + .../src/runtime/index.web.ts | 1 + .../src/runtime/types/react-native.d.ts | 4 + 24 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/basic/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/basic/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/event-bails/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/event-bails/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-prop/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-prop/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/static-style-derived-props/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/static-style-derived-props/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/index.test.ts create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/index.ts create mode 100644 packages/react-native-boost/src/runtime/components/native-image.tsx diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/fuzz.test.ts b/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/fuzz.test.ts index 413d587..483634b 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/fuzz.test.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/fuzz.test.ts @@ -10,6 +10,9 @@ vi.mock('../../../../runtime/components/native-text', async () => ({ vi.mock('../../../../runtime/components/native-view', async () => ({ NativeView: (await import('../capture')).NativeViewCapturer, })); +vi.mock('../../../../runtime/components/native-image', async () => ({ + NativeImage: (await import('../capture')).NativeViewCapturer, +})); import { captureBoost } from '../boost'; import { captureWrapper } from '../wrapper'; diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/boost-runtime.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/boost-runtime.ts index f60bcd5..3100e86 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/boost-runtime.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/boost-runtime.ts @@ -8,6 +8,7 @@ import { NativeTextCapturer, NativeViewCapturer } from '../capture'; */ export const NativeText = NativeTextCapturer; export const NativeView = NativeViewCapturer; +export const NativeImage = NativeViewCapturer; export const processTextStyle = (style: unknown): Record => (style ? { style } : {}); export const processViewStyle = (style: unknown): Record => (style ? { style } : {}); diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts b/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts index 4a491c6..f919e23 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts @@ -10,6 +10,9 @@ vi.mock('../../../runtime/components/native-text', async () => ({ vi.mock('../../../runtime/components/native-view', async () => ({ NativeView: (await import('./capture')).NativeViewCapturer, })); +vi.mock('../../../runtime/components/native-image', async () => ({ + NativeImage: (await import('./capture')).NativeViewCapturer, +})); import { captureWrapper, captureWrapperHosts } from './wrapper'; import { captureBoost, boostOptimizes } from './boost'; diff --git a/packages/react-native-boost/src/plugin/index.ts b/packages/react-native-boost/src/plugin/index.ts index ed6185d..f66509a 100644 --- a/packages/react-native-boost/src/plugin/index.ts +++ b/packages/react-native-boost/src/plugin/index.ts @@ -1,5 +1,6 @@ import { declare } from '@babel/helper-plugin-utils'; import { textOptimizer } from './optimizers/text'; +import { imageOptimizer } from './optimizers/image'; import { PluginLogger, PluginOptions } from './types'; import { createLogger } from './utils/logger'; import { viewOptimizer } from './optimizers/view'; @@ -47,6 +48,7 @@ export default declare((api, rawOptions, dirname?: string) => { if (isIgnoredFile(path, options.ignores ?? [])) return; if (options.optimizations?.text !== false) textOptimizer(path, logger, options, platform, unistylesEnabled); if (options.optimizations?.view !== false) viewOptimizer(path, logger, options, platform, unistylesEnabled); + if (options.optimizations?.image !== false) imageOptimizer(path, logger, options, platform, unistylesEnabled); }, }, }; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/basic/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/basic/code.js new file mode 100644 index 0000000..9c3f994 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/basic/code.js @@ -0,0 +1,3 @@ +import { Image } from 'react-native'; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/basic/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/basic/output.js new file mode 100644 index 0000000..cbd58b8 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/basic/output.js @@ -0,0 +1,22 @@ +import { NativeImage as _NativeImage } from 'react-native-boost/runtime'; +import { Image } from 'react-native'; +<_NativeImage + testID="logo" + style={[ + { + width: 24, + height: 16, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 24, + height: 16, + }, + ]} + resizeMode="cover" +/>; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/code.js new file mode 100644 index 0000000..7bdcfe8 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/code.js @@ -0,0 +1,3 @@ +import { Image } from 'react-native'; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/output.js new file mode 100644 index 0000000..cb79f14 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/output.js @@ -0,0 +1,2 @@ +import { Image } from 'react-native'; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/event-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/event-bails/code.js new file mode 100644 index 0000000..94ef370 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/event-bails/code.js @@ -0,0 +1,3 @@ +import { Image } from 'react-native'; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/event-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/event-bails/output.js new file mode 100644 index 0000000..55d2e02 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/event-bails/output.js @@ -0,0 +1,7 @@ +import { Image } from 'react-native'; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-prop/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-prop/code.js new file mode 100644 index 0000000..c13a5c5 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-prop/code.js @@ -0,0 +1,3 @@ +import { Image } from 'react-native'; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-prop/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-prop/output.js new file mode 100644 index 0000000..00d63eb --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-prop/output.js @@ -0,0 +1,22 @@ +import { NativeImage as _NativeImage } from 'react-native-boost/runtime'; +import { Image } from 'react-native'; +<_NativeImage + style={[ + { + width: 10, + height: 20, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'https://example.com/a.png', + headers: {}, + width: 10, + height: 20, + }, + ]} + resizeMode="cover" +/>; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/static-style-derived-props/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/static-style-derived-props/code.js new file mode 100644 index 0000000..af1a0c8 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/static-style-derived-props/code.js @@ -0,0 +1,3 @@ +import { Image } from 'react-native'; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/static-style-derived-props/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/static-style-derived-props/output.js new file mode 100644 index 0000000..6867529 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/static-style-derived-props/output.js @@ -0,0 +1,26 @@ +import { NativeImage as _NativeImage } from 'react-native-boost/runtime'; +import { Image } from 'react-native'; +<_NativeImage + style={[ + {}, + { + overflow: 'hidden', + }, + [ + { + width: 100, + tintColor: 'red', + }, + { + objectFit: 'fill', + }, + ], + ]} + source={[ + { + uri: 'hero.png', + }, + ]} + resizeMode="stretch" + tintColor="red" +/>; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/index.test.ts b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/index.test.ts new file mode 100644 index 0000000..212964e --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/index.test.ts @@ -0,0 +1,15 @@ +import path from 'node:path'; +import { pluginTester } from 'babel-plugin-tester'; +import { generateTestPlugin } from '../../../utils/generate-test-plugin'; +import { formatTestResult } from '../../../utils/format-test-result'; +import { imageOptimizer } from '..'; + +pluginTester({ + plugin: generateTestPlugin(imageOptimizer), + title: 'image', + fixtures: path.resolve(import.meta.dirname, 'fixtures'), + babelOptions: { + plugins: ['@babel/plugin-syntax-jsx'], + }, + formatResult: formatTestResult, +}); diff --git a/packages/react-native-boost/src/plugin/optimizers/image/index.ts b/packages/react-native-boost/src/plugin/optimizers/image/index.ts new file mode 100644 index 0000000..c2c6a6c --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/index.ts @@ -0,0 +1,321 @@ +import { NodePath, types as t } from '@babel/core'; +import { HubFile, Optimizer } from '../../types'; +import PluginError from '../../utils/plugin-error'; +import { BailoutCheck, getFirstBailoutReason } from '../../utils/helpers'; +import { + hasBlacklistedProperty, + isIgnoredLine, + isForcedLine, + isReactNativeImport, + isStaticLiteralTree, + isValidJSXComponent, + replaceWithNativeComponent, +} from '../../utils/common'; + +const IMAGE_BAILOUT_PROPS = new Set([ + 'alt', + 'aria-busy', + 'aria-checked', + 'aria-disabled', + 'aria-expanded', + 'aria-hidden', + 'aria-label', + 'aria-labelledby', + 'aria-selected', + 'children', + 'crossOrigin', + 'defaultSource', + 'internal_analyticTag', + 'loadingIndicatorSource', + 'onError', + 'onLoad', + 'onLoadEnd', + 'onLoadStart', + 'onPartialLoad', + 'onProgress', + 'ref', + 'referrerPolicy', + 'srcSet', +]); + +const IMAGE_BASE_STYLE = t.objectExpression([t.objectProperty(t.identifier('overflow'), t.stringLiteral('hidden'))]); + +const OBJECT_FIT_TO_RESIZE_MODE: Record = { + 'contain': 'contain', + 'cover': 'cover', + 'fill': 'stretch', + 'none': 'none', + 'scale-down': 'contain', +}; + +export const imageOptimizer: Optimizer = (path, logger, _options, platform) => { + if (platform === 'web') return; + if (!isValidJSXComponent(path, 'Image')) return; + if (!isReactNativeImport(path, 'Image')) return; + + const parent = path.parent as t.JSXElement; + const forced = isForcedLine(path); + + const overridableChecks: BailoutCheck[] = [ + { + reason: 'contains unsupported Image props', + shouldBail: () => hasBlacklistedProperty(path, IMAGE_BAILOUT_PROPS), + }, + { + reason: 'contains non-empty children', + shouldBail: () => parent.children.some((child) => !t.isJSXText(child) || child.value.trim() !== ''), + }, + { + reason: 'has an unsupported or dynamic source', + shouldBail: () => buildNativeSource(path.node.attributes) == null, + }, + { + reason: 'has dynamic image style', + shouldBail: () => + getStyleExpression(path.node.attributes) != null && buildStaticStyleInfo(path.node.attributes) == null, + }, + ]; + + if (forced) { + const overriddenReason = getFirstBailoutReason(overridableChecks); + if (overriddenReason) { + logger.forced({ component: 'Image', path, reason: overriddenReason }); + } + } else { + const skipReason = getFirstBailoutReason([ + { + reason: 'line is marked with @boost-ignore', + shouldBail: () => isIgnoredLine(path), + }, + ...overridableChecks, + ]); + + if (skipReason) { + logger.skipped({ component: 'Image', path, reason: skipReason }); + return; + } + } + + const hub = path.hub as unknown; + const file = typeof hub === 'object' && hub !== null && 'file' in hub ? (hub.file as HubFile) : undefined; + + if (!file) { + throw new PluginError('No file found in Babel hub'); + } + + const nativeSource = buildNativeSource(path.node.attributes); + const styleInfo = buildStaticStyleInfo(path.node.attributes); + if (!nativeSource || styleInfo === null) return; + + logger.optimized({ component: 'Image', path }); + + processImageProps(path, nativeSource, styleInfo); + replaceWithNativeComponent(path, parent, file, 'NativeImage'); +}; + +type NativeSource = { + sourceAttribute: t.JSXAttribute; + sourceArray: t.ArrayExpression; + width?: t.Expression; + height?: t.Expression; +}; + +type StyleInfo = { + styleAttribute?: t.JSXAttribute; + styleExpression?: t.Expression; + resizeMode?: t.Expression; + tintColor?: t.Expression; +} | null; + +function processImageProps(path: NodePath, nativeSource: NativeSource, styleInfo: StyleInfo) { + const consumed = new Set([nativeSource.sourceAttribute]); + if (styleInfo?.styleAttribute) consumed.add(styleInfo.styleAttribute); + + const remaining = path.node.attributes.filter((attribute) => { + if (!t.isJSXAttribute(attribute)) return true; + if (consumed.has(attribute)) return false; + const name = attribute.name.name; + return name !== 'width' && name !== 'height' && name !== 'resizeMode' && name !== 'tintColor'; + }); + + const explicitResizeMode = getAttributeExpression(path.node.attributes, 'resizeMode'); + const explicitTintColor = getAttributeExpression(path.node.attributes, 'tintColor'); + + path.node.attributes = [ + ...remaining, + makeAttribute('style', buildStyle(nativeSource, styleInfo)), + makeAttribute('source', nativeSource.sourceArray), + makeAttribute('resizeMode', explicitResizeMode ?? styleInfo?.resizeMode ?? t.stringLiteral('cover')), + explicitTintColor || styleInfo?.tintColor + ? makeAttribute('tintColor', explicitTintColor ?? styleInfo!.tintColor!) + : undefined, + ].filter((attribute): attribute is t.JSXAttribute => attribute !== undefined); +} + +function buildNativeSource(attributes: Array): NativeSource | undefined { + const src = findAttribute(attributes, 'src'); + if (src) { + const uri = getAttributeValueExpression(src); + const width = getAttributeExpression(attributes, 'width'); + const height = getAttributeExpression(attributes, 'height'); + return { + sourceAttribute: src, + sourceArray: t.arrayExpression([ + t.objectExpression([ + t.objectProperty(t.identifier('uri'), uri), + t.objectProperty(t.identifier('headers'), t.objectExpression([])), + ...(width ? [t.objectProperty(t.identifier('width'), width)] : []), + ...(height ? [t.objectProperty(t.identifier('height'), height)] : []), + ]), + ]), + width, + height, + }; + } + + const source = findAttribute(attributes, 'source'); + if (!source || !t.isJSXExpressionContainer(source.value) || !t.isObjectExpression(source.value.expression)) { + return undefined; + } + if (!isStaticLiteralTree(source.value.expression)) return undefined; + + const sourceObject = source.value.expression; + const sourceWidth = getObjectPropertyExpression(sourceObject, 'width'); + const sourceHeight = getObjectPropertyExpression(sourceObject, 'height'); + const width = sourceWidth ?? getAttributeExpression(attributes, 'width'); + const height = sourceHeight ?? getAttributeExpression(attributes, 'height'); + + return { + sourceAttribute: source, + sourceArray: t.arrayExpression([t.cloneNode(sourceObject, true)]), + width, + height, + }; +} + +function buildStyle(nativeSource: NativeSource, styleInfo: StyleInfo): t.ArrayExpression { + return t.arrayExpression([ + t.objectExpression([ + ...(nativeSource.width ? [t.objectProperty(t.identifier('width'), t.cloneNode(nativeSource.width, true))] : []), + ...(nativeSource.height + ? [t.objectProperty(t.identifier('height'), t.cloneNode(nativeSource.height, true))] + : []), + ]), + t.cloneNode(IMAGE_BASE_STYLE, true), + ...(styleInfo?.styleExpression ? [styleInfo.styleExpression] : []), + ]); +} + +function buildStaticStyleInfo(attributes: Array): StyleInfo { + const styleAttribute = findAttribute(attributes, 'style'); + if (!styleAttribute) return {}; + const styleExpression = getAttributeValueExpression(styleAttribute); + if (!isStaticLiteralTree(styleExpression)) return null; + + const flattened = flattenStaticStyle(styleExpression); + if (!flattened) return null; + + const objectFit = flattened.get('objectFit'); + const resizeModeFromObjectFit = + objectFit && t.isStringLiteral(objectFit) ? OBJECT_FIT_TO_RESIZE_MODE[objectFit.value] : undefined; + const resizeMode = resizeModeFromObjectFit + ? t.stringLiteral(resizeModeFromObjectFit) + : cloneMapValue(flattened, 'resizeMode'); + + return { + styleAttribute, + styleExpression, + resizeMode, + tintColor: cloneMapValue(flattened, 'tintColor'), + }; +} + +function flattenStaticStyle(styleExpression: t.Expression): Map | undefined { + const objects: t.ObjectExpression[] = []; + + const collect = (expression: t.Expression | t.SpreadElement): boolean => { + if (t.isObjectExpression(expression)) { + objects.push(expression); + return true; + } + if (t.isArrayExpression(expression)) { + return expression.elements.every((element) => element == null || (t.isExpression(element) && collect(element))); + } + if ( + t.isNullLiteral(expression) || + (t.isBooleanLiteral(expression) && expression.value === false) || + (t.isNumericLiteral(expression) && expression.value === 0) + ) { + return true; + } + return false; + }; + + if (!collect(styleExpression)) return undefined; + + const flattened = new Map(); + for (const object of objects) { + for (const property of object.properties) { + if (!t.isObjectProperty(property) || property.computed || !t.isExpression(property.value)) return undefined; + const key = t.isIdentifier(property.key) + ? property.key.name + : t.isStringLiteral(property.key) + ? property.key.value + : undefined; + if (!key) return undefined; + flattened.set(key, property.value); + } + } + return flattened; +} + +function getStyleExpression(attributes: Array): t.Expression | undefined { + const style = findAttribute(attributes, 'style'); + return style ? getAttributeValueExpression(style) : undefined; +} + +function getAttributeExpression( + attributes: Array, + name: string +): t.Expression | undefined { + const attribute = findAttribute(attributes, name); + return attribute ? getAttributeValueExpression(attribute) : undefined; +} + +function findAttribute( + attributes: Array, + name: string +): t.JSXAttribute | undefined { + return attributes.find( + (attribute): attribute is t.JSXAttribute => + t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name, { name }) + ); +} + +function getObjectPropertyExpression(object: t.ObjectExpression, name: string): t.Expression | undefined { + for (const property of object.properties) { + if (!t.isObjectProperty(property) || !t.isExpression(property.value)) continue; + if (t.isIdentifier(property.key, { name }) || (t.isStringLiteral(property.key) && property.key.value === name)) { + return t.cloneNode(property.value, true); + } + } + return undefined; +} + +function cloneMapValue(map: Map, name: string): t.Expression | undefined { + const value = map.get(name); + return value ? t.cloneNode(value, true) : undefined; +} + +function getAttributeValueExpression(attribute: t.JSXAttribute): t.Expression { + if (!attribute.value) return t.booleanLiteral(true); + if (t.isStringLiteral(attribute.value)) return attribute.value; + if (t.isJSXExpressionContainer(attribute.value)) { + return t.isJSXEmptyExpression(attribute.value.expression) ? t.booleanLiteral(true) : attribute.value.expression; + } + return t.nullLiteral(); +} + +function makeAttribute(name: string, value: t.Expression): t.JSXAttribute { + return t.jsxAttribute(t.jsxIdentifier(name), t.isStringLiteral(value) ? value : t.jsxExpressionContainer(value)); +} diff --git a/packages/react-native-boost/src/plugin/types/index.ts b/packages/react-native-boost/src/plugin/types/index.ts index 6b363ff..6153fe2 100644 --- a/packages/react-native-boost/src/plugin/types/index.ts +++ b/packages/react-native-boost/src/plugin/types/index.ts @@ -11,6 +11,11 @@ export interface PluginOptimizationOptions { * @default true */ view?: boolean; + /** + * Whether to optimize the `Image` component. + * @default true + */ + image?: boolean; } export interface PluginOptions { @@ -81,7 +86,7 @@ export interface PluginOptions { dangerouslyOptimizeTextWithUnknownAncestors?: boolean; } -export type OptimizableComponent = 'Text' | 'View'; +export type OptimizableComponent = 'Text' | 'View' | 'Image'; export interface OptimizationLogPayload { component: OptimizableComponent; diff --git a/packages/react-native-boost/src/plugin/utils/common/base.ts b/packages/react-native-boost/src/plugin/utils/common/base.ts index 0cbea91..937eb89 100644 --- a/packages/react-native-boost/src/plugin/utils/common/base.ts +++ b/packages/react-native-boost/src/plugin/utils/common/base.ts @@ -50,12 +50,13 @@ export interface NativeComponentSource { } /** The native hosts Boost rewrites elements into; the local-name basis for each injected import. */ -type NativeComponentName = 'NativeText' | 'NativeView'; +type NativeComponentName = 'NativeText' | 'NativeView' | 'NativeImage'; /** Which context each optimized host establishes for the ancestor walk. Total over every host name. */ const HOST_KIND_BY_NAME: Record = { NativeText: 'text', NativeView: 'view', + NativeImage: 'view', }; /** diff --git a/packages/react-native-boost/src/plugin/utils/generate-test-plugin.ts b/packages/react-native-boost/src/plugin/utils/generate-test-plugin.ts index 18956e4..b09120d 100644 --- a/packages/react-native-boost/src/plugin/utils/generate-test-plugin.ts +++ b/packages/react-native-boost/src/plugin/utils/generate-test-plugin.ts @@ -3,6 +3,7 @@ import { Optimizer, PluginOptions } from '../types'; import { createLogger } from './logger'; import { textOptimizer } from '../optimizers/text'; import { viewOptimizer } from '../optimizers/view'; +import { imageOptimizer } from '../optimizers/image'; export const generateTestPlugin = (optimizer: Optimizer, options: PluginOptions = {}) => { const logger = createLogger({ @@ -48,6 +49,7 @@ export const generateCombinedTestPlugin = (options: PluginOptions = {}) => { JSXOpeningElement(path) { textOptimizer(path, logger, options, undefined, unistylesEnabled); viewOptimizer(path, logger, options, undefined, unistylesEnabled); + imageOptimizer(path, logger, options, undefined, unistylesEnabled); }, }, }; diff --git a/packages/react-native-boost/src/runtime/__tests__/index.test.ts b/packages/react-native-boost/src/runtime/__tests__/index.test.ts index bf7c11a..c4530df 100644 --- a/packages/react-native-boost/src/runtime/__tests__/index.test.ts +++ b/packages/react-native-boost/src/runtime/__tests__/index.test.ts @@ -19,6 +19,10 @@ vi.mock('../components/native-view', () => ({ NativeView: () => 'MockedNativeView', })); +vi.mock('../components/native-image', () => ({ + NativeImage: () => 'MockedNativeImage', +})); + // Switchable Platform mock so platform-specific defaults can be asserted for both OSes. `select` // reads the live `OS`, mirroring react-native's own implementation; tests flip `Platform.OS` and the // shared `afterEach` resets it. diff --git a/packages/react-native-boost/src/runtime/components/native-image.tsx b/packages/react-native-boost/src/runtime/components/native-image.tsx new file mode 100644 index 0000000..f3d30a7 --- /dev/null +++ b/packages/react-native-boost/src/runtime/components/native-image.tsx @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/no-require-imports,unicorn/prefer-module */ + +import type { ComponentType } from 'react'; +import type { ImageProps } from 'react-native'; + +const reactNative = require('react-native'); +const isWeb = reactNative.Platform.OS === 'web'; + +let nativeImage = reactNative.Image; + +if (!isWeb) { + try { + nativeImage = require('react-native/Libraries/Image/ImageViewNativeComponent').default ?? reactNative.Image; + } catch { + nativeImage = reactNative.Image; + } +} + +/** + * Native Image component with graceful fallback. + * + * @remarks + * React Native does not expose an `unstable_NativeImage`, so this uses the internal host when + * available and falls back to `Image`. + */ +export const NativeImage: ComponentType = nativeImage; diff --git a/packages/react-native-boost/src/runtime/index.ts b/packages/react-native-boost/src/runtime/index.ts index ec6f577..6f9d045 100644 --- a/packages/react-native-boost/src/runtime/index.ts +++ b/packages/react-native-boost/src/runtime/index.ts @@ -306,3 +306,4 @@ export * from './types'; export * from './utils/constants'; export * from './components/native-text'; export * from './components/native-view'; +export * from './components/native-image'; diff --git a/packages/react-native-boost/src/runtime/index.web.ts b/packages/react-native-boost/src/runtime/index.web.ts index 0bafc52..30901a1 100644 --- a/packages/react-native-boost/src/runtime/index.web.ts +++ b/packages/react-native-boost/src/runtime/index.web.ts @@ -38,4 +38,5 @@ export * from './utils/constants'; /* eslint-disable @typescript-eslint/no-require-imports,unicorn/prefer-module */ export const NativeText = require('react-native').Text; export const NativeView = require('react-native').View; +export const NativeImage = require('react-native').Image; /* eslint-enable @typescript-eslint/no-require-imports,unicorn/prefer-module */ diff --git a/packages/react-native-boost/src/runtime/types/react-native.d.ts b/packages/react-native-boost/src/runtime/types/react-native.d.ts index 10c9f1b..bdba77b 100644 --- a/packages/react-native-boost/src/runtime/types/react-native.d.ts +++ b/packages/react-native-boost/src/runtime/types/react-native.d.ts @@ -5,3 +5,7 @@ declare module 'react-native/Libraries/Text/TextNativeComponent' { declare module 'react-native/Libraries/Components/View/ViewNativeComponent' { export default React.ComponentType; } + +declare module 'react-native/Libraries/Image/ImageViewNativeComponent' { + export default React.ComponentType; +} From fa3743f279e47c17fd76dd9a054dd1451b5181ce Mon Sep 17 00:00:00 2001 From: Adam Ivancza Date: Thu, 25 Jun 2026 17:13:14 +0200 Subject: [PATCH 2/9] test: add Image benchmark coverage --- apps/example/src/screens/benchmark.tsx | 27 +++++++++- .../native-attribute-conformance.test.ts | 50 ++++++++++++++++++- .../__tests__/native-valid-attributes.ts | 7 +++ .../src/plugin/optimizers/image/index.ts | 7 +++ 4 files changed, 88 insertions(+), 3 deletions(-) diff --git a/apps/example/src/screens/benchmark.tsx b/apps/example/src/screens/benchmark.tsx index f6139e8..8f732e6 100644 --- a/apps/example/src/screens/benchmark.tsx +++ b/apps/example/src/screens/benchmark.tsx @@ -1,4 +1,4 @@ -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { Image, Pressable, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useMemo, useState } from 'react'; import { startMarker } from 'react-native-time-to-render'; @@ -21,6 +21,31 @@ const benchmarks = [ unoptimizedComponent: , optimizedComponent: , }, + { + title: 'Image', + count: 2000, + // @boost-ignore + unoptimizedComponent: ( + + ), + optimizedComponent: ( + + ), + }, ] satisfies Benchmark[]; export default function BenchmarkScreen() { diff --git a/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts b/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts index 4205b84..de1bcf7 100644 --- a/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts +++ b/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect } from 'vitest'; import { transformSync, types as t, type PluginObj } from '@babel/core'; import { generateTestPlugin } from '../utils/generate-test-plugin'; +import { imageOptimizer } from '../optimizers/image'; import { textOptimizer } from '../optimizers/text'; import { viewOptimizer } from '../optimizers/view'; import { Optimizer } from '../types'; -import { NATIVE_TEXT_ATTRIBUTES, NATIVE_VIEW_ATTRIBUTES } from './native-valid-attributes'; +import { NATIVE_IMAGE_ATTRIBUTES, NATIVE_TEXT_ATTRIBUTES, NATIVE_VIEW_ATTRIBUTES } from './native-valid-attributes'; /** * Attribute-conformance check: every prop the plugin leaves on an optimized host element must @@ -20,7 +21,7 @@ import { NATIVE_TEXT_ATTRIBUTES, NATIVE_VIEW_ATTRIBUTES } from './native-valid-a * optimized output (e.g. the Android `accessible` default) require a differential render test. */ -const SOURCE_HEADER = `import { Text, View } from 'react-native';\n`; +const SOURCE_HEADER = `import { Image, Text, View } from 'react-native';\n`; interface OptimizedHost { optimized: boolean; @@ -65,6 +66,7 @@ function optimizeAndInspect(source: string, optimizer: Optimizer, originalName: const viewSource = (attributes: string) => `${SOURCE_HEADER}const element = ;`; const textSource = (attributes: string) => `${SOURCE_HEADER}const element = hello;`; +const imageSource = (attributes: string) => `${SOURCE_HEADER}const element = ;`; /** * Props the wrapper translates to a different native prop. Passing them through verbatim drops @@ -98,6 +100,27 @@ const TEXT_PASSTHROUGH_PROPS = [ 'maxFontSizeMultiplier={1.5}', ]; +const IMAGE_BASE_SOURCE = 'source={{ uri: "x", width: 16, height: 16 }}'; + +const IMAGE_WRAPPER_ONLY_PROPS = [ + `${IMAGE_BASE_SOURCE} alt="label"`, + `${IMAGE_BASE_SOURCE} aria-hidden={true}`, + `${IMAGE_BASE_SOURCE} aria-live="polite"`, + `${IMAGE_BASE_SOURCE} aria-valuenow={5}`, + `${IMAGE_BASE_SOURCE} id="image-id"`, + `${IMAGE_BASE_SOURCE} tabIndex={0}`, + `${IMAGE_BASE_SOURCE} defaultSource={{ uri: "fallback" }}`, + `${IMAGE_BASE_SOURCE} onLoad={() => {}}`, +]; + +const IMAGE_PASSTHROUGH_PROPS = [ + IMAGE_BASE_SOURCE, + 'source={{ uri: "x" }} width={16} height={16}', + 'src="https://example.com/a.png" width={16} height={16}', + `${IMAGE_BASE_SOURCE} resizeMode="contain" tintColor="red"`, + `${IMAGE_BASE_SOURCE} style={{ objectFit: "fill", tintColor: "red" }}`, +]; + describe('native attribute conformance', () => { it('derives a sane native attribute set from the installed React Native', () => { // Extraction must not silently collapse (e.g. if React Native restructured these configs). @@ -118,15 +141,24 @@ describe('native attribute conformance', () => { for (const attribute of ['numberOfLines', 'allowFontScaling', 'ellipsizeMode', 'selectable']) { expect(NATIVE_TEXT_ATTRIBUTES.has(attribute), `expected "${attribute}" to be a native Text attribute`).toBe(true); } + for (const attribute of ['source', 'src', 'resizeMode', 'tintColor']) { + expect(NATIVE_IMAGE_ATTRIBUTES.has(attribute), `expected "${attribute}" to be a native Image attribute`).toBe( + true + ); + } // ...and wrapper-only props must NOT be (otherwise the test could not catch the bug class). for (const attribute of ['aria-hidden', 'aria-live', 'aria-labelledby', 'aria-valuenow', 'tabIndex', 'id']) { expect(NATIVE_VIEW_ATTRIBUTES.has(attribute), `"${attribute}" must not be a native attribute`).toBe(false); } + for (const attribute of ['alt', 'aria-hidden']) { + expect(NATIVE_IMAGE_ATTRIBUTES.has(attribute), `"${attribute}" must not be a native Image attribute`).toBe(false); + } }); it('exercises the optimized path (otherwise conformance would pass vacuously)', () => { expect(optimizeAndInspect(viewSource('testID="element"'), viewOptimizer, 'View')?.optimized).toBe(true); expect(optimizeAndInspect(textSource('numberOfLines={1}'), textOptimizer, 'Text')?.optimized).toBe(true); + expect(optimizeAndInspect(imageSource(IMAGE_BASE_SOURCE), imageOptimizer, 'Image')?.optimized).toBe(true); }); describe('View', () => { @@ -156,4 +188,18 @@ describe('native attribute conformance', () => { } ); }); + + describe('Image', () => { + it.each([...IMAGE_WRAPPER_ONLY_PROPS, ...IMAGE_PASSTHROUGH_PROPS])( + 'leaves only native attributes on the host for ', + (attributes) => { + const result = optimizeAndInspect(imageSource(attributes), imageOptimizer, 'Image'); + if (!result?.optimized) return; // bailed out: nothing reaches the native component + const leaked = result.attributes.filter((attribute) => !NATIVE_IMAGE_ATTRIBUTES.has(attribute)); + expect(leaked, `optimized leaks non-native attribute(s): ${leaked.join(', ')}`).toEqual( + [] + ); + } + ); + }); }); diff --git a/packages/react-native-boost/src/plugin/__tests__/native-valid-attributes.ts b/packages/react-native-boost/src/plugin/__tests__/native-valid-attributes.ts index ea089a2..fd9c951 100644 --- a/packages/react-native-boost/src/plugin/__tests__/native-valid-attributes.ts +++ b/packages/react-native-boost/src/plugin/__tests__/native-valid-attributes.ts @@ -27,6 +27,7 @@ const VIEW_CONFIG_SOURCES = { viewIos: 'Libraries/NativeComponent/BaseViewConfig.ios.js', viewAndroid: 'Libraries/NativeComponent/BaseViewConfig.android.js', text: 'Libraries/Text/TextNativeComponent.js', + image: 'Libraries/Image/ImageViewNativeComponent.js', } as const; /** @@ -131,6 +132,7 @@ function extractValidAttributeKeys(subpath: string): Set { const viewAttributesIos = extractValidAttributeKeys(VIEW_CONFIG_SOURCES.viewIos); const viewAttributesAndroid = extractValidAttributeKeys(VIEW_CONFIG_SOURCES.viewAndroid); const textComponentAttributes = extractValidAttributeKeys(VIEW_CONFIG_SOURCES.text); +const imageComponentAttributes = extractValidAttributeKeys(VIEW_CONFIG_SOURCES.image); export const NATIVE_VIEW_ATTRIBUTES: ReadonlySet = new Set([...viewAttributesIos, ...viewAttributesAndroid]); @@ -138,3 +140,8 @@ export const NATIVE_TEXT_ATTRIBUTES: ReadonlySet = new Set([ ...NATIVE_VIEW_ATTRIBUTES, ...textComponentAttributes, ]); + +export const NATIVE_IMAGE_ATTRIBUTES: ReadonlySet = new Set([ + ...NATIVE_VIEW_ATTRIBUTES, + ...imageComponentAttributes, +]); diff --git a/packages/react-native-boost/src/plugin/optimizers/image/index.ts b/packages/react-native-boost/src/plugin/optimizers/image/index.ts index c2c6a6c..6ed3ffe 100644 --- a/packages/react-native-boost/src/plugin/optimizers/image/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/image/index.ts @@ -21,10 +21,16 @@ const IMAGE_BAILOUT_PROPS = new Set([ 'aria-hidden', 'aria-label', 'aria-labelledby', + 'aria-live', 'aria-selected', + 'aria-valuemax', + 'aria-valuemin', + 'aria-valuenow', + 'aria-valuetext', 'children', 'crossOrigin', 'defaultSource', + 'id', 'internal_analyticTag', 'loadingIndicatorSource', 'onError', @@ -36,6 +42,7 @@ const IMAGE_BAILOUT_PROPS = new Set([ 'ref', 'referrerPolicy', 'srcSet', + 'tabIndex', ]); const IMAGE_BASE_STYLE = t.objectExpression([t.objectProperty(t.identifier('overflow'), t.stringLiteral('hidden'))]); From 8ebcacc5dc3d261ad0ae00a152e9f7201965e2b0 Mon Sep 17 00:00:00 2001 From: Adam Ivancza Date: Fri, 26 Jun 2026 12:04:19 +0200 Subject: [PATCH 3/9] feat: expand Image optimizer coverage --- .../native-attribute-conformance.test.ts | 2 ++ .../__tests__/fixtures/native-props/code.js | 11 +++++++ .../__tests__/fixtures/native-props/output.js | 32 +++++++++++++++++++ .../__tests__/fixtures/source-array/code.js | 11 +++++++ .../__tests__/fixtures/source-array/output.js | 30 +++++++++++++++++ .../src/plugin/optimizers/image/index.ts | 22 ++++++++++--- 6 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/native-props/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/native-props/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/source-array/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/source-array/output.js diff --git a/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts b/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts index de1bcf7..a8f5682 100644 --- a/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts +++ b/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts @@ -116,9 +116,11 @@ const IMAGE_WRAPPER_ONLY_PROPS = [ const IMAGE_PASSTHROUGH_PROPS = [ IMAGE_BASE_SOURCE, 'source={{ uri: "x" }} width={16} height={16}', + 'source={[{ uri: "x", width: 16, height: 16 }, { uri: "y", width: 32, height: 32, scale: 2 }]} style={{ width: 16, height: 16 }}', 'src="https://example.com/a.png" width={16} height={16}', `${IMAGE_BASE_SOURCE} resizeMode="contain" tintColor="red"`, `${IMAGE_BASE_SOURCE} style={{ objectFit: "fill", tintColor: "red" }}`, + `${IMAGE_BASE_SOURCE} blurRadius={2} resizeMethod="resize" resizeMultiplier={2} progressiveRenderingEnabled={true} fadeDuration={0} capInsets={{ top: 1, left: 2, bottom: 3, right: 4 }}`, ]; describe('native attribute conformance', () => { diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/native-props/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/native-props/code.js new file mode 100644 index 0000000..c82a401 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/native-props/code.js @@ -0,0 +1,11 @@ +import { Image } from 'react-native'; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/native-props/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/native-props/output.js new file mode 100644 index 0000000..2030f3b --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/native-props/output.js @@ -0,0 +1,32 @@ +import { NativeImage as _NativeImage } from 'react-native-boost/runtime'; +import { Image } from 'react-native'; +<_NativeImage + blurRadius={2} + resizeMethod="resize" + resizeMultiplier={2} + progressiveRenderingEnabled={true} + fadeDuration={0} + capInsets={{ + top: 1, + left: 2, + bottom: 3, + right: 4, + }} + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + }, + ]} + resizeMode="cover" +/>; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/source-array/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/source-array/code.js new file mode 100644 index 0000000..0ba81a6 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/source-array/code.js @@ -0,0 +1,11 @@ +import { Image } from 'react-native'; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/source-array/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/source-array/output.js new file mode 100644 index 0000000..0663833 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/source-array/output.js @@ -0,0 +1,30 @@ +import { NativeImage as _NativeImage } from 'react-native-boost/runtime'; +import { Image } from 'react-native'; +<_NativeImage + width={16} + height={16} + style={[ + {}, + { + overflow: 'hidden', + }, + { + opacity: 0.9, + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + scale: 1, + }, + { + uri: 'logo@2x.png', + width: 32, + height: 32, + scale: 2, + }, + ]} + resizeMode="cover" +/>; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/index.ts b/packages/react-native-boost/src/plugin/optimizers/image/index.ts index 6ed3ffe..0d39620 100644 --- a/packages/react-native-boost/src/plugin/optimizers/image/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/image/index.ts @@ -123,6 +123,7 @@ export const imageOptimizer: Optimizer = (path, logger, _options, platform) => { type NativeSource = { sourceAttribute: t.JSXAttribute; sourceArray: t.ArrayExpression; + consumesSizeProps: boolean; width?: t.Expression; height?: t.Expression; }; @@ -142,7 +143,8 @@ function processImageProps(path: NodePath, nativeSource: Na if (!t.isJSXAttribute(attribute)) return true; if (consumed.has(attribute)) return false; const name = attribute.name.name; - return name !== 'width' && name !== 'height' && name !== 'resizeMode' && name !== 'tintColor'; + if (nativeSource.consumesSizeProps && (name === 'width' || name === 'height')) return false; + return name !== 'resizeMode' && name !== 'tintColor'; }); const explicitResizeMode = getAttributeExpression(path.node.attributes, 'resizeMode'); @@ -175,18 +177,27 @@ function buildNativeSource(attributes: Array Date: Fri, 26 Jun 2026 12:19:59 +0200 Subject: [PATCH 4/9] test: cover Image view props --- .../native-attribute-conformance.test.ts | 1 + .../__tests__/fixtures/view-props/code.js | 14 ++++++++ .../__tests__/fixtures/view-props/output.js | 34 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/output.js diff --git a/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts b/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts index a8f5682..ce5dcdc 100644 --- a/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts +++ b/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts @@ -121,6 +121,7 @@ const IMAGE_PASSTHROUGH_PROPS = [ `${IMAGE_BASE_SOURCE} resizeMode="contain" tintColor="red"`, `${IMAGE_BASE_SOURCE} style={{ objectFit: "fill", tintColor: "red" }}`, `${IMAGE_BASE_SOURCE} blurRadius={2} resizeMethod="resize" resizeMultiplier={2} progressiveRenderingEnabled={true} fadeDuration={0} capInsets={{ top: 1, left: 2, bottom: 3, right: 4 }}`, + `${IMAGE_BASE_SOURCE} accessible={true} accessibilityLabel="logo" accessibilityRole="image" accessibilityHint="opens logo" accessibilityValue={{ text: "loaded" }} accessibilityState={{ selected: true }} nativeID="logo" pointerEvents="none" collapsable={false}`, ]; describe('native attribute conformance', () => { diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/code.js new file mode 100644 index 0000000..2337259 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/code.js @@ -0,0 +1,14 @@ +import { Image } from 'react-native'; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/output.js new file mode 100644 index 0000000..174d391 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/output.js @@ -0,0 +1,34 @@ +import { NativeImage as _NativeImage } from 'react-native-boost/runtime'; +import { Image } from 'react-native'; +<_NativeImage + accessible={true} + accessibilityLabel="logo" + accessibilityRole="image" + accessibilityHint="opens logo" + accessibilityValue={{ + text: 'loaded', + }} + accessibilityState={{ + selected: true, + }} + nativeID="logo" + pointerEvents="none" + collapsable={false} + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + }, + ]} + resizeMode="cover" +/>; From f04a39ae7a688046541c79dc65d8a31e6c896a2d Mon Sep 17 00:00:00 2001 From: Adam Ivancza Date: Fri, 26 Jun 2026 14:22:31 +0200 Subject: [PATCH 5/9] feat: support Image request header props --- .../native-attribute-conformance.test.ts | 2 + .../dynamic-request-headers-bails/code.js | 11 +++ .../dynamic-request-headers-bails/output.js | 9 +++ .../fixtures/request-headers/code.js | 11 +++ .../fixtures/request-headers/output.js | 48 +++++++++++++ .../__tests__/fixtures/view-props/code.js | 6 ++ .../__tests__/fixtures/view-props/output.js | 6 ++ .../src/plugin/optimizers/image/index.ts | 70 +++++++++++++++++-- 8 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-bails/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-bails/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/output.js diff --git a/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts b/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts index ce5dcdc..dcaaa3b 100644 --- a/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts +++ b/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts @@ -122,6 +122,8 @@ const IMAGE_PASSTHROUGH_PROPS = [ `${IMAGE_BASE_SOURCE} style={{ objectFit: "fill", tintColor: "red" }}`, `${IMAGE_BASE_SOURCE} blurRadius={2} resizeMethod="resize" resizeMultiplier={2} progressiveRenderingEnabled={true} fadeDuration={0} capInsets={{ top: 1, left: 2, bottom: 3, right: 4 }}`, `${IMAGE_BASE_SOURCE} accessible={true} accessibilityLabel="logo" accessibilityRole="image" accessibilityHint="opens logo" accessibilityValue={{ text: "loaded" }} accessibilityState={{ selected: true }} nativeID="logo" pointerEvents="none" collapsable={false}`, + `${IMAGE_BASE_SOURCE} onLayout={() => {}} borderRadius={4} borderTopLeftRadius={1} borderTopRightRadius={2} borderBottomLeftRadius={3} borderBottomRightRadius={4}`, + `${IMAGE_BASE_SOURCE} crossOrigin="use-credentials" referrerPolicy="origin"`, ]; describe('native attribute conformance', () => { diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-bails/code.js new file mode 100644 index 0000000..7453a30 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-bails/code.js @@ -0,0 +1,11 @@ +import { Image } from 'react-native'; + +const policy = getPolicy(); + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-bails/output.js new file mode 100644 index 0000000..7e2141d --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-bails/output.js @@ -0,0 +1,9 @@ +import { Image } from 'react-native'; +const policy = getPolicy(); +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/code.js new file mode 100644 index 0000000..ba242bd --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/code.js @@ -0,0 +1,11 @@ +import { Image } from 'react-native'; + +; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/output.js new file mode 100644 index 0000000..4cdc487 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/output.js @@ -0,0 +1,48 @@ +import { NativeImage as _NativeImage } from 'react-native-boost/runtime'; +import { Image } from 'react-native'; +<_NativeImage + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'https://example.com/logo.png', + headers: { + 'Access-Control-Allow-Credentials': 'true', + 'Referrer-Policy': 'no-referrer', + }, + width: 16, + height: 16, + }, + ]} + resizeMode="cover" +/>; +<_NativeImage + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + headers: { + 'Access-Control-Allow-Credentials': 'true', + 'Referrer-Policy': 'origin', + }, + }, + ]} + resizeMode="cover" +/>; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/code.js index 2337259..066a667 100644 --- a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/code.js +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/code.js @@ -11,4 +11,10 @@ import { Image } from 'react-native'; nativeID="logo" pointerEvents="none" collapsable={false} + onLayout={() => {}} + borderRadius={4} + borderTopLeftRadius={1} + borderTopRightRadius={2} + borderBottomLeftRadius={3} + borderBottomRightRadius={4} />; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/output.js index 174d391..3be9364 100644 --- a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/output.js @@ -14,6 +14,12 @@ import { Image } from 'react-native'; nativeID="logo" pointerEvents="none" collapsable={false} + onLayout={() => {}} + borderRadius={4} + borderTopLeftRadius={1} + borderTopRightRadius={2} + borderBottomLeftRadius={3} + borderBottomRightRadius={4} style={[ { width: 16, diff --git a/packages/react-native-boost/src/plugin/optimizers/image/index.ts b/packages/react-native-boost/src/plugin/optimizers/image/index.ts index 0d39620..9e3fd9f 100644 --- a/packages/react-native-boost/src/plugin/optimizers/image/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/image/index.ts @@ -4,6 +4,7 @@ import PluginError from '../../utils/plugin-error'; import { BailoutCheck, getFirstBailoutReason } from '../../utils/helpers'; import { hasBlacklistedProperty, + hasBlacklistedPropertyInSpread, isIgnoredLine, isForcedLine, isReactNativeImport, @@ -28,7 +29,6 @@ const IMAGE_BAILOUT_PROPS = new Set([ 'aria-valuenow', 'aria-valuetext', 'children', - 'crossOrigin', 'defaultSource', 'id', 'internal_analyticTag', @@ -40,11 +40,12 @@ const IMAGE_BAILOUT_PROPS = new Set([ 'onPartialLoad', 'onProgress', 'ref', - 'referrerPolicy', 'srcSet', 'tabIndex', ]); +const IMAGE_REQUEST_HEADER_PROPS = new Set(['crossOrigin', 'referrerPolicy']); + const IMAGE_BASE_STYLE = t.objectExpression([t.objectProperty(t.identifier('overflow'), t.stringLiteral('hidden'))]); const OBJECT_FIT_TO_RESIZE_MODE: Record = { @@ -68,6 +69,10 @@ export const imageOptimizer: Optimizer = (path, logger, _options, platform) => { reason: 'contains unsupported Image props', shouldBail: () => hasBlacklistedProperty(path, IMAGE_BAILOUT_PROPS), }, + { + reason: 'has a spread that may carry translated Image request headers', + shouldBail: () => hasBlacklistedPropertyInSpread(path, IMAGE_REQUEST_HEADER_PROPS), + }, { reason: 'contains non-empty children', shouldBail: () => parent.children.some((child) => !t.isJSXText(child) || child.value.trim() !== ''), @@ -122,6 +127,7 @@ export const imageOptimizer: Optimizer = (path, logger, _options, platform) => { type NativeSource = { sourceAttribute: t.JSXAttribute; + requestHeaderAttributes: t.JSXAttribute[]; sourceArray: t.ArrayExpression; consumesSizeProps: boolean; width?: t.Expression; @@ -136,7 +142,7 @@ type StyleInfo = { } | null; function processImageProps(path: NodePath, nativeSource: NativeSource, styleInfo: StyleInfo) { - const consumed = new Set([nativeSource.sourceAttribute]); + const consumed = new Set([nativeSource.sourceAttribute, ...nativeSource.requestHeaderAttributes]); if (styleInfo?.styleAttribute) consumed.add(styleInfo.styleAttribute); const remaining = path.node.attributes.filter((attribute) => { @@ -162,6 +168,9 @@ function processImageProps(path: NodePath, nativeSource: Na } function buildNativeSource(attributes: Array): NativeSource | undefined { + const requestHeaders = buildRequestHeaders(attributes); + if (!requestHeaders) return undefined; + const src = findAttribute(attributes, 'src'); if (src) { const uri = getAttributeValueExpression(src); @@ -169,10 +178,11 @@ function buildNativeSource(attributes: Array 0) return undefined; return { sourceAttribute: source, + requestHeaderAttributes: requestHeaders.attributes, sourceArray: t.cloneNode(sourceExpression, true), consumesSizeProps: false, }; @@ -205,13 +217,61 @@ function buildNativeSource(attributes: Array): RequestHeaders | undefined { + const crossOrigin = findAttribute(attributes, 'crossOrigin'); + const referrerPolicy = findAttribute(attributes, 'referrerPolicy'); + const headerAttributes = [crossOrigin, referrerPolicy].filter( + (attribute): attribute is t.JSXAttribute => attribute !== undefined + ); + + const headerProperties: t.ObjectProperty[] = []; + + if (crossOrigin) { + const value = getAttributeValueExpression(crossOrigin); + if (!t.isStringLiteral(value)) return undefined; + if (value.value === 'use-credentials') { + headerProperties.push( + t.objectProperty(t.stringLiteral('Access-Control-Allow-Credentials'), t.stringLiteral('true')) + ); + } + } + + if (referrerPolicy) { + const value = getAttributeValueExpression(referrerPolicy); + if (!t.isStringLiteral(value)) return undefined; + headerProperties.push(t.objectProperty(t.stringLiteral('Referrer-Policy'), t.cloneNode(value, true))); + } + + return { + attributes: headerAttributes, + headers: t.objectExpression(headerProperties), + }; +} + +function buildSourceObject(sourceObject: t.ObjectExpression, requestHeaders: RequestHeaders): t.ObjectExpression { + if (requestHeaders.headers.properties.length === 0) return t.cloneNode(sourceObject, true); + + const uri = getObjectPropertyExpression(sourceObject, 'uri'); + if (!uri || (t.isStringLiteral(uri) && uri.value === '')) return t.cloneNode(sourceObject, true); + + const nativeSource = t.cloneNode(sourceObject, true); + nativeSource.properties.push(t.objectProperty(t.identifier('headers'), t.cloneNode(requestHeaders.headers, true))); + return nativeSource; +} + function buildStyle(nativeSource: NativeSource, styleInfo: StyleInfo): t.ArrayExpression { return t.arrayExpression([ t.objectExpression([ From 896d6e002bd18f8149bc7f0d246e6e302794832b Mon Sep 17 00:00:00 2001 From: Adam Ivancza Date: Fri, 26 Jun 2026 14:52:18 +0200 Subject: [PATCH 6/9] feat: support Image accessibility props --- .../native-attribute-conformance.test.ts | 3 + .../__tests__/parity/mocks/boost-runtime.ts | 1 + .../fixtures/accessibility-props/code.js | 34 +++++ .../fixtures/accessibility-props/output.js | 108 ++++++++++++++ .../accessibility-spread-bails/code.js | 5 + .../accessibility-spread-bails/output.js | 12 ++ .../src/plugin/optimizers/image/index.ts | 137 ++++++++++++++++-- .../src/runtime/__tests__/index.test.ts | 85 +++++++++++ .../react-native-boost/src/runtime/index.ts | 73 ++++++++++ .../src/runtime/index.web.ts | 5 + 10 files changed, 450 insertions(+), 13 deletions(-) create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-props/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-props/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-spread-bails/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-spread-bails/output.js diff --git a/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts b/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts index dcaaa3b..f972875 100644 --- a/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts +++ b/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts @@ -104,7 +104,10 @@ const IMAGE_BASE_SOURCE = 'source={{ uri: "x", width: 16, height: 16 }}'; const IMAGE_WRAPPER_ONLY_PROPS = [ `${IMAGE_BASE_SOURCE} alt="label"`, + `${IMAGE_BASE_SOURCE} aria-label="label"`, `${IMAGE_BASE_SOURCE} aria-hidden={true}`, + `${IMAGE_BASE_SOURCE} aria-labelledby="label-id"`, + `${IMAGE_BASE_SOURCE} aria-busy={true} aria-disabled={false} accessibilityState={{ checked: true }}`, `${IMAGE_BASE_SOURCE} aria-live="polite"`, `${IMAGE_BASE_SOURCE} aria-valuenow={5}`, `${IMAGE_BASE_SOURCE} id="image-id"`, diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/boost-runtime.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/boost-runtime.ts index 3100e86..56d92fd 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/boost-runtime.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/boost-runtime.ts @@ -13,3 +13,4 @@ export const NativeImage = NativeViewCapturer; export const processTextStyle = (style: unknown): Record => (style ? { style } : {}); export const processViewStyle = (style: unknown): Record => (style ? { style } : {}); export const processAccessibilityProps = (props: Record): Record => props; +export const processImageAccessibilityProps = (props: Record): Record => props; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-props/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-props/code.js new file mode 100644 index 0000000..f0a15d7 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-props/code.js @@ -0,0 +1,34 @@ +import { Image } from 'react-native'; + +const label = getLabel(); +const labelledBy = getLabelledBy(); + +Logo; + +Alt; + +; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-props/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-props/output.js new file mode 100644 index 0000000..215dff3 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-props/output.js @@ -0,0 +1,108 @@ +import { + processImageAccessibilityProps as _processImageAccessibilityProps, + NativeImage as _NativeImage, +} from 'react-native-boost/runtime'; +import { Image } from 'react-native'; +const label = getLabel(); +const labelledBy = getLabelledBy(); +<_NativeImage + {..._processImageAccessibilityProps({ + alt: 'Logo', + accessible: false, + accessibilityLabel: 'Fallback', + })} + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + }, + ]} + resizeMode="cover" +/>; +<_NativeImage + {..._processImageAccessibilityProps({ + 'aria-label': label, + 'accessibilityLabel': 'Fallback', + 'alt': 'Alt', + })} + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + }, + ]} + resizeMode="cover" +/>; +<_NativeImage + {..._processImageAccessibilityProps({ + 'accessibilityState': { + busy: false, + checked: true, + }, + 'aria-busy': true, + 'aria-disabled': false, + })} + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + }, + ]} + resizeMode="cover" +/>; +<_NativeImage + {..._processImageAccessibilityProps({ + 'aria-hidden': true, + 'accessible': true, + 'importantForAccessibility': 'yes', + 'aria-labelledby': labelledBy, + 'accessibilityLabelledBy': 'fallback', + })} + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + }, + ]} + resizeMode="cover" +/>; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-spread-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-spread-bails/code.js new file mode 100644 index 0000000..9978599 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-spread-bails/code.js @@ -0,0 +1,5 @@ +import { Image } from 'react-native'; + +const props = { 'aria-label': 'Logo' }; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-spread-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-spread-bails/output.js new file mode 100644 index 0000000..e15bac3 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-spread-bails/output.js @@ -0,0 +1,12 @@ +import { Image } from 'react-native'; +const props = { + 'aria-label': 'Logo', +}; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/index.ts b/packages/react-native-boost/src/plugin/optimizers/image/index.ts index 9e3fd9f..7f4fc2b 100644 --- a/packages/react-native-boost/src/plugin/optimizers/image/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/image/index.ts @@ -3,6 +3,8 @@ import { HubFile, Optimizer } from '../../types'; import PluginError from '../../utils/plugin-error'; import { BailoutCheck, getFirstBailoutReason } from '../../utils/helpers'; import { + addFileImportHint, + buildPropertiesFromAttributes, hasBlacklistedProperty, hasBlacklistedPropertyInSpread, isIgnoredLine, @@ -12,18 +14,10 @@ import { isValidJSXComponent, replaceWithNativeComponent, } from '../../utils/common'; +import { RUNTIME_MODULE_NAME } from '../../utils/constants'; const IMAGE_BAILOUT_PROPS = new Set([ - 'alt', - 'aria-busy', - 'aria-checked', - 'aria-disabled', - 'aria-expanded', - 'aria-hidden', - 'aria-label', - 'aria-labelledby', 'aria-live', - 'aria-selected', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow', @@ -44,6 +38,14 @@ const IMAGE_BAILOUT_PROPS = new Set([ 'tabIndex', ]); +const IMAGE_ARIA_STATE_PROPS = new Set([ + 'aria-busy', + 'aria-checked', + 'aria-disabled', + 'aria-expanded', + 'aria-selected', +]); + const IMAGE_REQUEST_HEADER_PROPS = new Set(['crossOrigin', 'referrerPolicy']); const IMAGE_BASE_STYLE = t.objectExpression([t.objectProperty(t.identifier('overflow'), t.stringLiteral('hidden'))]); @@ -73,6 +75,10 @@ export const imageOptimizer: Optimizer = (path, logger, _options, platform) => { reason: 'has a spread that may carry translated Image request headers', shouldBail: () => hasBlacklistedPropertyInSpread(path, IMAGE_REQUEST_HEADER_PROPS), }, + { + reason: 'has a spread that may carry translated Image accessibility props', + shouldBail: () => hasImageAccessibilitySpreadConflict(path), + }, { reason: 'contains non-empty children', shouldBail: () => parent.children.some((child) => !t.isJSXText(child) || child.value.trim() !== ''), @@ -121,7 +127,7 @@ export const imageOptimizer: Optimizer = (path, logger, _options, platform) => { logger.optimized({ component: 'Image', path }); - processImageProps(path, nativeSource, styleInfo); + processImageProps(path, file, nativeSource, styleInfo); replaceWithNativeComponent(path, parent, file, 'NativeImage'); }; @@ -141,8 +147,23 @@ type StyleInfo = { tintColor?: t.Expression; } | null; -function processImageProps(path: NodePath, nativeSource: NativeSource, styleInfo: StyleInfo) { - const consumed = new Set([nativeSource.sourceAttribute, ...nativeSource.requestHeaderAttributes]); +type ImageAccessibilityInfo = { + attributes: t.JSXAttribute[]; + spreadAttribute: t.JSXSpreadAttribute; +}; + +function processImageProps( + path: NodePath, + file: HubFile, + nativeSource: NativeSource, + styleInfo: StyleInfo +) { + const accessibilityInfo = buildImageAccessibilityInfo(path, file); + const consumed = new Set([ + nativeSource.sourceAttribute, + ...nativeSource.requestHeaderAttributes, + ...(accessibilityInfo?.attributes ?? []), + ]); if (styleInfo?.styleAttribute) consumed.add(styleInfo.styleAttribute); const remaining = path.node.attributes.filter((attribute) => { @@ -158,13 +179,93 @@ function processImageProps(path: NodePath, nativeSource: Na path.node.attributes = [ ...remaining, + accessibilityInfo?.spreadAttribute, makeAttribute('style', buildStyle(nativeSource, styleInfo)), makeAttribute('source', nativeSource.sourceArray), makeAttribute('resizeMode', explicitResizeMode ?? styleInfo?.resizeMode ?? t.stringLiteral('cover')), explicitTintColor || styleInfo?.tintColor ? makeAttribute('tintColor', explicitTintColor ?? styleInfo!.tintColor!) : undefined, - ].filter((attribute): attribute is t.JSXAttribute => attribute !== undefined); + ].filter((attribute): attribute is t.JSXAttribute | t.JSXSpreadAttribute => attribute !== undefined); +} + +function hasImageAccessibilitySpreadConflict(path: NodePath): boolean { + const directNames = getDirectAttributeNames(path.node.attributes); + const guarded = new Set(['alt', 'aria-hidden', 'aria-label', 'aria-labelledby', ...IMAGE_ARIA_STATE_PROPS]); + + if (directNames.has('alt') || directNames.has('aria-label')) { + guarded.add('accessibilityLabel'); + } + if (directNames.has('alt') || directNames.has('aria-hidden')) { + guarded.add('accessible'); + } + if (directNames.has('aria-hidden')) guarded.add('importantForAccessibility'); + if (directNames.has('aria-labelledby')) guarded.add('accessibilityLabelledBy'); + if ([...IMAGE_ARIA_STATE_PROPS].some((name) => directNames.has(name))) { + guarded.add('accessibilityState'); + } + + return hasBlacklistedPropertyInSpread(path, guarded); +} + +function buildImageAccessibilityInfo( + path: NodePath, + file: HubFile +): ImageAccessibilityInfo | undefined { + const directNames = getDirectAttributeNames(path.node.attributes); + const hasAlt = directNames.has('alt'); + const hasLabelTrigger = hasAlt || directNames.has('aria-label'); + const hasHiddenTrigger = directNames.has('aria-hidden'); + const hasLabelledByTrigger = directNames.has('aria-labelledby'); + const hasStateTrigger = [...IMAGE_ARIA_STATE_PROPS].some((name) => directNames.has(name)); + + if (!hasLabelTrigger && !hasHiddenTrigger && !hasLabelledByTrigger && !hasStateTrigger) return undefined; + + const helperNames = new Set(); + if (hasLabelTrigger) { + helperNames.add('alt'); + helperNames.add('aria-label'); + helperNames.add('accessibilityLabel'); + } + if (hasAlt) { + helperNames.add('accessible'); + } + if (hasHiddenTrigger) { + helperNames.add('aria-hidden'); + helperNames.add('accessible'); + helperNames.add('alt'); + helperNames.add('importantForAccessibility'); + } + if (hasLabelledByTrigger) { + helperNames.add('aria-labelledby'); + helperNames.add('accessibilityLabelledBy'); + } + if (hasStateTrigger) { + helperNames.add('accessibilityState'); + for (const name of IMAGE_ARIA_STATE_PROPS) helperNames.add(name); + } + + const attributes = path.node.attributes.filter( + (attribute): attribute is t.JSXAttribute => + t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name) && helperNames.has(attribute.name.name) + ); + + if (attributes.length === 0) return undefined; + + const helperIdentifier = addFileImportHint({ + file, + nameHint: 'processImageAccessibilityProps', + path, + importName: 'processImageAccessibilityProps', + moduleName: RUNTIME_MODULE_NAME, + }); + + return { + attributes, + spreadAttribute: t.jsxSpreadAttribute( + t.callExpression(t.identifier(helperIdentifier.name), [buildPropertiesFromAttributes(attributes)]) + ), + }; } function buildNativeSource(attributes: Array): NativeSource | undefined { @@ -361,6 +462,16 @@ function getAttributeExpression( return attribute ? getAttributeValueExpression(attribute) : undefined; } +function getDirectAttributeNames(attributes: Array): Set { + const names = new Set(); + for (const attribute of attributes) { + if (t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name)) { + names.add(attribute.name.name); + } + } + return names; +} + function findAttribute( attributes: Array, name: string diff --git a/packages/react-native-boost/src/runtime/__tests__/index.test.ts b/packages/react-native-boost/src/runtime/__tests__/index.test.ts index c4530df..5555eaf 100644 --- a/packages/react-native-boost/src/runtime/__tests__/index.test.ts +++ b/packages/react-native-boost/src/runtime/__tests__/index.test.ts @@ -4,6 +4,7 @@ import { processSelectionColor, processAccessibilityProps, processViewAccessibilityProps, + processImageAccessibilityProps, getDefaultTextAccessible, clampNumberOfLines, userSelectToSelectableMap, @@ -374,6 +375,90 @@ describe('processViewAccessibilityProps', () => { }); }); +describe('processImageAccessibilityProps', () => { + it('uses alt as the fallback accessibilityLabel and forces accessible on', () => { + expect(processImageAccessibilityProps({ alt: 'Logo', accessible: false })).toEqual({ + accessibilityLabel: 'Logo', + accessible: true, + }); + }); + + it('keeps accessibilityLabel ahead of alt while still forcing accessible on', () => { + expect(processImageAccessibilityProps({ alt: 'Logo', accessibilityLabel: 'Fallback' })).toEqual({ + accessibilityLabel: 'Fallback', + accessible: true, + }); + }); + + it('lets aria-label win over accessibilityLabel and alt', () => { + expect( + processImageAccessibilityProps({ + 'aria-label': 'ARIA', + 'accessibilityLabel': 'Fallback', + 'alt': 'Alt', + }).accessibilityLabel + ).toBe('ARIA'); + }); + + it('falls back to explicit accessible when a dynamic alt is undefined', () => { + expect(processImageAccessibilityProps({ alt: undefined, accessible: false }).accessible).toBe(false); + }); + + it('uses aria-hidden to force accessible off on iOS while preserving importantForAccessibility', () => { + Platform.OS = 'ios'; + expect( + processImageAccessibilityProps({ + 'aria-hidden': true, + 'accessible': true, + 'importantForAccessibility': 'yes', + }) + ).toEqual({ + accessible: false, + importantForAccessibility: 'yes', + }); + }); + + it('uses aria-hidden to force importantForAccessibility on Android', () => { + Platform.OS = 'android'; + expect( + processImageAccessibilityProps({ + 'aria-hidden': true, + 'accessible': true, + 'importantForAccessibility': 'yes', + }) + ).toEqual({ + accessible: true, + importantForAccessibility: 'no-hide-descendants', + }); + }); + + it('maps aria-labelledby to accessibilityLabelledBy on Android without splitting', () => { + Platform.OS = 'android'; + expect( + processImageAccessibilityProps({ + 'aria-labelledby': 'a, b', + 'accessibilityLabelledBy': 'fallback', + }).accessibilityLabelledBy + ).toBe('a, b'); + }); + + it('aggregates aria state fields over a passed accessibilityState', () => { + expect( + processImageAccessibilityProps({ + 'accessibilityState': { busy: false, checked: true }, + 'aria-busy': true, + 'aria-disabled': false, + }).accessibilityState + ).toEqual({ + busy: true, + checked: true, + disabled: false, + expanded: undefined, + selected: undefined, + }); + }); +}); + describe('clampNumberOfLines', () => { it('clamps negative values to 0', () => { expect(clampNumberOfLines(-1)).toBe(0); diff --git a/packages/react-native-boost/src/runtime/index.ts b/packages/react-native-boost/src/runtime/index.ts index 6f9d045..60eedaf 100644 --- a/packages/react-native-boost/src/runtime/index.ts +++ b/packages/react-native-boost/src/runtime/index.ts @@ -302,6 +302,79 @@ export function processViewAccessibilityProps(props: Record): Recor return result; } +/** + * Normalizes the Image wrapper's accessibility aliases before props reach `NativeImage`. + * + * @remarks + * Image's rules are close to View's ARIA merge, but not identical: `alt` is an accessibilityLabel + * fallback and also forces `accessible` on. Keep this separate from `processViewAccessibilityProps` + * so those Image-only precedence rules stay explicit. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function processImageAccessibilityProps(props: Record): Record { + const { + alt, + accessible, + accessibilityLabel, + accessibilityLabelledBy, + accessibilityState, + importantForAccessibility, + ['aria-label']: ariaLabel, + ['aria-labelledby']: ariaLabelledBy, + ['aria-busy']: ariaBusy, + ['aria-checked']: ariaChecked, + ['aria-disabled']: ariaDisabled, + ['aria-expanded']: ariaExpanded, + ['aria-hidden']: ariaHidden, + ['aria-selected']: ariaSelected, + ...restProperties + } = props; + + const result = restProperties; + const normalizedLabel = ariaLabel ?? accessibilityLabel ?? alt; + if (normalizedLabel !== undefined) result.accessibilityLabel = normalizedLabel; + + if (Platform.OS === 'android') { + const normalizedLabelledBy = ariaLabelledBy ?? accessibilityLabelledBy; + if (normalizedLabelledBy !== undefined) result.accessibilityLabelledBy = normalizedLabelledBy; + } else if (accessibilityLabelledBy !== undefined) { + result.accessibilityLabelledBy = accessibilityLabelledBy; + } + + if (ariaHidden === true && Platform.OS === 'ios') { + result.accessible = false; + } else if (alt !== undefined) { + result.accessible = true; + } else if (accessible !== undefined) { + result.accessible = accessible; + } + + if (ariaHidden === true && Platform.OS !== 'ios') { + result.importantForAccessibility = 'no-hide-descendants'; + } else if (importantForAccessibility !== undefined) { + result.importantForAccessibility = importantForAccessibility; + } + + if ( + accessibilityState != null || + ariaBusy != null || + ariaChecked != null || + ariaDisabled != null || + ariaExpanded != null || + ariaSelected != null + ) { + result.accessibilityState = { + busy: ariaBusy ?? accessibilityState?.busy, + checked: ariaChecked ?? accessibilityState?.checked, + disabled: ariaDisabled ?? accessibilityState?.disabled, + expanded: ariaExpanded ?? accessibilityState?.expanded, + selected: ariaSelected ?? accessibilityState?.selected, + }; + } + + return result; +} + export * from './types'; export * from './utils/constants'; export * from './components/native-text'; diff --git a/packages/react-native-boost/src/runtime/index.web.ts b/packages/react-native-boost/src/runtime/index.web.ts index 30901a1..1e9fa55 100644 --- a/packages/react-native-boost/src/runtime/index.web.ts +++ b/packages/react-native-boost/src/runtime/index.web.ts @@ -30,6 +30,11 @@ export function processViewAccessibilityProps(props: Record): Recor return props; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function processImageAccessibilityProps(props: Record): Record { + return props; +} + export * from './types'; export * from './utils/constants'; From 324d07fb4f4d7c8b69d40a098de302cf80c1a29a Mon Sep 17 00:00:00 2001 From: Adam Ivancza Date: Mon, 29 Jun 2026 11:47:43 +0200 Subject: [PATCH 7/9] feat(image): add boosted image optimizer parity --- .../native-attribute-conformance.test.ts | 15 +- .../src/plugin/__tests__/parity/boost.ts | 2 +- .../src/plugin/__tests__/parity/capture.tsx | 2 + .../plugin/__tests__/parity/fuzz/fuzz.test.ts | 27 +- .../plugin/__tests__/parity/fuzz/generator.ts | 31 ++- .../__tests__/parity/fuzz/vocabulary.ts | 97 +++++++ .../parity/mocks/ImageViewNativeComponent.ts | 3 + .../parity/mocks/NativeImageLoader.ts | 9 + .../parity/mocks/ReactNativeFeatureFlags.ts | 1 + .../__tests__/parity/mocks/StyleSheet.ts | 8 + .../mocks/TextInlineImageNativeComponent.ts | 3 + .../__tests__/parity/mocks/boost-runtime.ts | 4 +- .../__tests__/parity/mocks/react-native.ts | 3 +- .../parity/mocks/resolveAssetSource.ts | 3 + .../src/plugin/__tests__/parity/normalize.ts | 40 +++ .../plugin/__tests__/parity/parity.test.ts | 43 +++- .../__tests__/parity/vitest.config.parity.mts | 5 + .../src/plugin/__tests__/parity/wrapper.ts | 10 +- .../react-native-boost/src/plugin/index.ts | 10 +- .../fixtures/dynamic-source-bails/code.js | 1 + .../fixtures/dynamic-source-bails/output.js | 1 + .../fixtures/fallback-semantics/code.js | 7 + .../fixtures/fallback-semantics/output.js | 110 ++++++++ .../fixtures/force-dynamic-bails/code.js | 8 + .../fixtures/force-dynamic-bails/output.js | 14 + .../fixtures/request-headers/code.js | 2 + .../fixtures/request-headers/output.js | 29 ++- .../spread-wrapper-props-bails/code.js | 7 + .../spread-wrapper-props-bails/output.js | 25 ++ .../__tests__/fixtures/src-precedence/code.js | 3 + .../fixtures/src-precedence/output.js | 18 ++ .../__tests__/fixtures/src-prop/output.js | 5 +- .../fixtures/text-ancestor-bails/code.js | 5 + .../fixtures/text-ancestor-bails/output.js | 10 + .../optimizers/image/__tests__/index.test.ts | 241 +++++++++++++++++- .../src/plugin/optimizers/image/index.ts | 209 +++++++++++---- .../src/plugin/types/index.ts | 14 +- .../src/plugin/utils/generate-test-plugin.ts | 20 +- .../src/runtime/__tests__/index.test.ts | 14 +- .../mocks/ImageViewNativeComponent.ts | 3 + .../runtime/__tests__/mocks/react-native.ts | 1 + .../runtime/components/native-image.test.ts | 52 ++++ .../src/runtime/components/native-image.tsx | 32 ++- .../react-native-boost/src/runtime/index.ts | 6 +- packages/react-native-boost/vitest.config.ts | 13 +- 45 files changed, 1054 insertions(+), 112 deletions(-) create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/mocks/ImageViewNativeComponent.ts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/mocks/NativeImageLoader.ts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/mocks/StyleSheet.ts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/mocks/TextInlineImageNativeComponent.ts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/mocks/resolveAssetSource.ts create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/fallback-semantics/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/fallback-semantics/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-bails/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-bails/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/spread-wrapper-props-bails/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/spread-wrapper-props-bails/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-precedence/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-precedence/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/text-ancestor-bails/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/text-ancestor-bails/output.js create mode 100644 packages/react-native-boost/src/runtime/__tests__/mocks/ImageViewNativeComponent.ts create mode 100644 packages/react-native-boost/src/runtime/components/native-image.test.ts diff --git a/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts b/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts index f972875..ae846be 100644 --- a/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts +++ b/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts @@ -4,7 +4,7 @@ import { generateTestPlugin } from '../utils/generate-test-plugin'; import { imageOptimizer } from '../optimizers/image'; import { textOptimizer } from '../optimizers/text'; import { viewOptimizer } from '../optimizers/view'; -import { Optimizer } from '../types'; +import { Optimizer, TargetPlatform } from '../types'; import { NATIVE_IMAGE_ATTRIBUTES, NATIVE_TEXT_ATTRIBUTES, NATIVE_VIEW_ATTRIBUTES } from './native-valid-attributes'; /** @@ -33,7 +33,12 @@ interface OptimizedHost { * was optimized into its native counterpart and which direct attributes it carries. Returns * `null` if no JSX element was found. */ -function optimizeAndInspect(source: string, optimizer: Optimizer, originalName: string): OptimizedHost | null { +function optimizeAndInspect( + source: string, + optimizer: Optimizer, + originalName: string, + platform?: TargetPlatform +): OptimizedHost | null { let host: { name: string; attributes: string[] } | undefined; const capturePlugin = (): PluginObj => ({ @@ -57,7 +62,7 @@ function optimizeAndInspect(source: string, optimizer: Optimizer, originalName: transformSync(source, { configFile: false, babelrc: false, - plugins: ['@babel/plugin-syntax-jsx', generateTestPlugin(optimizer), capturePlugin], + plugins: ['@babel/plugin-syntax-jsx', generateTestPlugin(optimizer, {}, platform), capturePlugin], }); if (!host) return null; @@ -166,7 +171,7 @@ describe('native attribute conformance', () => { it('exercises the optimized path (otherwise conformance would pass vacuously)', () => { expect(optimizeAndInspect(viewSource('testID="element"'), viewOptimizer, 'View')?.optimized).toBe(true); expect(optimizeAndInspect(textSource('numberOfLines={1}'), textOptimizer, 'Text')?.optimized).toBe(true); - expect(optimizeAndInspect(imageSource(IMAGE_BASE_SOURCE), imageOptimizer, 'Image')?.optimized).toBe(true); + expect(optimizeAndInspect(imageSource(IMAGE_BASE_SOURCE), imageOptimizer, 'Image', 'ios')?.optimized).toBe(true); }); describe('View', () => { @@ -201,7 +206,7 @@ describe('native attribute conformance', () => { it.each([...IMAGE_WRAPPER_ONLY_PROPS, ...IMAGE_PASSTHROUGH_PROPS])( 'leaves only native attributes on the host for ', (attributes) => { - const result = optimizeAndInspect(imageSource(attributes), imageOptimizer, 'Image'); + const result = optimizeAndInspect(imageSource(attributes), imageOptimizer, 'Image', 'ios'); if (!result?.optimized) return; // bailed out: nothing reaches the native component const leaked = result.attributes.filter((attribute) => !NATIVE_IMAGE_ATTRIBUTES.has(attribute)); expect(leaked, `optimized leaks non-native attribute(s): ${leaked.join(', ')}`).toEqual( diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/boost.ts b/packages/react-native-boost/src/plugin/__tests__/parity/boost.ts index 6df3b22..8466875 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/boost.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/boost.ts @@ -24,7 +24,7 @@ interface BoostOptimized { function transformBoostCase(os: PlatformOS, jsxBody: string, preamble = ''): string { setPlatformOS(os); const source = - `import { Text, View } from 'react-native';\n${preamble}\n` + + `import { Image, Text, View } from 'react-native';\n${preamble}\n` + `export default function Case(){ return ${jsxBody}; }`; const out = transformSync(source, { configFile: false, diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/capture.tsx b/packages/react-native-boost/src/plugin/__tests__/parity/capture.tsx index c78ef85..0168047 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/capture.tsx +++ b/packages/react-native-boost/src/plugin/__tests__/parity/capture.tsx @@ -31,6 +31,8 @@ const makeCapturer = export const NativeTextCapturer = makeCapturer('NativeText'); export const NativeVirtualTextCapturer = makeCapturer('NativeVirtualText'); export const NativeViewCapturer = makeCapturer('NativeView'); +export const NativeImageCapturer = makeCapturer('NativeImage'); +export const TextInlineImageCapturer = makeCapturer('TextInlineImage'); /** Render an element and return every native host it produced, in render order. */ export function renderAndCaptureAll(element: React.ReactElement): Capture[] { diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/fuzz.test.ts b/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/fuzz.test.ts index 483634b..99af3ee 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/fuzz.test.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/fuzz.test.ts @@ -11,14 +11,14 @@ vi.mock('../../../../runtime/components/native-view', async () => ({ NativeView: (await import('../capture')).NativeViewCapturer, })); vi.mock('../../../../runtime/components/native-image', async () => ({ - NativeImage: (await import('../capture')).NativeViewCapturer, + NativeImage: (await import('../capture')).NativeImageCapturer, })); import { captureBoost } from '../boost'; import { captureWrapper } from '../wrapper'; -import { normalize } from '../normalize'; +import { normalize, normalizeImage } from '../normalize'; import { type PlatformOS } from '../mocks/Platform'; -import { elementSpecArb, platformArb, render } from './generator'; +import { elementSpecArb, platformArb, render, type Tag } from './generator'; import { divergingKeys } from './diff'; const SEED = Number(process.env.FUZZ_SEED ?? 0xb0051); @@ -53,8 +53,9 @@ async function runCase(os: PlatformOS, jsxBody: string, preamble: string): Promi if (!boost.optimized) return { status: 'skipped' }; const wrapper = await captureWrapper(os, jsxBody, preamble); - const boostNorm = normalize(boost.props); - const wrapperNorm = normalize(wrapper.props); + const normalizer = boost.which === 'NativeImage' || wrapper.which === 'NativeImage' ? normalizeImage : normalize; + const boostNorm = normalizer(boost.props); + const wrapperNorm = normalizer(wrapper.props); const keys = divergingKeys(boostNorm, wrapperNorm); if (boost.which === wrapper.which && keys.length === 0) return { status: 'match' }; @@ -88,15 +89,22 @@ describe.skipIf(DISCOVER)('parity fuzzing', () => { async () => { let optimized = 0; let skipped = 0; + const byTag: Record = { + Image: { optimized: 0, skipped: 0 }, + Text: { optimized: 0, skipped: 0 }, + View: { optimized: 0, skipped: 0 }, + }; const property = fc.asyncProperty(platformArb, elementSpecArb, async (os, spec) => { const { preamble, jsxBody } = render(spec); const result = await runCase(os, jsxBody, preamble); if (result.status === 'skipped') { skipped++; + byTag[spec.tag].skipped++; return; } optimized++; + byTag[spec.tag].optimized++; if (result.status === 'divergence') throw new Error(formatDivergence(os, jsxBody, preamble, result)); }); @@ -109,11 +117,18 @@ describe.skipIf(DISCOVER)('parity fuzzing', () => { console.log( `[fuzz] cases=${total} optimized=${optimized} skipped=${skipped} ` + `optimize-rate=${(rate * 100).toFixed(1)}% elapsed=${elapsed.toFixed(0)}ms ` + - `(${(total / (elapsed / 1000)).toFixed(1)} cases/s)` + `(${(total / (elapsed / 1000)).toFixed(1)} cases/s) ` + + `image=${byTag.Image.optimized}/${byTag.Image.optimized + byTag.Image.skipped} ` + + `text=${byTag.Text.optimized}/${byTag.Text.optimized + byTag.Text.skipped} ` + + `view=${byTag.View.optimized}/${byTag.View.optimized + byTag.View.skipped}` ); // Anti-vacuous-green guard: a generator drifting into all-bail would pass trivially. expect(rate).toBeGreaterThan(0.5); + const imageTotal = byTag.Image.optimized + byTag.Image.skipped; + const imageRate = byTag.Image.optimized / imageTotal; + expect(imageTotal).toBeGreaterThan(0); + expect(imageRate).toBeGreaterThan(0.2); }, Math.max(30_000, NUM_RUNS * 80) ); diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/generator.ts b/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/generator.ts index 7e5d415..1c92191 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/generator.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/generator.ts @@ -1,11 +1,18 @@ import fc from 'fast-check'; -import { TEXT_VOCAB, VIEW_VOCAB, TEXT_BLACKLIST_SAMPLE, type PropSpec } from './vocabulary'; +import { + IMAGE_SOURCE_VOCAB, + IMAGE_VOCAB, + TEXT_VOCAB, + VIEW_VOCAB, + TEXT_BLACKLIST_SAMPLE, + type PropSpec, +} from './vocabulary'; // The unit of generation is an abstract spec, not a raw string — fast-check shrinks the spec (drop an // attr, simplify a value, collapse dynamic→static) and the renderer turns it into valid JSX by // construction, so shrinking never explores malformed snippets. -export type Tag = 'Text' | 'View'; +export type Tag = 'Image' | 'Text' | 'View'; /** One attribute: the value's source code, and whether it is inlined or hoisted to a preamble const. */ export interface GenAttr { @@ -31,7 +38,7 @@ export interface ChildSpec { export interface ElementSpec { tag: Tag; attrs: GenAttr[]; - blacklisted: GenAttr | null; // Text only: a deliberate, low-probability bail trigger + blacklisted: GenAttr | null; // deliberate, low-probability bail trigger spreads: GenSpread[]; child: ChildSpec | null; // Text only } @@ -128,7 +135,19 @@ const viewSpecArb: fc.Arbitrary = fc.record({ child: fc.constant(null), }); -export const elementSpecArb: fc.Arbitrary = fc.oneof(textSpecArb, viewSpecArb); +const imageSourceArb: fc.Arbitrary = fc + .constantFrom(...IMAGE_SOURCE_VOCAB) + .chain((spec) => spec.arb.map((code): GenAttr => ({ name: spec.name, code, dynamic: false }))); + +const imageSpecArb: fc.Arbitrary = fc.record({ + tag: fc.constant('Image'), + attrs: fc.tuple(imageSourceArb, attrsArb(IMAGE_VOCAB)).map(([source, attrs]) => [source, ...attrs]), + blacklisted: fc.constant(null), + spreads: spreadsArb(IMAGE_VOCAB), + child: fc.constant(null), +}); + +export const elementSpecArb: fc.Arbitrary = fc.oneof(textSpecArb, viewSpecArb, imageSpecArb); export const platformArb = fc.constantFrom('ios' as const, 'android' as const); @@ -172,6 +191,10 @@ export function render(spec: ElementSpec): RenderedCase { return { preamble: declarations.join('\n'), jsxBody: `` }; } + if (spec.tag === 'Image') { + return { preamble: declarations.join('\n'), jsxBody: `` }; + } + const child = spec.child!; let childStr: string; if (child.kind === 'text' || child.kind === 'element') { diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/vocabulary.ts b/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/vocabulary.ts index 7f155d9..95eba00 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/vocabulary.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/vocabulary.ts @@ -102,6 +102,43 @@ const styleValue = fc.oneof( { weight: 1, arbitrary: fc.constantFrom('null', 'undefined', '[]', '{}') } ); +const imageStyleKeyValue = fc.constantFrom( + 'width: 16', + 'height: 16', + 'objectFit: "contain"', + 'objectFit: "cover"', + 'objectFit: "fill"', + 'resizeMode: "contain"', + 'resizeMode: "cover"', + 'resizeMode: ""', + 'tintColor: "red"', + 'borderRadius: 4' +); + +const imageStyleObject = fc + .uniqueArray(imageStyleKeyValue, { selector: (kv) => kv.slice(0, kv.indexOf(':')), maxLength: 3 }) + .map((kvs) => `{ ${kvs.join(', ')} }`); + +const imageStyleValue = fc.oneof( + { weight: 4, arbitrary: imageStyleObject }, + { + weight: 2, + arbitrary: fc + .array( + fc.oneof( + { weight: 3, arbitrary: imageStyleObject }, + { weight: 1, arbitrary: fc.constantFrom('null', 'false', '0') } + ), + { + minLength: 1, + maxLength: 3, + } + ) + .map((elements) => `[${elements.join(', ')}]`), + }, + { weight: 1, arbitrary: fc.constantFrom('{}', 'null') } +); + // ── Text ──────────────────────────────────────────────────────────────────────────────────────── export const TEXT_VOCAB: PropSpec[] = [ // Accessibility / normalized → always routed through `processAccessibilityProps` at runtime. @@ -270,3 +307,63 @@ export const VIEW_VOCAB: PropSpec[] = [ { name: 'renderToHardwareTextureAndroid', arb: bool, disposition: 'probe pass-through' }, { name: 'shouldRasterizeIOS', arb: bool, disposition: 'probe pass-through' }, ]; + +// ── Image ───────────────────────────────────────────────────────────────────────────────────────── +export const IMAGE_SOURCE_VOCAB: PropSpec[] = [ + { + name: 'source', + arb: fc.constantFrom( + '{ uri: "logo.png", width: 16, height: 16 }', + '{ uri: "logo.png", width: 16, height: 16, headers: { Authorization: "Bearer object" } }', + '{ uri: "", width: 16, height: 16 }', + '{ uri: "logo.png", width: null, height: 16 }', + '{ uri: "logo.png" }', + '[{ uri: "logo.png", width: 16, height: 16 }, { uri: "logo@2x.png", width: 32, height: 32, scale: 2 }]', + '[{ uri: "logo.png", width: 16, height: 16, headers: { Authorization: "Bearer first" } }, { uri: "logo@2x.png", width: 32, height: 32, scale: 2, headers: { Authorization: "Bearer second" } }]' + ), + disposition: 'required static source', + }, + { + name: 'src', + arb: fc.constantFrom('"https://example.com/logo.png"'), + disposition: 'required static src', + }, +]; + +export const IMAGE_VOCAB: PropSpec[] = [ + { name: 'width', arb: withNullish(fc.constantFrom('16', '20')), disposition: 'source/style size fallback' }, + { name: 'height', arb: withNullish(fc.constantFrom('16', '20')), disposition: 'source/style size fallback' }, + { name: 'style', arb: imageStyleValue, disposition: 'static image style synthesis' }, + { + name: 'resizeMode', + arb: withNullish(fc.constantFrom('"contain"', '"cover"', '"stretch"', '""')), + disposition: 'resize mode fallback', + }, + { name: 'tintColor', arb: withNullish(fc.constantFrom('"red"', '"blue"')), disposition: 'tintColor fallback' }, + { name: 'crossOrigin', arb: fc.constantFrom('"anonymous"', '"use-credentials"'), disposition: 'request headers' }, + { + name: 'referrerPolicy', + arb: fc.constantFrom('"origin"', '"no-referrer"', '"same-origin"'), + disposition: 'request headers', + }, + { name: 'alt', arb: withNullish(str), disposition: 'translate → accessibilityLabel + accessible' }, + { name: 'aria-label', arb: withNullish(str), disposition: 'translate → accessibilityLabel' }, + { name: 'aria-hidden', arb: withNullish(bool), disposition: 'platform accessibility hiding' }, + { name: 'aria-labelledby', arb: withNullish(labelledBy), disposition: 'android labelledBy translation' }, + { name: 'aria-busy', arb: withNullish(bool), disposition: 'aggregate → accessibilityState' }, + { name: 'aria-checked', arb: withNullish(checked), disposition: 'aggregate → accessibilityState' }, + { name: 'aria-disabled', arb: withNullish(bool), disposition: 'aggregate → accessibilityState' }, + { name: 'aria-expanded', arb: withNullish(bool), disposition: 'aggregate → accessibilityState' }, + { name: 'aria-selected', arb: withNullish(bool), disposition: 'aggregate → accessibilityState' }, + { name: 'accessibilityLabel', arb: withNullish(str), disposition: 'a11y label fallback' }, + { name: 'accessibilityState', arb: a11yStateObject, disposition: 'a11y state merge target' }, + { name: 'accessible', arb: withNullish(bool), disposition: 'a11y pass-through / alt override' }, + { + name: 'importantForAccessibility', + arb: fc.constantFrom('"auto"', '"yes"', '"no"', '"no-hide-descendants"'), + disposition: 'a11y pass-through / aria-hidden override', + }, + { name: 'testID', arb: withNullish(str), disposition: 'pass-through' }, + { name: 'blurRadius', arb: fc.constantFrom('0', '2'), disposition: 'native pass-through' }, + { name: 'borderRadius', arb: fc.constantFrom('0', '4'), disposition: 'native pass-through' }, +]; diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/ImageViewNativeComponent.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/ImageViewNativeComponent.ts new file mode 100644 index 0000000..76381bc --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/ImageViewNativeComponent.ts @@ -0,0 +1,3 @@ +import { NativeImageCapturer } from '../capture'; + +export default NativeImageCapturer; diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/NativeImageLoader.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/NativeImageLoader.ts new file mode 100644 index 0000000..dd9b2fe --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/NativeImageLoader.ts @@ -0,0 +1,9 @@ +const NativeImageLoader = { + getSize: () => Promise.resolve([0, 0]), + getSizeWithHeaders: () => Promise.resolve([0, 0]), + prefetchImage: () => Promise.resolve(true), + prefetchImageWithMetadata: () => Promise.resolve(true), + queryCache: () => Promise.resolve({}), +}; + +export default NativeImageLoader; diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/ReactNativeFeatureFlags.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/ReactNativeFeatureFlags.ts index 930653b..65411e7 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/ReactNativeFeatureFlags.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/ReactNativeFeatureFlags.ts @@ -2,3 +2,4 @@ // the legacy code path whose `Platform.select` `accessible` logic we compare against. If a future // RN `Text.js` consults another flag, the import fails loud at module load — add it here. export const reduceDefaultPropsInText = () => false; +export const reduceDefaultPropsInImage = () => false; diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/StyleSheet.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/StyleSheet.ts new file mode 100644 index 0000000..f9b42cc --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/StyleSheet.ts @@ -0,0 +1,8 @@ +import { flattenStyle } from '../normalize'; + +const StyleSheet = { + create: (styles: T): T => styles, + flatten: flattenStyle, +}; + +export default StyleSheet; diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/TextInlineImageNativeComponent.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/TextInlineImageNativeComponent.ts new file mode 100644 index 0000000..6c08838 --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/TextInlineImageNativeComponent.ts @@ -0,0 +1,3 @@ +import { TextInlineImageCapturer } from '../capture'; + +export default TextInlineImageCapturer; diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/boost-runtime.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/boost-runtime.ts index 56d92fd..a43c337 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/boost-runtime.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/boost-runtime.ts @@ -1,4 +1,4 @@ -import { NativeTextCapturer, NativeViewCapturer } from '../capture'; +import { NativeImageCapturer, NativeTextCapturer, NativeViewCapturer } from '../capture'; /** * Stand-in for `react-native-boost/runtime`, used only by the fibers collector (redirected while @@ -8,7 +8,7 @@ import { NativeTextCapturer, NativeViewCapturer } from '../capture'; */ export const NativeText = NativeTextCapturer; export const NativeView = NativeViewCapturer; -export const NativeImage = NativeViewCapturer; +export const NativeImage = NativeImageCapturer; export const processTextStyle = (style: unknown): Record => (style ? { style } : {}); export const processViewStyle = (style: unknown): Record => (style ? { style } : {}); diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/react-native.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/react-native.ts index 42a3f8e..c27ed39 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/react-native.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/react-native.ts @@ -1,5 +1,5 @@ import Platform from './Platform'; -import { NativeTextCapturer, NativeViewCapturer } from '../capture'; +import { NativeImageCapturer, NativeTextCapturer, NativeViewCapturer } from '../capture'; import { flattenStyle } from '../normalize'; import { processColor } from './processColor'; @@ -15,6 +15,7 @@ export const unstable_NativeText = NativeTextCapturer; export const unstable_NativeView = NativeViewCapturer; export const Text = NativeTextCapturer; export const View = NativeViewCapturer; +export const Image = NativeImageCapturer; // `processTextStyle` (the runtime under test) calls `StyleSheet.flatten`, so it must faithfully // reproduce RN's flatten semantics — an identity stub would silently break every dynamic-`style` parity diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/resolveAssetSource.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/resolveAssetSource.ts new file mode 100644 index 0000000..fb0bef5 --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/resolveAssetSource.ts @@ -0,0 +1,3 @@ +export default function resolveAssetSource(source: T): T { + return source; +} diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/normalize.ts b/packages/react-native-boost/src/plugin/__tests__/parity/normalize.ts index c7c31ad..e9b26d7 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/normalize.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/normalize.ts @@ -27,3 +27,43 @@ export const flattenStyle = (style: unknown): unknown => { */ export const normalize = (props: Record) => JSON.parse(JSON.stringify('style' in props ? { ...props, style: flattenStyle(props.style) } : props)); + +export const normalizeImage = (props: Record) => { + const normalized = normalize(props); + + for (const key of [ + 'alt', + 'aria-busy', + 'aria-checked', + 'aria-disabled', + 'aria-expanded', + 'aria-hidden', + 'aria-label', + 'aria-labelledby', + 'aria-selected', + 'crossOrigin', + 'referrerPolicy', + 'width', + 'height', + ]) { + delete normalized[key]; + } + + for (const key of ['defaultSource', 'internal_analyticTag', 'loadingIndicatorSrc']) { + if (normalized[key] === null) delete normalized[key]; + } + + if (normalized.shouldNotifyLoadEvents === false) delete normalized.shouldNotifyLoadEvents; + if (normalized.headers && typeof normalized.headers === 'object' && Object.keys(normalized.headers).length === 0) { + delete normalized.headers; + } + if ( + normalized.accessibilityState && + typeof normalized.accessibilityState === 'object' && + Object.keys(normalized.accessibilityState).length === 0 + ) { + delete normalized.accessibilityState; + } + + return normalized; +}; diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts b/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts index f919e23..e94ed2f 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts @@ -11,12 +11,12 @@ vi.mock('../../../runtime/components/native-view', async () => ({ NativeView: (await import('./capture')).NativeViewCapturer, })); vi.mock('../../../runtime/components/native-image', async () => ({ - NativeImage: (await import('./capture')).NativeViewCapturer, + NativeImage: (await import('./capture')).NativeImageCapturer, })); import { captureWrapper, captureWrapperHosts } from './wrapper'; import { captureBoost, boostOptimizes } from './boost'; -import { normalize } from './normalize'; +import { normalize, normalizeImage } from './normalize'; const PLATFORMS = ['ios', 'android'] as const; @@ -107,6 +107,36 @@ const VIEW_CASES = [ // a silent loss of optimization — from masquerading as a passing parity test. const BAILED_VIEW_CASES = new Set(['', '']); +const IMAGE_CASES = [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + 'Logo', + '', + '', + '', + '', + '', + '', +]; + +const BAILED_IMAGE_CASES = new Set([ + '', + '', + '', +]); + describe('differential parity', () => { describe.each(PLATFORMS)('Platform.OS=%s', (os) => { it.each(TEXT_CASES)('Text: %s', async (jsx) => { @@ -118,6 +148,15 @@ describe('differential parity', () => { expect(normalize(boost.props)).toEqual(normalize(wrapper.props)); }); + it.each(IMAGE_CASES)('Image: %s', async (jsx) => { + const boost = await captureBoost(os, jsx); + expect(boost.optimized).toBe(!BAILED_IMAGE_CASES.has(jsx)); + if (!boost.optimized) return; // bailed → defers to the wrapper, equivalent by construction + const wrapper = await captureWrapper(os, jsx); + expect(boost.which).toEqual(wrapper.which); + expect(normalizeImage(boost.props)).toEqual(normalizeImage(wrapper.props)); + }); + it.each(VIEW_CASES)('View: %s', async (jsx) => { const boost = await captureBoost(os, jsx); expect(boost.optimized).toBe(!BAILED_VIEW_CASES.has(jsx)); diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/vitest.config.parity.mts b/packages/react-native-boost/src/plugin/__tests__/parity/vitest.config.parity.mts index b412fdf..cf13fd5 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/vitest.config.parity.mts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/vitest.config.parity.mts @@ -39,6 +39,11 @@ if (process.env.BENCH_FIBERS_OUT) { const basenameRedirects: Array<[RegExp, string]> = [ [/(^|[./])ViewNativeComponent$/, u('./mocks/ViewNativeComponent.ts')], [/(^|[./])TextNativeComponent$/, u('./mocks/TextNativeComponent.ts')], + [/(^|[./])ImageViewNativeComponent$/, u('./mocks/ImageViewNativeComponent.ts')], + [/(^|[./])TextInlineImageNativeComponent$/, u('./mocks/TextInlineImageNativeComponent.ts')], + [/(^|[./])NativeImageLoader(Android|IOS)$/, u('./mocks/NativeImageLoader.ts')], + [/(^|[./])StyleSheet$/, u('./mocks/StyleSheet.ts')], + [/(^|[./])resolveAssetSource$/, u('./mocks/resolveAssetSource.ts')], [/(^|[./])Platform$/, u('./mocks/Platform.ts')], [/(^|[./])usePressability$/, u('./mocks/usePressability.ts')], [/(^|[./])PressabilityDebug$/, u('./mocks/PressabilityDebug.ts')], diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/wrapper.ts b/packages/react-native-boost/src/plugin/__tests__/parity/wrapper.ts index ccda829..18dceca 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/wrapper.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/wrapper.ts @@ -12,9 +12,17 @@ import { setPlatformOS, type PlatformOS } from './mocks/Platform'; */ async function compileWrapperCase(os: PlatformOS, jsxBody: string, preamble = ''): Promise { setPlatformOS(os); // Text.js reads Platform.select at render time + const usesImage = jsxBody.includes(' { // Target platform, resolved at build time. Metro sets this on the Babel caller per platform bundle, // letting optimizers inline platform-specific defaults instead of deferring them to the runtime. - const platform = api.caller((caller) => (caller as { platform?: string } | undefined)?.platform); + const platform = api.caller((caller) => + normalizeTargetPlatform((caller as { platform?: string } | undefined)?.platform) + ); // Resolve "Unistyles mode" once per plugin instance. An explicit `unistyles` flag always wins; // otherwise auto-detect an installed `react-native-unistyles` and, when found, enable the mode but @@ -66,3 +68,7 @@ function getOrCreateLogger(state: PluginState, options: PluginOptions): PluginLo return state.__reactNativeBoostLogger; } + +function normalizeTargetPlatform(platform?: string): TargetPlatform | undefined { + return platform === 'ios' || platform === 'android' || platform === 'web' ? platform : undefined; +} diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/code.js index 7bdcfe8..47efeab 100644 --- a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/code.js +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/code.js @@ -1,3 +1,4 @@ import { Image } from 'react-native'; ; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/output.js index cb79f14..47a097c 100644 --- a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/output.js @@ -1,2 +1,3 @@ import { Image } from 'react-native'; ; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/fallback-semantics/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/fallback-semantics/code.js new file mode 100644 index 0000000..300ea36 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/fallback-semantics/code.js @@ -0,0 +1,7 @@ +import { Image } from 'react-native'; + +; +; +; +; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/fallback-semantics/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/fallback-semantics/output.js new file mode 100644 index 0000000..6ee1674 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/fallback-semantics/output.js @@ -0,0 +1,110 @@ +import { NativeImage as _NativeImage } from 'react-native-boost/runtime'; +import { Image } from 'react-native'; +<_NativeImage + style={[ + { + width: 16, + height: 12, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'logo.png', + width: null, + height: 12, + }, + ]} + resizeMode="cover" +/>; +<_NativeImage + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + { + resizeMode: 'contain', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + }, + ]} + resizeMode="contain" +/>; +<_NativeImage + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + { + resizeMode: 'contain', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + }, + ]} + resizeMode="contain" +/>; +<_NativeImage + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + { + objectFit: 'fill', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + }, + ]} + resizeMode="stretch" +/>; +<_NativeImage + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + { + tintColor: 'red', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + }, + ]} + resizeMode="cover" + tintColor="red" +/>; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-bails/code.js new file mode 100644 index 0000000..4ed42b3 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-bails/code.js @@ -0,0 +1,8 @@ +import { Image } from 'react-native'; + +<> + {/* @boost-force */} + + {/* @boost-force */} + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-bails/output.js new file mode 100644 index 0000000..881b8e3 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-bails/output.js @@ -0,0 +1,14 @@ +import { Image } from 'react-native'; +<> + {/* @boost-force */} + + {/* @boost-force */} + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/code.js index ba242bd..0c56700 100644 --- a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/code.js +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/code.js @@ -9,3 +9,5 @@ import { Image } from 'react-native'; />; ; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/output.js index 4cdc487..2946041 100644 --- a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/output.js @@ -2,10 +2,7 @@ import { NativeImage as _NativeImage } from 'react-native-boost/runtime'; import { Image } from 'react-native'; <_NativeImage style={[ - { - width: 16, - height: 16, - }, + {}, { overflow: 'hidden', }, @@ -25,10 +22,7 @@ import { Image } from 'react-native'; />; <_NativeImage style={[ - { - width: 16, - height: 16, - }, + {}, { overflow: 'hidden', }, @@ -46,3 +40,22 @@ import { Image } from 'react-native'; ]} resizeMode="cover" />; +<_NativeImage + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: '', + width: 16, + height: 16, + }, + ]} + resizeMode="cover" +/>; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/spread-wrapper-props-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/spread-wrapper-props-bails/code.js new file mode 100644 index 0000000..dbe3fe8 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/spread-wrapper-props-bails/code.js @@ -0,0 +1,7 @@ +import { Image } from 'react-native'; + +const props = { source: { uri: 'override.png' } }; +const accessibilityProps = { alt: 'Logo' }; + +; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/spread-wrapper-props-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/spread-wrapper-props-bails/output.js new file mode 100644 index 0000000..963d70d --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/spread-wrapper-props-bails/output.js @@ -0,0 +1,25 @@ +import { Image } from 'react-native'; +const props = { + source: { + uri: 'override.png', + }, +}; +const accessibilityProps = { + alt: 'Logo', +}; +; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-precedence/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-precedence/code.js new file mode 100644 index 0000000..0c34140 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-precedence/code.js @@ -0,0 +1,3 @@ +import { Image } from 'react-native'; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-precedence/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-precedence/output.js new file mode 100644 index 0000000..f4e6d23 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-precedence/output.js @@ -0,0 +1,18 @@ +import { NativeImage as _NativeImage } from 'react-native-boost/runtime'; +import { Image } from 'react-native'; +<_NativeImage + style={[ + {}, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'https://example.com/src.png', + headers: {}, + width: 20, + }, + ]} + resizeMode="cover" +/>; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-prop/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-prop/output.js index 00d63eb..c90a088 100644 --- a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-prop/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-prop/output.js @@ -2,10 +2,7 @@ import { NativeImage as _NativeImage } from 'react-native-boost/runtime'; import { Image } from 'react-native'; <_NativeImage style={[ - { - width: 10, - height: 20, - }, + {}, { overflow: 'hidden', }, diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/text-ancestor-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/text-ancestor-bails/code.js new file mode 100644 index 0000000..c83c008 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/text-ancestor-bails/code.js @@ -0,0 +1,5 @@ +import { Image, Text } from 'react-native'; + + + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/text-ancestor-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/text-ancestor-bails/output.js new file mode 100644 index 0000000..43075b4 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/text-ancestor-bails/output.js @@ -0,0 +1,10 @@ +import { Image, Text } from 'react-native'; + + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/index.test.ts b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/index.test.ts index 212964e..ae65b53 100644 --- a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/index.test.ts +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/index.test.ts @@ -1,11 +1,89 @@ import path from 'node:path'; +import { parseSync, transformSync, traverse, types as t, type PluginObj } from '@babel/core'; import { pluginTester } from 'babel-plugin-tester'; +import { describe, expect, it } from 'vitest'; import { generateTestPlugin } from '../../../utils/generate-test-plugin'; import { formatTestResult } from '../../../utils/format-test-result'; +import { createLogger } from '../../../utils/logger'; +import type { TargetPlatform } from '../../../types'; import { imageOptimizer } from '..'; +const transformImage = async (source: string, platform: TargetPlatform): Promise => { + const logger = createLogger({ silent: true, verbose: false }); + const plugin = (): PluginObj => ({ + name: `${platform}-image-optimizer-test`, + visitor: { + JSXOpeningElement(path) { + imageOptimizer(path, logger, {}, platform); + }, + }, + }); + + return formatTestResult( + transformSync(source, { + configFile: false, + babelrc: false, + plugins: ['@babel/plugin-syntax-jsx', plugin], + })!.code! + ); +}; + +const getNativeImageAttributes = (source: string): t.JSXAttribute[][] => { + const ast = parseSync(source, { + configFile: false, + babelrc: false, + parserOpts: { sourceType: 'module', plugins: ['jsx'] }, + }); + const images: t.JSXAttribute[][] = []; + + traverse(ast!, { + JSXOpeningElement(path) { + if (!t.isJSXIdentifier(path.node.name, { name: '_NativeImage' })) return; + images.push(path.node.attributes.filter((attribute): attribute is t.JSXAttribute => t.isJSXAttribute(attribute))); + }, + }); + + return images; +}; + +const getAttributeExpression = (attributes: t.JSXAttribute[], name: string): t.Expression | undefined => { + const attribute = attributes.find((item) => t.isJSXIdentifier(item.name, { name })); + if (!attribute?.value) return undefined; + if (t.isStringLiteral(attribute.value)) return attribute.value; + if (t.isJSXExpressionContainer(attribute.value) && t.isExpression(attribute.value.expression)) { + return attribute.value.expression; + } + return undefined; +}; + +const getAttributeNames = (attributes: t.JSXAttribute[]): Set => + new Set( + attributes.map((attribute) => + t.isJSXIdentifier(attribute.name) + ? attribute.name.name + : `${attribute.name.namespace.name}:${attribute.name.name.name}` + ) + ); + +const getObjectExpressionProperty = (object: t.ObjectExpression, name: string): t.Expression | undefined => { + const property = object.properties.find( + (item): item is t.ObjectProperty => + t.isObjectProperty(item) && + ((t.isIdentifier(item.key) && item.key.name === name) || + (t.isStringLiteral(item.key) && item.key.value === name)) && + t.isExpression(item.value) + ); + + return property?.value as t.Expression | undefined; +}; + +const getStringPropertyValue = (object: t.ObjectExpression, name: string): string | undefined => { + const value = getObjectExpressionProperty(object, name); + return t.isStringLiteral(value) ? value.value : undefined; +}; + pluginTester({ - plugin: generateTestPlugin(imageOptimizer), + plugin: generateTestPlugin(imageOptimizer, {}, 'ios'), title: 'image', fixtures: path.resolve(import.meta.dirname, 'fixtures'), babelOptions: { @@ -13,3 +91,164 @@ pluginTester({ }, formatResult: formatTestResult, }); + +describe('image android output', () => { + it('emits Android top-level empty headers for src sources', async () => { + const output = await transformImage( + ` + import { Image } from 'react-native'; + ; + `, + 'android' + ); + + const images = getNativeImageAttributes(output); + expect(images).toHaveLength(1); + const headers = getAttributeExpression(images[0]!, 'headers'); + + expect(t.isObjectExpression(headers)).toBe(true); + expect((headers as t.ObjectExpression).properties).toHaveLength(0); + }); + + it('emits Android src and top-level headers for request header props', async () => { + const output = await transformImage( + ` + import { Image } from 'react-native'; + ; + `, + 'android' + ); + + const images = getNativeImageAttributes(output); + expect(images).toHaveLength(1); + const image = images[0]!; + const src = getAttributeExpression(image, 'src'); + const source = getAttributeExpression(image, 'source'); + const headers = getAttributeExpression(image, 'headers'); + + expect(t.isArrayExpression(src)).toBe(true); + expect(t.isArrayExpression(source)).toBe(true); + expect(t.isObjectExpression(headers)).toBe(true); + expect(getStringPropertyValue(headers as t.ObjectExpression, 'Access-Control-Allow-Credentials')).toBe('true'); + expect(getStringPropertyValue(headers as t.ObjectExpression, 'Referrer-Policy')).toBe('origin'); + }); + + it('emits Android top-level headers from static source headers', async () => { + const output = await transformImage( + ` + import { Image } from 'react-native'; + ; + ; + `, + 'android' + ); + + const images = getNativeImageAttributes(output); + expect(images).toHaveLength(2); + const objectSourceImage = images[0]!; + const arraySourceImage = images[1]!; + const objectHeaders = getAttributeExpression(objectSourceImage, 'headers'); + const arrayHeaders = getAttributeExpression(arraySourceImage, 'headers'); + + expect(t.isObjectExpression(objectHeaders)).toBe(true); + expect(t.isObjectExpression(arrayHeaders)).toBe(true); + expect(getStringPropertyValue(objectHeaders as t.ObjectExpression, 'Authorization')).toBe('Bearer object'); + expect(getStringPropertyValue(arrayHeaders as t.ObjectExpression, 'Authorization')).toBe('Bearer first'); + + const objectSource = getAttributeExpression(objectSourceImage, 'source'); + const arraySource = getAttributeExpression(arraySourceImage, 'source'); + expect(t.isArrayExpression(objectSource)).toBe(true); + expect(t.isArrayExpression(arraySource)).toBe(true); + + const objectSourceEntry = (objectSource as t.ArrayExpression).elements[0]; + const arraySourceEntry = (arraySource as t.ArrayExpression).elements[0]; + expect(t.isObjectExpression(objectSourceEntry)).toBe(true); + expect(t.isObjectExpression(arraySourceEntry)).toBe(true); + + const objectSourceHeaders = getObjectExpressionProperty(objectSourceEntry as t.ObjectExpression, 'headers'); + const arraySourceHeaders = getObjectExpressionProperty(arraySourceEntry as t.ObjectExpression, 'headers'); + expect(t.isObjectExpression(objectSourceHeaders)).toBe(true); + expect(t.isObjectExpression(arraySourceHeaders)).toBe(true); + expect(getStringPropertyValue(objectSourceHeaders as t.ObjectExpression, 'Authorization')).toBe('Bearer object'); + expect(getStringPropertyValue(arraySourceHeaders as t.ObjectExpression, 'Authorization')).toBe('Bearer first'); + }); +}); + +describe('image unknown platform output', () => { + it('bails because the native Image host prop contract is platform-specific', async () => { + const logger = createLogger({ silent: true, verbose: false }); + const plugin = (): PluginObj => ({ + name: 'unknown-platform-image-optimizer-test', + visitor: { + JSXOpeningElement(path) { + imageOptimizer(path, logger, {}, undefined); + }, + }, + }); + + const output = await formatTestResult( + transformSync( + ` + import { Image } from 'react-native'; + ; + `, + { + configFile: false, + babelrc: false, + plugins: ['@babel/plugin-syntax-jsx', plugin], + } + )!.code! + ); + + expect(output).not.toContain('NativeImage'); + expect(output).toContain(' { + it.each(['ios', 'android'] as const)( + 'removes wrapper-consumed size and request props from optimized %s output', + async (platform) => { + const output = await transformImage( + ` + import { Image } from 'react-native'; + ; + ; + `, + platform + ); + + const images = getNativeImageAttributes(output); + expect(images).toHaveLength(2); + + for (const image of images) { + const names = getAttributeNames(image); + expect(names.has('width')).toBe(false); + expect(names.has('height')).toBe(false); + expect(names.has('crossOrigin')).toBe(false); + expect(names.has('referrerPolicy')).toBe(false); + } + } + ); +}); diff --git a/packages/react-native-boost/src/plugin/optimizers/image/index.ts b/packages/react-native-boost/src/plugin/optimizers/image/index.ts index 7f4fc2b..72e544f 100644 --- a/packages/react-native-boost/src/plugin/optimizers/image/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/image/index.ts @@ -13,6 +13,7 @@ import { isStaticLiteralTree, isValidJSXComponent, replaceWithNativeComponent, + ancestorBailoutChecks, } from '../../utils/common'; import { RUNTIME_MODULE_NAME } from '../../utils/constants'; @@ -48,6 +49,28 @@ const IMAGE_ARIA_STATE_PROPS = new Set([ const IMAGE_REQUEST_HEADER_PROPS = new Set(['crossOrigin', 'referrerPolicy']); +const IMAGE_SPREAD_GUARD_PROPS = new Set([ + ...IMAGE_BAILOUT_PROPS, + ...IMAGE_REQUEST_HEADER_PROPS, + ...IMAGE_ARIA_STATE_PROPS, + 'accessible', + 'accessibilityLabel', + 'accessibilityLabelledBy', + 'accessibilityState', + 'alt', + 'aria-hidden', + 'aria-label', + 'aria-labelledby', + 'height', + 'importantForAccessibility', + 'resizeMode', + 'source', + 'src', + 'style', + 'tintColor', + 'width', +]); + const IMAGE_BASE_STYLE = t.objectExpression([t.objectProperty(t.identifier('overflow'), t.stringLiteral('hidden'))]); const OBJECT_FIT_TO_RESIZE_MODE: Record = { @@ -58,7 +81,7 @@ const OBJECT_FIT_TO_RESIZE_MODE: Record = { 'scale-down': 'contain', }; -export const imageOptimizer: Optimizer = (path, logger, _options, platform) => { +export const imageOptimizer: Optimizer = (path, logger, options, platform) => { if (platform === 'web') return; if (!isValidJSXComponent(path, 'Image')) return; if (!isReactNativeImport(path, 'Image')) return; @@ -66,18 +89,18 @@ export const imageOptimizer: Optimizer = (path, logger, _options, platform) => { const parent = path.parent as t.JSXElement; const forced = isForcedLine(path); - const overridableChecks: BailoutCheck[] = [ + const hardChecks: BailoutCheck[] = [ { - reason: 'contains unsupported Image props', - shouldBail: () => hasBlacklistedProperty(path, IMAGE_BAILOUT_PROPS), + reason: 'target platform is unknown', + shouldBail: () => platform !== 'ios' && platform !== 'android', }, { - reason: 'has a spread that may carry translated Image request headers', - shouldBail: () => hasBlacklistedPropertyInSpread(path, IMAGE_REQUEST_HEADER_PROPS), + reason: 'contains unsupported Image props', + shouldBail: () => hasBlacklistedProperty(path, IMAGE_BAILOUT_PROPS), }, { - reason: 'has a spread that may carry translated Image accessibility props', - shouldBail: () => hasImageAccessibilitySpreadConflict(path), + reason: 'has a spread that may carry Image wrapper props', + shouldBail: () => hasBlacklistedPropertyInSpread(path, IMAGE_SPREAD_GUARD_PROPS), }, { reason: 'contains non-empty children', @@ -94,6 +117,16 @@ export const imageOptimizer: Optimizer = (path, logger, _options, platform) => { }, ]; + const overridableChecks: BailoutCheck[] = [ + ...ancestorBailoutChecks(path, options?.dangerouslyOptimizeImageWithUnknownAncestors === true), + ]; + + const hardSkipReason = getFirstBailoutReason(hardChecks); + if (hardSkipReason) { + logger.skipped({ component: 'Image', path, reason: hardSkipReason }); + return; + } + if (forced) { const overriddenReason = getFirstBailoutReason(overridableChecks); if (overriddenReason) { @@ -127,15 +160,16 @@ export const imageOptimizer: Optimizer = (path, logger, _options, platform) => { logger.optimized({ component: 'Image', path }); - processImageProps(path, file, nativeSource, styleInfo); + processImageProps(path, file, nativeSource, styleInfo, platform); replaceWithNativeComponent(path, parent, file, 'NativeImage'); }; type NativeSource = { - sourceAttribute: t.JSXAttribute; + sourceAttributes: t.JSXAttribute[]; requestHeaderAttributes: t.JSXAttribute[]; sourceArray: t.ArrayExpression; consumesSizeProps: boolean; + androidHeaders?: t.Expression; width?: t.Expression; height?: t.Expression; }; @@ -143,7 +177,8 @@ type NativeSource = { type StyleInfo = { styleAttribute?: t.JSXAttribute; styleExpression?: t.Expression; - resizeMode?: t.Expression; + objectFitResizeMode?: t.Expression; + styleResizeMode?: t.Expression; tintColor?: t.Expression; } | null; @@ -156,11 +191,12 @@ function processImageProps( path: NodePath, file: HubFile, nativeSource: NativeSource, - styleInfo: StyleInfo + styleInfo: StyleInfo, + platform?: string ) { const accessibilityInfo = buildImageAccessibilityInfo(path, file); const consumed = new Set([ - nativeSource.sourceAttribute, + ...nativeSource.sourceAttributes, ...nativeSource.requestHeaderAttributes, ...(accessibilityInfo?.attributes ?? []), ]); @@ -176,38 +212,23 @@ function processImageProps( const explicitResizeMode = getAttributeExpression(path.node.attributes, 'resizeMode'); const explicitTintColor = getAttributeExpression(path.node.attributes, 'tintColor'); + const tintColor = buildTintColor(explicitTintColor, styleInfo?.tintColor, platform); + const emitsAndroidProps = platform === 'android'; path.node.attributes = [ ...remaining, accessibilityInfo?.spreadAttribute, makeAttribute('style', buildStyle(nativeSource, styleInfo)), + emitsAndroidProps ? makeAttribute('src', t.cloneNode(nativeSource.sourceArray, true)) : undefined, makeAttribute('source', nativeSource.sourceArray), - makeAttribute('resizeMode', explicitResizeMode ?? styleInfo?.resizeMode ?? t.stringLiteral('cover')), - explicitTintColor || styleInfo?.tintColor - ? makeAttribute('tintColor', explicitTintColor ?? styleInfo!.tintColor!) + emitsAndroidProps && nativeSource.androidHeaders + ? makeAttribute('headers', t.cloneNode(nativeSource.androidHeaders, true)) : undefined, + makeAttribute('resizeMode', buildResizeMode(explicitResizeMode, styleInfo)), + tintColor ? makeAttribute('tintColor', tintColor) : undefined, ].filter((attribute): attribute is t.JSXAttribute | t.JSXSpreadAttribute => attribute !== undefined); } -function hasImageAccessibilitySpreadConflict(path: NodePath): boolean { - const directNames = getDirectAttributeNames(path.node.attributes); - const guarded = new Set(['alt', 'aria-hidden', 'aria-label', 'aria-labelledby', ...IMAGE_ARIA_STATE_PROPS]); - - if (directNames.has('alt') || directNames.has('aria-label')) { - guarded.add('accessibilityLabel'); - } - if (directNames.has('alt') || directNames.has('aria-hidden')) { - guarded.add('accessible'); - } - if (directNames.has('aria-hidden')) guarded.add('importantForAccessibility'); - if (directNames.has('aria-labelledby')) guarded.add('accessibilityLabelledBy'); - if ([...IMAGE_ARIA_STATE_PROPS].some((name) => directNames.has(name))) { - guarded.add('accessibilityState'); - } - - return hasBlacklistedPropertyInSpread(path, guarded); -} - function buildImageAccessibilityInfo( path: NodePath, file: HubFile @@ -275,22 +296,26 @@ function buildNativeSource(attributes: Array attribute !== undefined), requestHeaderAttributes: requestHeaders.attributes, sourceArray: t.arrayExpression([ t.objectExpression([ t.objectProperty(t.identifier('uri'), uri), - t.objectProperty(t.identifier('headers'), t.cloneNode(requestHeaders.headers, true)), + t.objectProperty(t.identifier('headers'), headers), ...(width ? [t.objectProperty(t.identifier('width'), width)] : []), ...(height ? [t.objectProperty(t.identifier('height'), height)] : []), ]), ]), consumesSizeProps: true, - width, - height, + androidHeaders: t.cloneNode(requestHeaders.headers, true), }; } @@ -303,26 +328,36 @@ function buildNativeSource(attributes: Array 0) return undefined; return { - sourceAttribute: source, + sourceAttributes: [source], requestHeaderAttributes: requestHeaders.attributes, sourceArray: t.cloneNode(sourceExpression, true), consumesSizeProps: false, + androidHeaders: getFirstSourceHeaders(sourceExpression), }; } const sourceObject = sourceExpression; const sourceWidth = getObjectPropertyExpression(sourceObject, 'width'); const sourceHeight = getObjectPropertyExpression(sourceObject, 'height'); - const width = sourceWidth ?? getAttributeExpression(attributes, 'width'); - const height = sourceHeight ?? getAttributeExpression(attributes, 'height'); + const width = getNullishFallback(sourceWidth, getAttributeExpression(attributes, 'width')); + const height = getNullishFallback(sourceHeight, getAttributeExpression(attributes, 'height')); + const sourceArrayObject = buildSourceObject(sourceObject, requestHeaders); + // RN's wrapper only synthesizes style dimensions when it receives an object source. `src` and + // source+request-header props with a truthy `uri` go through ImageSourceUtils as array sources, so + // their width/height stay in the source payload and do not become layout style. + const usesGeneratedHeaders = + requestHeaders.headers.properties.length > 0 && hasObjectProperty(sourceArrayObject, 'headers'); return { - sourceAttribute: source, + sourceAttributes: [source], requestHeaderAttributes: requestHeaders.attributes, - sourceArray: t.arrayExpression([buildSourceObject(sourceObject, requestHeaders)]), + sourceArray: t.arrayExpression([sourceArrayObject]), consumesSizeProps: true, - width, - height, + androidHeaders: usesGeneratedHeaders + ? t.cloneNode(requestHeaders.headers, true) + : getObjectPropertyExpression(sourceArrayObject, 'headers'), + width: usesGeneratedHeaders ? undefined : width, + height: usesGeneratedHeaders ? undefined : height, }; } @@ -366,7 +401,7 @@ function buildSourceObject(sourceObject: t.ObjectExpression, requestHeaders: Req if (requestHeaders.headers.properties.length === 0) return t.cloneNode(sourceObject, true); const uri = getObjectPropertyExpression(sourceObject, 'uri'); - if (!uri || (t.isStringLiteral(uri) && uri.value === '')) return t.cloneNode(sourceObject, true); + if (!uri || !isStaticTruthyForLogicalOr(uri)) return t.cloneNode(sourceObject, true); const nativeSource = t.cloneNode(sourceObject, true); nativeSource.properties.push(t.objectProperty(t.identifier('headers'), t.cloneNode(requestHeaders.headers, true))); @@ -398,14 +433,12 @@ function buildStaticStyleInfo(attributes: Array + t.isObjectProperty(property) && + (t.isIdentifier(property.key, { name }) || (t.isStringLiteral(property.key) && property.key.value === name)) + ); +} + +function getFirstSourceHeaders(sourceArray: t.ArrayExpression): t.Expression | undefined { + const first = sourceArray.elements[0]; + return first && t.isObjectExpression(first) ? getObjectPropertyExpression(first, 'headers') : undefined; +} + +function getNullishFallback( + primary: t.Expression | undefined, + fallback: t.Expression | undefined +): t.Expression | undefined { + return primary && !isNullishExpression(primary) ? primary : fallback; +} + +function buildResizeMode(explicit: t.Expression | undefined, styleInfo: StyleInfo): t.Expression { + if (styleInfo?.objectFitResizeMode) return t.cloneNode(styleInfo.objectFitResizeMode, true); + + const fallback = + styleInfo?.styleResizeMode && !isFalsyForLogicalOr(styleInfo.styleResizeMode) + ? t.cloneNode(styleInfo.styleResizeMode, true) + : t.stringLiteral('cover'); + if (!explicit) return fallback; + if (isFalsyForLogicalOr(explicit)) return fallback; + if (isStaticTruthyForLogicalOr(explicit)) return t.cloneNode(explicit, true); + return t.logicalExpression('||', t.cloneNode(explicit, true), fallback); +} + +function buildTintColor( + explicit: t.Expression | undefined, + styleTintColor: t.Expression | undefined, + platform?: string +): t.Expression | undefined { + if (platform === 'android' && explicit) return t.cloneNode(explicit, true); + if (!explicit) return styleTintColor ? t.cloneNode(styleTintColor, true) : undefined; + if (isNullishExpression(explicit)) return styleTintColor ? t.cloneNode(styleTintColor, true) : undefined; + if (isStaticNonNullishExpression(explicit)) return t.cloneNode(explicit, true); + return t.logicalExpression( + '??', + t.cloneNode(explicit, true), + styleTintColor ? t.cloneNode(styleTintColor, true) : t.identifier('undefined') + ); +} + +function isNullishExpression(expression: t.Expression): boolean { + return t.isNullLiteral(expression) || t.isIdentifier(expression, { name: 'undefined' }); +} + +function isFalsyForLogicalOr(expression: t.Expression): boolean { + return ( + isNullishExpression(expression) || + (t.isStringLiteral(expression) && expression.value === '') || + (t.isBooleanLiteral(expression) && !expression.value) || + (t.isNumericLiteral(expression) && expression.value === 0) + ); +} + +function isStaticTruthyForLogicalOr(expression: t.Expression): boolean { + return ( + (t.isStringLiteral(expression) && expression.value !== '') || + (t.isBooleanLiteral(expression) && expression.value) || + (t.isNumericLiteral(expression) && expression.value !== 0) + ); +} + +function isStaticNonNullishExpression(expression: t.Expression): boolean { + return t.isStringLiteral(expression) || t.isNumericLiteral(expression) || t.isBooleanLiteral(expression); +} + function cloneMapValue(map: Map, name: string): t.Expression | undefined { const value = map.get(name); return value ? t.cloneNode(value, true) : undefined; diff --git a/packages/react-native-boost/src/plugin/types/index.ts b/packages/react-native-boost/src/plugin/types/index.ts index 6153fe2..c03ceb5 100644 --- a/packages/react-native-boost/src/plugin/types/index.ts +++ b/packages/react-native-boost/src/plugin/types/index.ts @@ -84,10 +84,22 @@ export interface PluginOptions { * @default false */ dangerouslyOptimizeTextWithUnknownAncestors?: boolean; + /** + * Opt-in flag that allows Image optimization when ancestor components cannot be statically resolved. + * + * This increases optimization coverage, but may introduce behavioral differences when an unresolved + * ancestor renders a React Native `Text` wrapper: Android images under text render through the inline + * image host rather than the normal image view, and optimizing them would emit the wrong host. + * Prefer targeted `@boost-force` first, and enable this only after verifying affected screens. + * @default false + */ + dangerouslyOptimizeImageWithUnknownAncestors?: boolean; } export type OptimizableComponent = 'Text' | 'View' | 'Image'; +export type TargetPlatform = 'ios' | 'android' | 'web'; + export interface OptimizationLogPayload { component: OptimizableComponent; path: NodePath; @@ -115,7 +127,7 @@ export type Optimizer = ( logger: PluginLogger, options?: PluginOptions, /** Target platform from Babel's caller (e.g. Metro sets `'ios'`/`'android'`). Lets optimizers resolve platform-specific defaults at build time. */ - platform?: string, + platform?: TargetPlatform, /** * Whether "Unistyles mode" is active for this build (resolved once at plugin init from the `unistyles` * option + install auto-detection). When `true`, optimizers classify each element's `style` origin and diff --git a/packages/react-native-boost/src/plugin/utils/generate-test-plugin.ts b/packages/react-native-boost/src/plugin/utils/generate-test-plugin.ts index b09120d..b0b91e3 100644 --- a/packages/react-native-boost/src/plugin/utils/generate-test-plugin.ts +++ b/packages/react-native-boost/src/plugin/utils/generate-test-plugin.ts @@ -1,11 +1,11 @@ import { declare } from '@babel/helper-plugin-utils'; -import { Optimizer, PluginOptions } from '../types'; +import { Optimizer, PluginOptions, TargetPlatform } from '../types'; import { createLogger } from './logger'; import { textOptimizer } from '../optimizers/text'; import { viewOptimizer } from '../optimizers/view'; import { imageOptimizer } from '../optimizers/image'; -export const generateTestPlugin = (optimizer: Optimizer, options: PluginOptions = {}) => { +export const generateTestPlugin = (optimizer: Optimizer, options: PluginOptions = {}, platform?: TargetPlatform) => { const logger = createLogger({ verbose: false, silent: true, @@ -20,7 +20,7 @@ export const generateTestPlugin = (optimizer: Optimizer, options: PluginOptions JSXOpeningElement(path) { // Mirror the real plugin's explicit-flag resolution for Unistyles mode (auto-detection is not // exercised in fixtures); a fixture opts in with `{ unistyles: true }`. - optimizer(path, logger, options, undefined, options.unistyles === true); + optimizer(path, logger, options, platform, options.unistyles === true); }, }, }; @@ -28,11 +28,11 @@ export const generateTestPlugin = (optimizer: Optimizer, options: PluginOptions }; /** - * Runs both optimizers per element, exactly as the real plugin does (`textOptimizer` then - * `viewOptimizer`). Needed for cases that depend on the two interacting — notably nested elements, where - * an outer `View` must be rewritten before an inner element classifies it as an ancestor. + * Runs all optimizers per element, exactly as the real plugin does (`Text`, then `View`, then `Image`). + * Needed for cases that depend on optimizers interacting — notably nested elements, where an outer host + * rewrite affects how an inner element classifies its ancestors. */ -export const generateCombinedTestPlugin = (options: PluginOptions = {}) => { +export const generateCombinedTestPlugin = (options: PluginOptions = {}, platform?: TargetPlatform) => { const logger = createLogger({ verbose: false, silent: true, @@ -47,9 +47,9 @@ export const generateCombinedTestPlugin = (options: PluginOptions = {}) => { name: 'react-native-boost', visitor: { JSXOpeningElement(path) { - textOptimizer(path, logger, options, undefined, unistylesEnabled); - viewOptimizer(path, logger, options, undefined, unistylesEnabled); - imageOptimizer(path, logger, options, undefined, unistylesEnabled); + textOptimizer(path, logger, options, platform, unistylesEnabled); + viewOptimizer(path, logger, options, platform, unistylesEnabled); + imageOptimizer(path, logger, options, platform, unistylesEnabled); }, }, }; diff --git a/packages/react-native-boost/src/runtime/__tests__/index.test.ts b/packages/react-native-boost/src/runtime/__tests__/index.test.ts index 5555eaf..82c1d4b 100644 --- a/packages/react-native-boost/src/runtime/__tests__/index.test.ts +++ b/packages/react-native-boost/src/runtime/__tests__/index.test.ts @@ -442,7 +442,19 @@ describe('processImageAccessibilityProps', () => { ).toBe('a, b'); }); - it('aggregates aria state fields over a passed accessibilityState', () => { + it('preserves explicit accessibilityState over aria state fields on iOS', () => { + Platform.OS = 'ios'; + expect( + processImageAccessibilityProps({ + 'accessibilityState': { busy: false, checked: true }, + 'aria-busy': true, + 'aria-disabled': false, + }).accessibilityState + ).toEqual({ busy: false, checked: true }); + }); + + it('aggregates aria state fields over a passed accessibilityState on Android', () => { + Platform.OS = 'android'; expect( processImageAccessibilityProps({ 'accessibilityState': { busy: false, checked: true }, diff --git a/packages/react-native-boost/src/runtime/__tests__/mocks/ImageViewNativeComponent.ts b/packages/react-native-boost/src/runtime/__tests__/mocks/ImageViewNativeComponent.ts new file mode 100644 index 0000000..d78de8e --- /dev/null +++ b/packages/react-native-boost/src/runtime/__tests__/mocks/ImageViewNativeComponent.ts @@ -0,0 +1,3 @@ +const ImageViewNativeComponent = () => 'ImageViewNativeComponent'; + +export default ImageViewNativeComponent; diff --git a/packages/react-native-boost/src/runtime/__tests__/mocks/react-native.ts b/packages/react-native-boost/src/runtime/__tests__/mocks/react-native.ts index 5907c35..bbdbadc 100644 --- a/packages/react-native-boost/src/runtime/__tests__/mocks/react-native.ts +++ b/packages/react-native-boost/src/runtime/__tests__/mocks/react-native.ts @@ -1,5 +1,6 @@ export const View = () => 'View'; export const Text = () => 'Text'; +export const Image = () => 'Image'; export const Platform = { OS: 'ios', diff --git a/packages/react-native-boost/src/runtime/components/native-image.test.ts b/packages/react-native-boost/src/runtime/components/native-image.test.ts new file mode 100644 index 0000000..1653705 --- /dev/null +++ b/packages/react-native-boost/src/runtime/components/native-image.test.ts @@ -0,0 +1,52 @@ +import type { ComponentType } from 'react'; +import type { ImageProps } from 'react-native'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const reactNativeImage = (() => null) as ComponentType; +const nativeHost = (() => null) as ComponentType; + +type LoadNativeComponent = () => { default?: ComponentType }; + +async function importNativeImage({ + os, + loadNativeComponent = vi.fn(() => ({ default: nativeHost })), +}: { + os: string; + loadNativeComponent?: LoadNativeComponent; +}) { + vi.resetModules(); + vi.doMock('react-native', () => ({ + Image: reactNativeImage, + Platform: { OS: os }, + })); + vi.stubGlobal('require', loadNativeComponent); + + return import('./native-image'); +} + +afterEach(() => { + vi.doUnmock('react-native'); + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +describe('NativeImage', () => { + it('falls back to React Native Image when the internal host cannot be loaded', async () => { + const { NativeImage } = await importNativeImage({ + os: 'android', + loadNativeComponent: vi.fn(() => { + throw new Error('missing internal host'); + }), + }); + + expect(NativeImage).toBe(reactNativeImage); + }); + + it('uses React Native Image on web without loading the internal host', async () => { + const loadNativeComponent = vi.fn(() => ({ default: nativeHost })); + const { NativeImage } = await importNativeImage({ os: 'web', loadNativeComponent }); + + expect(NativeImage).toBe(reactNativeImage); + expect(loadNativeComponent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-native-boost/src/runtime/components/native-image.tsx b/packages/react-native-boost/src/runtime/components/native-image.tsx index f3d30a7..7421da1 100644 --- a/packages/react-native-boost/src/runtime/components/native-image.tsx +++ b/packages/react-native-boost/src/runtime/components/native-image.tsx @@ -2,17 +2,35 @@ import type { ComponentType } from 'react'; import type { ImageProps } from 'react-native'; +import * as reactNativeModule from 'react-native'; -const reactNative = require('react-native'); -const isWeb = reactNative.Platform.OS === 'web'; +type ReactNativeImageModule = { + Image: ComponentType; + Platform: { + OS: string; + }; +}; -let nativeImage = reactNative.Image; +type NativeImageModule = { + default?: ComponentType; +}; + +const reactNative = reactNativeModule as ReactNativeImageModule; + +function loadImageViewNativeComponent(): NativeImageModule { + return require('react-native/Libraries/Image/ImageViewNativeComponent'); +} + +function resolveNativeImageComponent( + reactNativeModule: ReactNativeImageModule, + loadNativeComponent: () => NativeImageModule = loadImageViewNativeComponent +): ComponentType { + if (reactNativeModule.Platform.OS === 'web') return reactNativeModule.Image; -if (!isWeb) { try { - nativeImage = require('react-native/Libraries/Image/ImageViewNativeComponent').default ?? reactNative.Image; + return loadNativeComponent().default ?? reactNativeModule.Image; } catch { - nativeImage = reactNative.Image; + return reactNativeModule.Image; } } @@ -23,4 +41,4 @@ if (!isWeb) { * React Native does not expose an `unstable_NativeImage`, so this uses the internal host when * available and falls back to `Image`. */ -export const NativeImage: ComponentType = nativeImage; +export const NativeImage: ComponentType = resolveNativeImageComponent(reactNative); diff --git a/packages/react-native-boost/src/runtime/index.ts b/packages/react-native-boost/src/runtime/index.ts index 60eedaf..2f31490 100644 --- a/packages/react-native-boost/src/runtime/index.ts +++ b/packages/react-native-boost/src/runtime/index.ts @@ -355,7 +355,9 @@ export function processImageAccessibilityProps(props: Record): Reco result.importantForAccessibility = importantForAccessibility; } - if ( + if (Platform.OS === 'ios' && accessibilityState !== undefined) { + result.accessibilityState = accessibilityState; + } else if ( accessibilityState != null || ariaBusy != null || ariaChecked != null || @@ -379,4 +381,4 @@ export * from './types'; export * from './utils/constants'; export * from './components/native-text'; export * from './components/native-view'; -export * from './components/native-image'; +export { NativeImage } from './components/native-image'; diff --git a/packages/react-native-boost/vitest.config.ts b/packages/react-native-boost/vitest.config.ts index 909f0b2..c958562 100644 --- a/packages/react-native-boost/vitest.config.ts +++ b/packages/react-native-boost/vitest.config.ts @@ -3,6 +3,9 @@ import { resolve } from 'node:path'; import { configDefaults, defineConfig } from 'vitest/config'; const runtimeMockPath = fileURLToPath(new URL('src/runtime/__tests__/mocks/react-native.ts', import.meta.url)); +const imageViewNativeComponentMockPath = fileURLToPath( + new URL('src/runtime/__tests__/mocks/ImageViewNativeComponent.ts', import.meta.url) +); const parityConfig = fileURLToPath(new URL('src/plugin/__tests__/parity/vitest.config.parity.mts', import.meta.url)); export default defineConfig({ @@ -11,9 +14,13 @@ export default defineConfig({ { // Unit suite: aliases `react-native` to a lightweight mock for the whole project. resolve: { - alias: { - 'react-native': resolve(runtimeMockPath), - }, + alias: [ + { find: /^react-native$/, replacement: resolve(runtimeMockPath) }, + { + find: /^react-native\/Libraries\/Image\/ImageViewNativeComponent$/, + replacement: resolve(imageViewNativeComponentMockPath), + }, + ], }, test: { name: 'unit', From 0170c1284069480888d5db2cb204c849c367839d Mon Sep 17 00:00:00 2001 From: Adam Ivancza Date: Tue, 30 Jun 2026 18:44:56 +0200 Subject: [PATCH 8/9] feat(image): support dynamic source and style --- .../__tests__/parity/mocks/react-native.ts | 4 +- .../plugin/__tests__/parity/parity.test.ts | 22 +++++ .../dynamic-request-headers-bails/output.js | 9 -- .../code.js | 0 .../dynamic-request-headers-runtime/output.js | 15 +++ .../fixtures/dynamic-source-bails/output.js | 3 - .../code.js | 0 .../fixtures/dynamic-source-runtime/output.js | 15 +++ .../fixtures/force-dynamic-bails/output.js | 14 --- .../code.js | 0 .../fixtures/force-dynamic-runtime/output.js | 24 +++++ .../__tests__/fixtures/require-source/code.js | 3 + .../fixtures/require-source/output.js | 10 ++ .../src/plugin/optimizers/image/index.ts | 75 +++++++++++--- .../src/runtime/__tests__/index.test.ts | 98 ++++++++++++++++++- .../runtime/__tests__/mocks/react-native.ts | 14 ++- .../react-native-boost/src/runtime/index.ts | 98 ++++++++++++++++++- .../src/runtime/index.web.ts | 5 + 18 files changed, 365 insertions(+), 44 deletions(-) delete mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-bails/output.js rename packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/{dynamic-request-headers-bails => dynamic-request-headers-runtime}/code.js (100%) create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-runtime/output.js delete mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/output.js rename packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/{dynamic-source-bails => dynamic-source-runtime}/code.js (100%) create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-runtime/output.js delete mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-bails/output.js rename packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/{force-dynamic-bails => force-dynamic-runtime}/code.js (100%) create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-runtime/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/require-source/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/require-source/output.js diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/react-native.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/react-native.ts index c27ed39..159491f 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/react-native.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/react-native.ts @@ -15,7 +15,9 @@ export const unstable_NativeText = NativeTextCapturer; export const unstable_NativeView = NativeViewCapturer; export const Text = NativeTextCapturer; export const View = NativeViewCapturer; -export const Image = NativeImageCapturer; +export const Image = Object.assign(NativeImageCapturer, { + resolveAssetSource: (source: T): T => source, +}); // `processTextStyle` (the runtime under test) calls `StyleSheet.flatten`, so it must faithfully // reproduce RN's flatten semantics — an identity stub would silently break every dynamic-`style` parity diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts b/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts index e94ed2f..cfa6b2a 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts @@ -137,6 +137,19 @@ const BAILED_IMAGE_CASES = new Set([ '', ]); +const DYNAMIC_IMAGE_CASES: Array<[string, string]> = [ + ['', 'const asset = { uri: "asset.png", width: 11, height: 12 };'], + ['', 'const require = () => ({ uri: "asset.png", width: 11, height: 12 });'], + [ + '', + 'const url = "https://example.com/logo.png"; const crossOrigin = "use-credentials"; const policy = "origin";', + ], + [ + '', + 'const imageStyle = [{ width: 16, height: 8 }, { objectFit: "fill", tintColor: "red" }]; const mode = ""; const tint = undefined;', + ], +]; + describe('differential parity', () => { describe.each(PLATFORMS)('Platform.OS=%s', (os) => { it.each(TEXT_CASES)('Text: %s', async (jsx) => { @@ -157,6 +170,15 @@ describe('differential parity', () => { expect(normalizeImage(boost.props)).toEqual(normalizeImage(wrapper.props)); }); + it.each(DYNAMIC_IMAGE_CASES)('Image dynamic: %s', async (jsx, preamble) => { + const boost = await captureBoost(os, jsx, preamble); + expect(boost.optimized).toBe(true); + if (!boost.optimized) throw new Error('expected Image dynamic case to optimize'); + const wrapper = await captureWrapper(os, jsx, preamble); + expect(boost.which).toEqual(wrapper.which); + expect(normalizeImage(boost.props)).toEqual(normalizeImage(wrapper.props)); + }); + it.each(VIEW_CASES)('View: %s', async (jsx) => { const boost = await captureBoost(os, jsx); expect(boost.optimized).toBe(!BAILED_VIEW_CASES.has(jsx)); diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-bails/output.js deleted file mode 100644 index 7e2141d..0000000 --- a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-bails/output.js +++ /dev/null @@ -1,9 +0,0 @@ -import { Image } from 'react-native'; -const policy = getPolicy(); -; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-runtime/code.js similarity index 100% rename from packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-bails/code.js rename to packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-runtime/code.js diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-runtime/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-runtime/output.js new file mode 100644 index 0000000..d8b67eb --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-runtime/output.js @@ -0,0 +1,15 @@ +import { + processImageSourceProps as _processImageSourceProps, + NativeImage as _NativeImage, +} from 'react-native-boost/runtime'; +import { Image } from 'react-native'; +const policy = getPolicy(); +<_NativeImage + {..._processImageSourceProps({ + src: 'https://example.com/logo.png', + width: 16, + height: 16, + crossOrigin: 'use-credentials', + referrerPolicy: policy, + })} +/>; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/output.js deleted file mode 100644 index 47a097c..0000000 --- a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/output.js +++ /dev/null @@ -1,3 +0,0 @@ -import { Image } from 'react-native'; -; -; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-runtime/code.js similarity index 100% rename from packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-bails/code.js rename to packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-runtime/code.js diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-runtime/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-runtime/output.js new file mode 100644 index 0000000..aee1019 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-runtime/output.js @@ -0,0 +1,15 @@ +import { + processImageSourceProps as _processImageSourceProps, + NativeImage as _NativeImage, +} from 'react-native-boost/runtime'; +import { Image } from 'react-native'; +<_NativeImage + {..._processImageSourceProps({ + source: source, + })} +/>; +<_NativeImage + {..._processImageSourceProps({ + src: src, + })} +/>; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-bails/output.js deleted file mode 100644 index 881b8e3..0000000 --- a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-bails/output.js +++ /dev/null @@ -1,14 +0,0 @@ -import { Image } from 'react-native'; -<> - {/* @boost-force */} - - {/* @boost-force */} - -; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-runtime/code.js similarity index 100% rename from packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-bails/code.js rename to packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-runtime/code.js diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-runtime/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-runtime/output.js new file mode 100644 index 0000000..7a6a02c --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-runtime/output.js @@ -0,0 +1,24 @@ +import { + processImageSourceProps as _processImageSourceProps, + NativeImage as _NativeImage, +} from 'react-native-boost/runtime'; +import { Image } from 'react-native'; +<> + {/* @boost-force */} + <_NativeImage + {..._processImageSourceProps({ + source: source, + })} + /> + {/* @boost-force */} + <_NativeImage + {..._processImageSourceProps({ + source: { + uri: 'logo.png', + width: 16, + height: 16, + }, + style: style, + })} + /> +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/require-source/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/require-source/code.js new file mode 100644 index 0000000..255a0ba --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/require-source/code.js @@ -0,0 +1,3 @@ +import { Image } from 'react-native'; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/require-source/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/require-source/output.js new file mode 100644 index 0000000..d94670f --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/require-source/output.js @@ -0,0 +1,10 @@ +import { + processImageSourceProps as _processImageSourceProps, + NativeImage as _NativeImage, +} from 'react-native-boost/runtime'; +import { Image } from 'react-native'; +<_NativeImage + {..._processImageSourceProps({ + source: require('./logo.png'), + })} +/>; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/index.ts b/packages/react-native-boost/src/plugin/optimizers/image/index.ts index 72e544f..582bee1 100644 --- a/packages/react-native-boost/src/plugin/optimizers/image/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/image/index.ts @@ -108,12 +108,7 @@ export const imageOptimizer: Optimizer = (path, logger, options, platform) => { }, { reason: 'has an unsupported or dynamic source', - shouldBail: () => buildNativeSource(path.node.attributes) == null, - }, - { - reason: 'has dynamic image style', - shouldBail: () => - getStyleExpression(path.node.attributes) != null && buildStaticStyleInfo(path.node.attributes) == null, + shouldBail: () => !hasImageSourceInput(path.node.attributes), }, ]; @@ -154,13 +149,16 @@ export const imageOptimizer: Optimizer = (path, logger, options, platform) => { throw new PluginError('No file found in Babel hub'); } - const nativeSource = buildNativeSource(path.node.attributes); + const nativeSource = buildStaticNativeSource(path.node.attributes); const styleInfo = buildStaticStyleInfo(path.node.attributes); - if (!nativeSource || styleInfo === null) return; logger.optimized({ component: 'Image', path }); - processImageProps(path, file, nativeSource, styleInfo, platform); + if (nativeSource && styleInfo !== null) { + processImageProps(path, file, nativeSource, styleInfo, platform); + } else { + processRuntimeImageProps(path, file); + } replaceWithNativeComponent(path, parent, file, 'NativeImage'); }; @@ -187,6 +185,11 @@ type ImageAccessibilityInfo = { spreadAttribute: t.JSXSpreadAttribute; }; +type RuntimeImageInfo = { + attributes: t.JSXAttribute[]; + spreadAttribute: t.JSXSpreadAttribute; +}; + function processImageProps( path: NodePath, file: HubFile, @@ -229,6 +232,22 @@ function processImageProps( ].filter((attribute): attribute is t.JSXAttribute | t.JSXSpreadAttribute => attribute !== undefined); } +function processRuntimeImageProps(path: NodePath, file: HubFile) { + const accessibilityInfo = buildImageAccessibilityInfo(path, file); + const runtimeInfo = buildRuntimeImageInfo(path, file); + if (!runtimeInfo) return; + + const consumed = new Set([...runtimeInfo.attributes, ...(accessibilityInfo?.attributes ?? [])]); + + const remaining = path.node.attributes.filter( + (attribute) => !t.isJSXAttribute(attribute) || !consumed.has(attribute) + ); + + path.node.attributes = [...remaining, accessibilityInfo?.spreadAttribute, runtimeInfo.spreadAttribute].filter( + (attribute): attribute is t.JSXAttribute | t.JSXSpreadAttribute => attribute !== undefined + ); +} + function buildImageAccessibilityInfo( path: NodePath, file: HubFile @@ -289,7 +308,38 @@ function buildImageAccessibilityInfo( }; } -function buildNativeSource(attributes: Array): NativeSource | undefined { +function buildRuntimeImageInfo(path: NodePath, file: HubFile): RuntimeImageInfo | undefined { + const attributes = [ + findAttribute(path.node.attributes, 'source'), + findAttribute(path.node.attributes, 'src'), + findAttribute(path.node.attributes, 'width'), + findAttribute(path.node.attributes, 'height'), + findAttribute(path.node.attributes, 'crossOrigin'), + findAttribute(path.node.attributes, 'referrerPolicy'), + findAttribute(path.node.attributes, 'style'), + findAttribute(path.node.attributes, 'resizeMode'), + findAttribute(path.node.attributes, 'tintColor'), + ].filter((attribute): attribute is t.JSXAttribute => attribute !== undefined); + + if (attributes.length === 0) return undefined; + + const helperIdentifier = addFileImportHint({ + file, + nameHint: 'processImageSourceProps', + path, + importName: 'processImageSourceProps', + moduleName: RUNTIME_MODULE_NAME, + }); + + return { + attributes, + spreadAttribute: t.jsxSpreadAttribute( + t.callExpression(t.identifier(helperIdentifier.name), [buildPropertiesFromAttributes(attributes)]) + ), + }; +} + +function buildStaticNativeSource(attributes: Array): NativeSource | undefined { const requestHeaders = buildRequestHeaders(attributes); if (!requestHeaders) return undefined; @@ -482,9 +532,8 @@ function flattenStaticStyle(styleExpression: t.Expression): Map): t.Expression | undefined { - const style = findAttribute(attributes, 'style'); - return style ? getAttributeValueExpression(style) : undefined; +function hasImageSourceInput(attributes: Array): boolean { + return findAttribute(attributes, 'source') !== undefined || findAttribute(attributes, 'src') !== undefined; } function getAttributeExpression( diff --git a/packages/react-native-boost/src/runtime/__tests__/index.test.ts b/packages/react-native-boost/src/runtime/__tests__/index.test.ts index 82c1d4b..988626d 100644 --- a/packages/react-native-boost/src/runtime/__tests__/index.test.ts +++ b/packages/react-native-boost/src/runtime/__tests__/index.test.ts @@ -5,6 +5,7 @@ import { processAccessibilityProps, processViewAccessibilityProps, processImageAccessibilityProps, + processImageSourceProps, getDefaultTextAccessible, clampNumberOfLines, userSelectToSelectableMap, @@ -28,6 +29,15 @@ vi.mock('../components/native-image', () => ({ // reads the live `OS`, mirroring react-native's own implementation; tests flip `Platform.OS` and the // shared `afterEach` resets it. vi.mock('react-native', () => { + const flattenStyle = (style: unknown): unknown => { + if (!Array.isArray(style)) return style; + const result: Record = {}; + for (const entry of style) { + const flat = flattenStyle(entry); + if (flat && typeof flat === 'object') Object.assign(result, flat); + } + return result; + }; const Platform = { OS: 'ios' as 'ios' | 'android', select(spec: Record): T | undefined { @@ -37,9 +47,12 @@ vi.mock('react-native', () => { return { View: () => 'View', Text: () => 'Text', + Image: Object.assign(() => 'Image', { + resolveAssetSource: (source: T): T => source, + }), Platform, StyleSheet: { - flatten: (style: any) => style, + flatten: flattenStyle, }, // Distinguishable stand-in for RN's `processColor` so `processSelectionColor` can be asserted to // actually call it (a named color → packed int) rather than passing the value through unchanged. @@ -471,6 +484,89 @@ describe('processImageAccessibilityProps', () => { }); }); +describe('processImageSourceProps', () => { + it('resolves object sources into native source/style/resize props', () => { + expect(processImageSourceProps({ source: { uri: 'logo.png', width: 16, height: 8 } })).toEqual({ + style: [{ width: 16, height: 8 }, { overflow: 'hidden' }, undefined], + source: [{ uri: 'logo.png', width: 16, height: 8 }], + resizeMode: 'cover', + }); + }); + + it('keeps array sources as arrays and ignores width/height style synthesis', () => { + expect( + processImageSourceProps({ + source: [{ uri: 'logo.png', width: 16, height: 8 }], + width: 20, + height: 10, + }).style + ).toEqual([{ overflow: 'hidden' }, undefined]); + }); + + it('synthesizes src and request headers on Android', () => { + Platform.OS = 'android'; + expect( + processImageSourceProps({ + src: 'https://example.com/logo.png', + width: 16, + height: 8, + crossOrigin: 'use-credentials', + referrerPolicy: 'origin', + }) + ).toMatchObject({ + source: [ + { + uri: 'https://example.com/logo.png', + headers: { + 'Access-Control-Allow-Credentials': 'true', + 'Referrer-Policy': 'origin', + }, + width: 16, + height: 8, + }, + ], + src: [ + { + uri: 'https://example.com/logo.png', + headers: { + 'Access-Control-Allow-Credentials': 'true', + 'Referrer-Policy': 'origin', + }, + width: 16, + height: 8, + }, + ], + headers: { + 'Access-Control-Allow-Credentials': 'true', + 'Referrer-Policy': 'origin', + }, + }); + }); + + it('derives resizeMode and iOS tintColor from dynamic style', () => { + expect( + processImageSourceProps({ + source: { uri: 'logo.png' }, + style: [{ objectFit: 'fill' }, { tintColor: 'red' }], + }) + ).toMatchObject({ + resizeMode: 'stretch', + tintColor: 'red', + }); + }); + + it('preserves Android tintColor wrapper semantics', () => { + Platform.OS = 'android'; + expect( + processImageSourceProps({ + source: { uri: 'logo.png' }, + style: { tintColor: 'red' }, + }).tintColor + ).toBeUndefined(); + expect(processImageSourceProps({ source: { uri: 'logo.png' }, tintColor: null }).tintColor).toBeNull(); + }); +}); + describe('clampNumberOfLines', () => { it('clamps negative values to 0', () => { expect(clampNumberOfLines(-1)).toBe(0); diff --git a/packages/react-native-boost/src/runtime/__tests__/mocks/react-native.ts b/packages/react-native-boost/src/runtime/__tests__/mocks/react-native.ts index bbdbadc..570dde1 100644 --- a/packages/react-native-boost/src/runtime/__tests__/mocks/react-native.ts +++ b/packages/react-native-boost/src/runtime/__tests__/mocks/react-native.ts @@ -1,13 +1,23 @@ export const View = () => 'View'; export const Text = () => 'Text'; -export const Image = () => 'Image'; +export const Image = Object.assign(() => 'Image', { + resolveAssetSource: (source: T): T => source, +}); export const Platform = { OS: 'ios', }; export const StyleSheet = { - flatten: (style: T) => style, + flatten: (style: unknown): unknown => { + if (!Array.isArray(style)) return style; + const result: Record = {}; + for (const entry of style) { + const flat = StyleSheet.flatten(entry); + if (flat && typeof flat === 'object') Object.assign(result, flat); + } + return result; + }, }; // Backs the runtime index's `import { processColor } from 'react-native'`. Identity is fine here: the diff --git a/packages/react-native-boost/src/runtime/index.ts b/packages/react-native-boost/src/runtime/index.ts index 2f31490..e5724a8 100644 --- a/packages/react-native-boost/src/runtime/index.ts +++ b/packages/react-native-boost/src/runtime/index.ts @@ -1,15 +1,36 @@ -import { TextProps, TextStyle, StyleSheet, Platform, processColor as rnProcessColor } from 'react-native'; +import { + TextProps, + TextStyle, + StyleSheet, + Platform, + Image as RNImage, + processColor as rnProcessColor, +} from 'react-native'; import type { ColorValue, ProcessedColorValue } from 'react-native'; import { GenericStyleProp } from './types'; import { userSelectToSelectableMap, verticalAlignToTextAlignVerticalMap } from './utils/constants'; const propsCache = new WeakMap(); +const imageBaseStyle = { overflow: 'hidden' } as const; +const emptyImageSource = { uri: undefined, width: undefined, height: undefined }; // Resolve RN's `processColor` once. The `typeof` guard degrades to a passthrough on // a non-RN host that lacks it (see {@link processSelectionColor}); the web build never reaches this — it // uses the separate `index.web.ts` shim. const processColor: ((color?: ColorValue | number) => ProcessedColorValue | null | undefined) | undefined = typeof rnProcessColor === 'function' ? rnProcessColor : undefined; +const resolveImageAssetSource = + typeof RNImage.resolveAssetSource === 'function' + ? RNImage.resolveAssetSource.bind(RNImage) + : (source: T): T => source; + +const objectFitToResizeMode: Record = { + 'contain': 'contain', + 'cover': 'cover', + 'fill': 'stretch', + 'none': 'none', + 'scale-down': 'contain', +}; /** * Normalizes `Text` style values for `NativeText`. @@ -80,6 +101,81 @@ export function processSelectionColor(selectionColor?: ColorValue | number | nul return processed === undefined ? {} : { selectionColor: processed }; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ImageSourceHelperProps = Record; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getImageSourcesFromProps(props: ImageSourceHelperProps): any { + const source = resolveImageAssetSource(props.source); + const headers: Record = {}; + + if (props.crossOrigin === 'use-credentials') { + headers['Access-Control-Allow-Credentials'] = 'true'; + } + if (props.referrerPolicy != null) { + headers['Referrer-Policy'] = props.referrerPolicy; + } + + if (props.src != null) { + return [{ uri: props.src, headers, width: props.width, height: props.height }]; + } + if (source != null && source.uri && Object.keys(headers).length > 0) { + return [{ ...source, headers }]; + } + return source; +} + +/** + * Normalizes dynamic `Image` source/style props for `NativeImage`. + * + * @remarks + * Static Image cases are still rewritten at build time. This helper is only emitted when the source + * or style cannot be safely flattened by Babel, so it mirrors the RN wrapper's runtime work: + * `resolveAssetSource`, `src`/request-header synthesis, object-vs-array source style construction, + * `objectFit`/`resizeMode`, and iOS tint fallback. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function processImageSourceProps(props: ImageSourceHelperProps): Record { + const source = getImageSourcesFromProps(props) || emptyImageSource; + let style; + let sources; + let headers; + + if (Array.isArray(source)) { + style = [imageBaseStyle, props.style]; + sources = source; + headers = source[0]?.headers; + } else { + const width = source.width ?? props.width; + const height = source.height ?? props.height; + style = [{ width, height }, imageBaseStyle, props.style]; + sources = [source]; + headers = source.headers; + } + + const flattenedStyle = StyleSheet.flatten(style); + const objectFit = flattenedStyle?.objectFit; + const resizeMode = + (typeof objectFit === 'string' ? objectFitToResizeMode[objectFit] : undefined) || + props.resizeMode || + flattenedStyle?.resizeMode || + 'cover'; + const tintColor = Platform.OS === 'android' ? props.tintColor : (props.tintColor ?? flattenedStyle?.tintColor); + const result: Record = { + style, + source: sources, + resizeMode, + }; + Object.assign(result, tintColor === undefined ? {} : { tintColor }); + + if (Platform.OS === 'android') { + result.src = sources; + Object.assign(result, headers === null || headers === undefined ? {} : { headers }); + } + + return result; +} + /** * The default value `Text` resolves for `accessible` when the prop is omitted: `true` on iOS (text is * an accessibility element unless opted out), `false` on Android, and `undefined` elsewhere. diff --git a/packages/react-native-boost/src/runtime/index.web.ts b/packages/react-native-boost/src/runtime/index.web.ts index 1e9fa55..9548023 100644 --- a/packages/react-native-boost/src/runtime/index.web.ts +++ b/packages/react-native-boost/src/runtime/index.web.ts @@ -35,6 +35,11 @@ export function processImageAccessibilityProps(props: Record): Reco return props; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function processImageSourceProps(props: Record): Record { + return props; +} + export * from './types'; export * from './utils/constants'; From 5eae71fb75af39e5059b57d080aee80870066213 Mon Sep 17 00:00:00 2001 From: Adam Ivancza Date: Fri, 3 Jul 2026 13:03:17 +0200 Subject: [PATCH 9/9] test(image): address review coverage gaps --- .../native-attribute-conformance.test.ts | 17 ++++- .../__tests__/parity/fuzz/vocabulary.ts | 8 ++- .../src/plugin/__tests__/parity/normalize.ts | 3 + .../plugin/__tests__/parity/parity.test.ts | 66 +++++++++++++++++++ .../runtime/components/native-image.test.ts | 21 +++++- .../src/runtime/components/native-image.tsx | 2 +- 6 files changed, 110 insertions(+), 7 deletions(-) diff --git a/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts b/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts index ae846be..decd393 100644 --- a/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts +++ b/packages/react-native-boost/src/plugin/__tests__/native-attribute-conformance.test.ts @@ -203,8 +203,8 @@ describe('native attribute conformance', () => { }); describe('Image', () => { - it.each([...IMAGE_WRAPPER_ONLY_PROPS, ...IMAGE_PASSTHROUGH_PROPS])( - 'leaves only native attributes on the host for ', + it.each(IMAGE_WRAPPER_ONLY_PROPS)( + 'leaves only native attributes on the host for when optimized', (attributes) => { const result = optimizeAndInspect(imageSource(attributes), imageOptimizer, 'Image', 'ios'); if (!result?.optimized) return; // bailed out: nothing reaches the native component @@ -214,5 +214,18 @@ describe('native attribute conformance', () => { ); } ); + + it.each(IMAGE_PASSTHROUGH_PROPS)( + 'optimizes and leaves only native attributes on the host for ', + (attributes) => { + const result = optimizeAndInspect(imageSource(attributes), imageOptimizer, 'Image', 'ios'); + expect(result?.optimized).toBe(true); + const leaked = result?.attributes.filter((attribute) => !NATIVE_IMAGE_ATTRIBUTES.has(attribute)); + expect( + leaked, + `optimized leaks non-native attribute(s): ${leaked?.join(', ')}` + ).toEqual([]); + } + ); }); }); diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/vocabulary.ts b/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/vocabulary.ts index 95eba00..b3e46fd 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/vocabulary.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/fuzz/vocabulary.ts @@ -340,10 +340,14 @@ export const IMAGE_VOCAB: PropSpec[] = [ disposition: 'resize mode fallback', }, { name: 'tintColor', arb: withNullish(fc.constantFrom('"red"', '"blue"')), disposition: 'tintColor fallback' }, - { name: 'crossOrigin', arb: fc.constantFrom('"anonymous"', '"use-credentials"'), disposition: 'request headers' }, + { + name: 'crossOrigin', + arb: withNullish(fc.constantFrom('"anonymous"', '"use-credentials"')), + disposition: 'request headers', + }, { name: 'referrerPolicy', - arb: fc.constantFrom('"origin"', '"no-referrer"', '"same-origin"'), + arb: withNullish(fc.constantFrom('"origin"', '"no-referrer"', '"same-origin"')), disposition: 'request headers', }, { name: 'alt', arb: withNullish(str), disposition: 'translate → accessibilityLabel + accessible' }, diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/normalize.ts b/packages/react-native-boost/src/plugin/__tests__/parity/normalize.ts index e9b26d7..3355890 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/normalize.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/normalize.ts @@ -31,6 +31,9 @@ export const normalize = (props: Record) => export const normalizeImage = (props: Record) => { const normalized = normalize(props); + // These are wrapper-level Image inputs. The RN wrapper may still pass the authored prop through to + // the mock host while Boost translates it into native-facing props (`source`/`headers`/`style`/a11y). + // parity.test.ts asserts those translated outputs directly for the representative cases. for (const key of [ 'alt', 'aria-busy', diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts b/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts index cfa6b2a..8dbba5e 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts @@ -150,6 +150,70 @@ const DYNAMIC_IMAGE_CASES: Array<[string, string]> = [ ], ]; +const getFirstImageSource = (props: Record) => { + const source = props.source; + if (!Array.isArray(source)) throw new Error('expected Image source to be normalized to an array'); + return source[0] as Record; +}; + +const IMAGE_PROP_ASSERTIONS = new Map, os: (typeof PLATFORMS)[number]) => void>( + [ + [ + '', + (props) => expect(normalize(props).style).toMatchObject({ width: 16, height: 16 }), + ], + [ + '', + (props) => expect(getFirstImageSource(props)).toMatchObject({ width: 16, height: 16 }), + ], + [ + '', + (props) => + expect(getFirstImageSource(props).headers).toEqual({ + 'Access-Control-Allow-Credentials': 'true', + 'Referrer-Policy': 'origin', + }), + ], + [ + 'Logo', + (props) => expect(props).toMatchObject({ accessibilityLabel: 'Logo', accessible: true }), + ], + [ + '', + (props) => expect(props.accessibilityLabel).toBe('Logo'), + ], + [ + '', + (props, os) => { + if (os === 'android') expect(props.importantForAccessibility).toBe('no-hide-descendants'); + }, + ], + [ + '', + (props, os) => { + if (os === 'android') expect(props.accessibilityState).toEqual({ selected: true, busy: true }); + else expect(props.accessibilityState).toEqual({ selected: true }); + }, + ], + ] +); + +const DYNAMIC_IMAGE_PROP_ASSERTIONS = new Map) => void>([ + [ + '', + (props) => { + expect(getFirstImageSource(props)).toMatchObject({ + width: 16, + height: 8, + headers: { + 'Access-Control-Allow-Credentials': 'true', + 'Referrer-Policy': 'origin', + }, + }); + }, + ], +]); + describe('differential parity', () => { describe.each(PLATFORMS)('Platform.OS=%s', (os) => { it.each(TEXT_CASES)('Text: %s', async (jsx) => { @@ -168,6 +232,7 @@ describe('differential parity', () => { const wrapper = await captureWrapper(os, jsx); expect(boost.which).toEqual(wrapper.which); expect(normalizeImage(boost.props)).toEqual(normalizeImage(wrapper.props)); + IMAGE_PROP_ASSERTIONS.get(jsx)?.(boost.props, os); }); it.each(DYNAMIC_IMAGE_CASES)('Image dynamic: %s', async (jsx, preamble) => { @@ -177,6 +242,7 @@ describe('differential parity', () => { const wrapper = await captureWrapper(os, jsx, preamble); expect(boost.which).toEqual(wrapper.which); expect(normalizeImage(boost.props)).toEqual(normalizeImage(wrapper.props)); + DYNAMIC_IMAGE_PROP_ASSERTIONS.get(jsx)?.(boost.props); }); it.each(VIEW_CASES)('View: %s', async (jsx) => { diff --git a/packages/react-native-boost/src/runtime/components/native-image.test.ts b/packages/react-native-boost/src/runtime/components/native-image.test.ts index 1653705..035ba9d 100644 --- a/packages/react-native-boost/src/runtime/components/native-image.test.ts +++ b/packages/react-native-boost/src/runtime/components/native-image.test.ts @@ -9,7 +9,7 @@ type LoadNativeComponent = () => { default?: ComponentType }; async function importNativeImage({ os, - loadNativeComponent = vi.fn(() => ({ default: nativeHost })), + loadNativeComponent, }: { os: string; loadNativeComponent?: LoadNativeComponent; @@ -19,18 +19,35 @@ async function importNativeImage({ Image: reactNativeImage, Platform: { OS: os }, })); - vi.stubGlobal('require', loadNativeComponent); + vi.doMock('react-native/Libraries/Image/ImageViewNativeComponent', () => { + if (loadNativeComponent) return loadNativeComponent(); + return { default: nativeHost }; + }); return import('./native-image'); } afterEach(() => { vi.doUnmock('react-native'); + vi.doUnmock('react-native/Libraries/Image/ImageViewNativeComponent'); vi.unstubAllGlobals(); vi.resetModules(); }); describe('NativeImage', () => { + it('resolves to the internal native host when loading succeeds on a non-web platform', async () => { + const { resolveNativeImageComponent } = await importNativeImage({ os: 'android' }); + const NativeImage = resolveNativeImageComponent( + { + Image: reactNativeImage, + Platform: { OS: 'android' }, + }, + () => ({ default: nativeHost }) + ); + + expect(NativeImage).toBe(nativeHost); + }); + it('falls back to React Native Image when the internal host cannot be loaded', async () => { const { NativeImage } = await importNativeImage({ os: 'android', diff --git a/packages/react-native-boost/src/runtime/components/native-image.tsx b/packages/react-native-boost/src/runtime/components/native-image.tsx index 7421da1..4d5d306 100644 --- a/packages/react-native-boost/src/runtime/components/native-image.tsx +++ b/packages/react-native-boost/src/runtime/components/native-image.tsx @@ -21,7 +21,7 @@ function loadImageViewNativeComponent(): NativeImageModule { return require('react-native/Libraries/Image/ImageViewNativeComponent'); } -function resolveNativeImageComponent( +export function resolveNativeImageComponent( reactNativeModule: ReactNativeImageModule, loadNativeComponent: () => NativeImageModule = loadImageViewNativeComponent ): ComponentType {