Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion apps/example/src/screens/benchmark.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,6 +21,31 @@ const benchmarks = [
unoptimizedComponent: <View style={{ borderWidth: 1, borderColor: 'red' }} />,
optimizedComponent: <View style={{ borderWidth: 1, borderColor: 'red' }} />,
},
{
title: 'Image',
count: 2000,
// @boost-ignore
unoptimizedComponent: (
<Image
source={{
uri: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==',
width: 16,
height: 16,
}}
style={{ width: 16, height: 16 }}
/>
),
optimizedComponent: (
<Image
source={{
uri: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==',
width: 16,
height: 16,
}}
style={{ width: 16, height: 16 }}
/>
),
},
] satisfies Benchmark[];

export default function BenchmarkScreen() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
Expand All @@ -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 => ({
Expand All @@ -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;
Expand All @@ -65,6 +71,7 @@ function optimizeAndInspect(source: string, optimizer: Optimizer, originalName:

const viewSource = (attributes: string) => `${SOURCE_HEADER}const element = <View ${attributes} />;`;
const textSource = (attributes: string) => `${SOURCE_HEADER}const element = <Text ${attributes}>hello</Text>;`;
const imageSource = (attributes: string) => `${SOURCE_HEADER}const element = <Image ${attributes} />;`;

/**
* Props the wrapper translates to a different native prop. Passing them through verbatim drops
Expand Down Expand Up @@ -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).
Expand All @@ -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', () => {
Expand Down Expand Up @@ -156,4 +201,31 @@ describe('native attribute conformance', () => {
}
);
});

describe('Image', () => {
it.each(IMAGE_WRAPPER_ONLY_PROPS)(
'leaves only native attributes on the host for <Image %s /> 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 <Image ${attributes} /> leaks non-native attribute(s): ${leaked.join(', ')}`).toEqual(
[]
);
}
);

it.each(IMAGE_PASSTHROUGH_PROPS)(
'optimizes and leaves only native attributes on the host for <Image %s />',
(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 <Image ${attributes} /> leaks non-native attribute(s): ${leaked?.join(', ')}`
).toEqual([]);
}
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -131,10 +132,16 @@ function extractValidAttributeKeys(subpath: string): Set<string> {
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<string> = new Set([...viewAttributesIos, ...viewAttributesAndroid]);

export const NATIVE_TEXT_ATTRIBUTES: ReadonlySet<string> = new Set([
...NATIVE_VIEW_ATTRIBUTES,
...textComponentAttributes,
]);

export const NATIVE_IMAGE_ATTRIBUTES: ReadonlySet<string> = new Set([
...NATIVE_VIEW_ATTRIBUTES,
...imageComponentAttributes,
]);
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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' };
Expand Down Expand Up @@ -85,15 +89,22 @@ describe.skipIf(DISCOVER)('parity fuzzing', () => {
async () => {
let optimized = 0;
let skipped = 0;
const byTag: Record<Tag, { optimized: number; skipped: number }> = {
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));
});

Expand All @@ -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)
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -128,7 +135,19 @@ const viewSpecArb: fc.Arbitrary<ElementSpec> = fc.record({
child: fc.constant<ChildSpec | null>(null),
});

export const elementSpecArb: fc.Arbitrary<ElementSpec> = fc.oneof(textSpecArb, viewSpecArb);
const imageSourceArb: fc.Arbitrary<GenAttr> = fc
.constantFrom(...IMAGE_SOURCE_VOCAB)
.chain((spec) => spec.arb.map((code): GenAttr => ({ name: spec.name, code, dynamic: false })));

const imageSpecArb: fc.Arbitrary<ElementSpec> = fc.record({
tag: fc.constant<Tag>('Image'),
attrs: fc.tuple(imageSourceArb, attrsArb(IMAGE_VOCAB)).map(([source, attrs]) => [source, ...attrs]),
blacklisted: fc.constant<GenAttr | null>(null),
spreads: spreadsArb(IMAGE_VOCAB),
child: fc.constant<ChildSpec | null>(null),
});

export const elementSpecArb: fc.Arbitrary<ElementSpec> = fc.oneof(textSpecArb, viewSpecArb, imageSpecArb);

export const platformArb = fc.constantFrom('ios' as const, 'android' as const);

Expand Down Expand Up @@ -172,6 +191,10 @@ export function render(spec: ElementSpec): RenderedCase {
return { preamble: declarations.join('\n'), jsxBody: `<View${attrs} />` };
}

if (spec.tag === 'Image') {
return { preamble: declarations.join('\n'), jsxBody: `<Image${attrs} />` };
}

const child = spec.child!;
let childStr: string;
if (child.kind === 'text' || child.kind === 'element') {
Expand Down
Loading