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..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
@@ -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 { Optimizer, TargetPlatform } from '../types';
+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;
@@ -32,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 => ({
@@ -56,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;
@@ -65,6 +71,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 +105,35 @@ 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-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"`,
+ `${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}',
+ '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 }}`,
+ `${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', () => {
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 +154,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', 'ios')?.optimized).toBe(true);
});
describe('View', () => {
@@ -156,4 +201,31 @@ describe('native attribute conformance', () => {
}
);
});
+
+ describe('Image', () => {
+ 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
+ const leaked = result.attributes.filter((attribute) => !NATIVE_IMAGE_ATTRIBUTES.has(attribute));
+ expect(leaked, `optimized leaks non-native attribute(s): ${leaked.join(', ')}`).toEqual(
+ []
+ );
+ }
+ );
+
+ 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__/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/__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 413d587..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
@@ -10,12 +10,15 @@ 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')).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);
@@ -50,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' };
@@ -85,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));
});
@@ -106,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..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
@@ -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,67 @@ 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: withNullish(fc.constantFrom('"anonymous"', '"use-credentials"')),
+ disposition: 'request headers',
+ },
+ {
+ name: 'referrerPolicy',
+ arb: withNullish(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 f60bcd5..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,9 @@ import { NativeTextCapturer, NativeViewCapturer } from '../capture';
*/
export const NativeText = NativeTextCapturer;
export const NativeView = NativeViewCapturer;
+export const NativeImage = NativeImageCapturer;
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/__tests__/parity/mocks/react-native.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/react-native.ts
index 42a3f8e..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
@@ -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,9 @@ export const unstable_NativeText = NativeTextCapturer;
export const unstable_NativeView = NativeViewCapturer;
export const Text = NativeTextCapturer;
export const View = NativeViewCapturer;
+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/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..3355890 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,46 @@ 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);
+
+ // 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',
+ '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 4a491c6..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
@@ -10,10 +10,13 @@ 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')).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;
@@ -104,6 +107,113 @@ const VIEW_CASES = [
// a silent loss of optimization — from masquerading as a passing parity test.
const BAILED_VIEW_CASES = new Set(['', '']);
+const IMAGE_CASES = [
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+];
+
+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;',
+ ],
+];
+
+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',
+ }),
+ ],
+ [
+ '',
+ (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) => {
@@ -115,6 +225,26 @@ 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));
+ IMAGE_PROP_ASSERTIONS.get(jsx)?.(boost.props, os);
+ });
+
+ 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));
+ DYNAMIC_IMAGE_PROP_ASSERTIONS.get(jsx)?.(boost.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
@@ -47,6 +50,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);
},
},
};
@@ -64,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/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();
+
+;
+
+;
+
+;
+
+;
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/__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-request-headers-runtime/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-request-headers-runtime/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-runtime/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-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-runtime/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-runtime/code.js
new file mode 100644
index 0000000..47efeab
--- /dev/null
+++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/dynamic-source-runtime/code.js
@@ -0,0 +1,4 @@
+import { Image } from 'react-native';
+
+;
+;
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/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/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-runtime/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-runtime/code.js
new file mode 100644
index 0000000..4ed42b3
--- /dev/null
+++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/force-dynamic-runtime/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-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/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/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..0c56700
--- /dev/null
+++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/code.js
@@ -0,0 +1,13 @@
+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..2946041
--- /dev/null
+++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/request-headers/output.js
@@ -0,0 +1,61 @@
+import { NativeImage as _NativeImage } from 'react-native-boost/runtime';
+import { Image } from 'react-native';
+<_NativeImage
+ style={[
+ {},
+ {
+ 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={[
+ {},
+ {
+ overflow: 'hidden',
+ },
+ ]}
+ source={[
+ {
+ uri: 'logo.png',
+ width: 16,
+ height: 16,
+ headers: {
+ 'Access-Control-Allow-Credentials': 'true',
+ 'Referrer-Policy': 'origin',
+ },
+ },
+ ]}
+ 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/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/__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/__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/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..c90a088
--- /dev/null
+++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/src-prop/output.js
@@ -0,0 +1,19 @@
+import { NativeImage as _NativeImage } from 'react-native-boost/runtime';
+import { Image } from 'react-native';
+<_NativeImage
+ style={[
+ {},
+ {
+ 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__/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__/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..066a667
--- /dev/null
+++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/code.js
@@ -0,0 +1,20 @@
+import { Image } from 'react-native';
+
+ {}}
+ 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
new file mode 100644
index 0000000..3be9364
--- /dev/null
+++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/view-props/output.js
@@ -0,0 +1,40 @@
+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}
+ onLayout={() => {}}
+ borderRadius={4}
+ borderTopLeftRadius={1}
+ borderTopRightRadius={2}
+ borderBottomLeftRadius={3}
+ borderBottomRightRadius={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__/index.test.ts b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/index.test.ts
new file mode 100644
index 0000000..ae65b53
--- /dev/null
+++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/index.test.ts
@@ -0,0 +1,254 @@
+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, {}, 'ios'),
+ title: 'image',
+ fixtures: path.resolve(import.meta.dirname, 'fixtures'),
+ babelOptions: {
+ plugins: ['@babel/plugin-syntax-jsx'],
+ },
+ 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
new file mode 100644
index 0000000..582bee1
--- /dev/null
+++ b/packages/react-native-boost/src/plugin/optimizers/image/index.ts
@@ -0,0 +1,667 @@
+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 {
+ addFileImportHint,
+ buildPropertiesFromAttributes,
+ hasBlacklistedProperty,
+ hasBlacklistedPropertyInSpread,
+ isIgnoredLine,
+ isForcedLine,
+ isReactNativeImport,
+ isStaticLiteralTree,
+ isValidJSXComponent,
+ replaceWithNativeComponent,
+ ancestorBailoutChecks,
+} from '../../utils/common';
+import { RUNTIME_MODULE_NAME } from '../../utils/constants';
+
+const IMAGE_BAILOUT_PROPS = new Set([
+ 'aria-live',
+ 'aria-valuemax',
+ 'aria-valuemin',
+ 'aria-valuenow',
+ 'aria-valuetext',
+ 'children',
+ 'defaultSource',
+ 'id',
+ 'internal_analyticTag',
+ 'loadingIndicatorSource',
+ 'onError',
+ 'onLoad',
+ 'onLoadEnd',
+ 'onLoadStart',
+ 'onPartialLoad',
+ 'onProgress',
+ 'ref',
+ 'srcSet',
+ '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_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 = {
+ '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 hardChecks: BailoutCheck[] = [
+ {
+ reason: 'target platform is unknown',
+ shouldBail: () => platform !== 'ios' && platform !== 'android',
+ },
+ {
+ reason: 'contains unsupported Image props',
+ shouldBail: () => hasBlacklistedProperty(path, IMAGE_BAILOUT_PROPS),
+ },
+ {
+ reason: 'has a spread that may carry Image wrapper props',
+ shouldBail: () => hasBlacklistedPropertyInSpread(path, IMAGE_SPREAD_GUARD_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: () => !hasImageSourceInput(path.node.attributes),
+ },
+ ];
+
+ 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) {
+ 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 = buildStaticNativeSource(path.node.attributes);
+ const styleInfo = buildStaticStyleInfo(path.node.attributes);
+
+ logger.optimized({ component: 'Image', path });
+
+ if (nativeSource && styleInfo !== null) {
+ processImageProps(path, file, nativeSource, styleInfo, platform);
+ } else {
+ processRuntimeImageProps(path, file);
+ }
+ replaceWithNativeComponent(path, parent, file, 'NativeImage');
+};
+
+type NativeSource = {
+ sourceAttributes: t.JSXAttribute[];
+ requestHeaderAttributes: t.JSXAttribute[];
+ sourceArray: t.ArrayExpression;
+ consumesSizeProps: boolean;
+ androidHeaders?: t.Expression;
+ width?: t.Expression;
+ height?: t.Expression;
+};
+
+type StyleInfo = {
+ styleAttribute?: t.JSXAttribute;
+ styleExpression?: t.Expression;
+ objectFitResizeMode?: t.Expression;
+ styleResizeMode?: t.Expression;
+ tintColor?: t.Expression;
+} | null;
+
+type ImageAccessibilityInfo = {
+ attributes: t.JSXAttribute[];
+ spreadAttribute: t.JSXSpreadAttribute;
+};
+
+type RuntimeImageInfo = {
+ attributes: t.JSXAttribute[];
+ spreadAttribute: t.JSXSpreadAttribute;
+};
+
+function processImageProps(
+ path: NodePath,
+ file: HubFile,
+ nativeSource: NativeSource,
+ styleInfo: StyleInfo,
+ platform?: string
+) {
+ const accessibilityInfo = buildImageAccessibilityInfo(path, file);
+ const consumed = new Set([
+ ...nativeSource.sourceAttributes,
+ ...nativeSource.requestHeaderAttributes,
+ ...(accessibilityInfo?.attributes ?? []),
+ ]);
+ 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;
+ if (nativeSource.consumesSizeProps && (name === 'width' || name === 'height')) return false;
+ return name !== 'resizeMode' && name !== 'tintColor';
+ });
+
+ 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),
+ 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 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
+): 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 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;
+
+ const src = findAttribute(attributes, 'src');
+ if (src) {
+ const uri = getAttributeValueExpression(src);
+ if (!t.isStringLiteral(uri)) return undefined;
+ const source = findAttribute(attributes, 'source');
+ const width = getAttributeExpression(attributes, 'width');
+ const height = getAttributeExpression(attributes, 'height');
+ const headers = t.cloneNode(requestHeaders.headers, true);
+ // `src` resolves to an array source in RN's wrapper, and array sources do not synthesize
+ // width/height into style. Keep the dimensions in the source entry only.
+ return {
+ sourceAttributes: [src, source].filter((attribute): attribute is t.JSXAttribute => attribute !== undefined),
+ requestHeaderAttributes: requestHeaders.attributes,
+ sourceArray: t.arrayExpression([
+ t.objectExpression([
+ t.objectProperty(t.identifier('uri'), uri),
+ t.objectProperty(t.identifier('headers'), headers),
+ ...(width ? [t.objectProperty(t.identifier('width'), width)] : []),
+ ...(height ? [t.objectProperty(t.identifier('height'), height)] : []),
+ ]),
+ ]),
+ consumesSizeProps: true,
+ androidHeaders: t.cloneNode(requestHeaders.headers, true),
+ };
+ }
+
+ const source = findAttribute(attributes, 'source');
+ if (!source || !t.isJSXExpressionContainer(source.value)) return undefined;
+ const sourceExpression = source.value.expression;
+ if (!t.isObjectExpression(sourceExpression) && !t.isArrayExpression(sourceExpression)) return undefined;
+ if (!isStaticLiteralTree(sourceExpression)) return undefined;
+
+ if (t.isArrayExpression(sourceExpression)) {
+ if (requestHeaders.attributes.length > 0) return undefined;
+ return {
+ 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 = 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 {
+ sourceAttributes: [source],
+ requestHeaderAttributes: requestHeaders.attributes,
+ sourceArray: t.arrayExpression([sourceArrayObject]),
+ consumesSizeProps: true,
+ androidHeaders: usesGeneratedHeaders
+ ? t.cloneNode(requestHeaders.headers, true)
+ : getObjectPropertyExpression(sourceArrayObject, 'headers'),
+ width: usesGeneratedHeaders ? undefined : width,
+ height: usesGeneratedHeaders ? undefined : height,
+ };
+}
+
+type RequestHeaders = {
+ attributes: t.JSXAttribute[];
+ headers: t.ObjectExpression;
+};
+
+function buildRequestHeaders(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 || !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)));
+ return nativeSource;
+}
+
+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;
+
+ return {
+ styleAttribute,
+ styleExpression,
+ objectFitResizeMode: resizeModeFromObjectFit ? t.stringLiteral(resizeModeFromObjectFit) : undefined,
+ styleResizeMode: cloneMapValue(flattened, '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 hasImageSourceInput(attributes: Array): boolean {
+ return findAttribute(attributes, 'source') !== undefined || findAttribute(attributes, 'src') !== undefined;
+}
+
+function getAttributeExpression(
+ attributes: Array,
+ name: string
+): t.Expression | undefined {
+ const attribute = findAttribute(attributes, name);
+ 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
+): 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 hasObjectProperty(object: t.ObjectExpression, name: string): boolean {
+ return object.properties.some(
+ (property) =>
+ 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;
+}
+
+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..c03ceb5 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 {
@@ -79,9 +84,21 @@ 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';
+export type OptimizableComponent = 'Text' | 'View' | 'Image';
+
+export type TargetPlatform = 'ios' | 'android' | 'web';
export interface OptimizationLogPayload {
component: OptimizableComponent;
@@ -110,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/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..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,10 +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,
@@ -19,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);
},
},
};
@@ -27,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,
@@ -46,8 +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);
+ 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 bf7c11a..988626d 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,8 @@ import {
processSelectionColor,
processAccessibilityProps,
processViewAccessibilityProps,
+ processImageAccessibilityProps,
+ processImageSourceProps,
getDefaultTextAccessible,
clampNumberOfLines,
userSelectToSelectableMap,
@@ -19,10 +21,23 @@ 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.
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 {
@@ -32,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.
@@ -370,6 +388,185 @@ 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('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 },
+ 'aria-busy': true,
+ 'aria-disabled': false,
+ }).accessibilityState
+ ).toEqual({
+ busy: true,
+ checked: true,
+ disabled: false,
+ expanded: undefined,
+ selected: undefined,
+ });
+ });
+});
+
+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/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..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,12 +1,23 @@
export const View = () => 'View';
export const Text = () => 'Text';
+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/components/native-image.test.ts b/packages/react-native-boost/src/runtime/components/native-image.test.ts
new file mode 100644
index 0000000..035ba9d
--- /dev/null
+++ b/packages/react-native-boost/src/runtime/components/native-image.test.ts
@@ -0,0 +1,69 @@
+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,
+}: {
+ os: string;
+ loadNativeComponent?: LoadNativeComponent;
+}) {
+ vi.resetModules();
+ vi.doMock('react-native', () => ({
+ Image: reactNativeImage,
+ Platform: { OS: os },
+ }));
+ 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',
+ 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
new file mode 100644
index 0000000..4d5d306
--- /dev/null
+++ b/packages/react-native-boost/src/runtime/components/native-image.tsx
@@ -0,0 +1,44 @@
+/* eslint-disable @typescript-eslint/no-require-imports,unicorn/prefer-module */
+
+import type { ComponentType } from 'react';
+import type { ImageProps } from 'react-native';
+import * as reactNativeModule from 'react-native';
+
+type ReactNativeImageModule = {
+ Image: ComponentType;
+ Platform: {
+ OS: string;
+ };
+};
+
+type NativeImageModule = {
+ default?: ComponentType;
+};
+
+const reactNative = reactNativeModule as ReactNativeImageModule;
+
+function loadImageViewNativeComponent(): NativeImageModule {
+ return require('react-native/Libraries/Image/ImageViewNativeComponent');
+}
+
+export function resolveNativeImageComponent(
+ reactNativeModule: ReactNativeImageModule,
+ loadNativeComponent: () => NativeImageModule = loadImageViewNativeComponent
+): ComponentType {
+ if (reactNativeModule.Platform.OS === 'web') return reactNativeModule.Image;
+
+ try {
+ return loadNativeComponent().default ?? reactNativeModule.Image;
+ } catch {
+ return reactNativeModule.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 = resolveNativeImageComponent(reactNative);
diff --git a/packages/react-native-boost/src/runtime/index.ts b/packages/react-native-boost/src/runtime/index.ts
index ec6f577..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.
@@ -302,7 +398,83 @@ 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 (Platform.OS === 'ios' && accessibilityState !== undefined) {
+ result.accessibilityState = accessibilityState;
+ } else 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';
export * from './components/native-view';
+export { NativeImage } 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..9548023 100644
--- a/packages/react-native-boost/src/runtime/index.web.ts
+++ b/packages/react-native-boost/src/runtime/index.web.ts
@@ -30,6 +30,16 @@ 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;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function processImageSourceProps(props: Record): Record {
+ return props;
+}
+
export * from './types';
export * from './utils/constants';
@@ -38,4 +48,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;
+}
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',