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 = [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + 'Logo', + '', + '', + '', + '', + '', + '', +]; + +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', + }), + ], + [ + 'Logo', + (props) => expect(props).toMatchObject({ accessibilityLabel: 'Logo', accessible: true }), + ], + [ + '', + (props) => expect(props.accessibilityLabel).toBe('Logo'), + ], + [ + '', + (props, os) => { + if (os === 'android') expect(props.importantForAccessibility).toBe('no-hide-descendants'); + }, + ], + [ + '', + (props, os) => { + if (os === 'android') expect(props.accessibilityState).toEqual({ selected: true, busy: true }); + else expect(props.accessibilityState).toEqual({ selected: true }); + }, + ], + ] +); + +const DYNAMIC_IMAGE_PROP_ASSERTIONS = new Map) => void>([ + [ + '', + (props) => { + expect(getFirstImageSource(props)).toMatchObject({ + width: 16, + height: 8, + headers: { + 'Access-Control-Allow-Credentials': 'true', + 'Referrer-Policy': 'origin', + }, + }); + }, + ], +]); + describe('differential parity', () => { describe.each(PLATFORMS)('Platform.OS=%s', (os) => { it.each(TEXT_CASES)('Text: %s', async (jsx) => { @@ -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(); + +Logo; + +Alt; + +; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-props/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-props/output.js new file mode 100644 index 0000000..215dff3 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-props/output.js @@ -0,0 +1,108 @@ +import { + processImageAccessibilityProps as _processImageAccessibilityProps, + NativeImage as _NativeImage, +} from 'react-native-boost/runtime'; +import { Image } from 'react-native'; +const label = getLabel(); +const labelledBy = getLabelledBy(); +<_NativeImage + {..._processImageAccessibilityProps({ + alt: 'Logo', + accessible: false, + accessibilityLabel: 'Fallback', + })} + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + }, + ]} + resizeMode="cover" +/>; +<_NativeImage + {..._processImageAccessibilityProps({ + 'aria-label': label, + 'accessibilityLabel': 'Fallback', + 'alt': 'Alt', + })} + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + }, + ]} + resizeMode="cover" +/>; +<_NativeImage + {..._processImageAccessibilityProps({ + 'accessibilityState': { + busy: false, + checked: true, + }, + 'aria-busy': true, + 'aria-disabled': false, + })} + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + }, + ]} + resizeMode="cover" +/>; +<_NativeImage + {..._processImageAccessibilityProps({ + 'aria-hidden': true, + 'accessible': true, + 'importantForAccessibility': 'yes', + 'aria-labelledby': labelledBy, + 'accessibilityLabelledBy': 'fallback', + })} + style={[ + { + width: 16, + height: 16, + }, + { + overflow: 'hidden', + }, + ]} + source={[ + { + uri: 'logo.png', + width: 16, + height: 16, + }, + ]} + resizeMode="cover" +/>; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-spread-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-spread-bails/code.js new file mode 100644 index 0000000..9978599 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-spread-bails/code.js @@ -0,0 +1,5 @@ +import { Image } from 'react-native'; + +const props = { 'aria-label': 'Logo' }; + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-spread-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-spread-bails/output.js new file mode 100644 index 0000000..e15bac3 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/image/__tests__/fixtures/accessibility-spread-bails/output.js @@ -0,0 +1,12 @@ +import { Image } from 'react-native'; +const props = { + 'aria-label': 'Logo', +}; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/image/__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',