Skip to content
Merged
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
57 changes: 57 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,60 @@ jobs:

- name: Build plugin
run: pnpm package build

parity-matrix:
name: React Native ${{ matrix.react-native }} parity
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
react-native:
- '0.83'
- '0.84'
- '0.85'
- '0.86'
- '0.87'
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Select React Native version
env:
REACT_NATIVE_VERSION: ${{ matrix.react-native }}
run: |
node <<'NODE'
const fs = require('node:fs');
const path = 'packages/react-native-boost/package.json';
const packageJson = JSON.parse(fs.readFileSync(path, 'utf8'));
const version = process.env.REACT_NATIVE_VERSION;

packageJson.devDependencies['react-native'] = version;
packageJson.devDependencies['@react-native/babel-preset'] = version;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE

- uses: pnpm/action-setup@v6

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
packages/react-native-boost/package.json

- name: Enable Corepack
run: corepack enable

- name: Install dependencies
run: pnpm install --no-frozen-lockfile

- name: Typecheck package
run: pnpm package typecheck

- name: Build package
run: pnpm package build

- name: Test runtime parity
run: pnpm package test --run --project parity
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ vi.mock('../../../../runtime/components/native-image', async () => ({
import { captureBoost } from '../boost';
import { captureWrapper } from '../wrapper';
import { normalize, normalizeImage } from '../normalize';
import { type PlatformOS } from '../mocks/Platform';
import { reactNativeVersion, type PlatformOS } from '../mocks/Platform';
import { elementSpecArb, platformArb, render, type Tag } from './generator';
import { divergingKeys } from './diff';

Expand Down Expand Up @@ -53,9 +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 normalizer = boost.which === 'NativeImage' || wrapper.which === 'NativeImage' ? normalizeImage : normalize;
const boostNorm = normalizer(boost.props);
const wrapperNorm = normalizer(wrapper.props);
const isImage = boost.which === 'NativeImage' || wrapper.which === 'NativeImage';
const boostNorm = isImage ? normalizeImage(boost.props, reactNativeVersion.minor) : normalize(boost.props);
const wrapperNorm = isImage ? normalizeImage(wrapper.props, reactNativeVersion.minor) : normalize(wrapper.props);
const keys = divergingKeys(boostNorm, wrapperNorm);

if (boost.which === wrapper.which && keys.length === 0) return { status: 'match' };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ export function setPlatformOS(value: PlatformOS) {
// suite needing a hand-maintained list of expected divergences.
const { version } = createRequire(import.meta.url)('react-native/package.json') as { version: string };
const [major, minor, patch] = version.split('-')[0]!.split('.').map(Number);
export const reactNativeVersion = { major, minor, patch };

const Platform = {
get OS() {
return os;
},
constants: { reactNativeVersion: { major, minor, patch } },
constants: { reactNativeVersion },
select<T>(spec: Record<string, T>): T | undefined {
return os in spec ? spec[os] : spec.default;
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// Release defaults used by the real RN 0.86 wrappers in parity tests.
// Release defaults used by the supported RN wrappers in parity tests.
export const defaultTextToOverflowHidden = () => true;
export const enableNativeViewPropTransformations = () => false;
export const fixImageSrcDimensionPropagation = () => true;
export const reduceDefaultPropsInImage = () => false;
export const reduceDefaultPropsInText = () => false;
export const shouldUseLinkRoleForPressableText = () => true;
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@ export const normalize = (props: Record<string, unknown>) => {
return normalized;
};

export const normalizeImage = (props: Record<string, unknown>) => {
export const normalizeImage = (props: Record<string, unknown>, reactNativeMinor: number) => {
const srcDuplicatesSource = props.src === props.source;
const normalized = normalize(props);

// RN <=0.85 sends the same Android source array through both native aliases.
if (reactNativeMinor <= 85 && srcDuplicatesSource) delete normalized.src;

// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ vi.mock('../../../runtime/components/native-image', async () => ({

import { captureWrapper, captureWrapperHosts } from './wrapper';
import { captureBoost, boostOptimizes } from './boost';
import { reactNativeVersion } from './mocks/Platform';
import { normalize, normalizeImage } from './normalize';

const PLATFORMS = ['ios', 'android'] as const;
const REACT_NATIVE_MINOR = reactNativeVersion.minor;

// `<Text>` cases use a primitive child (string, number, or template literal) so they render to
// NativeText (not NativeVirtualText).
Expand Down Expand Up @@ -127,6 +129,8 @@ const IMAGE_CASES = [
'<Image source={{ uri: "logo.png", width: 16, height: 16 }} tintColor={null} style={{ tintColor: "red" }} />',
'<Image source={{ uri: "logo.png", width: 16, height: 16 }} crossOrigin="use-credentials" referrerPolicy="origin" />',
'<Image source={{ uri: "logo.png", width: 16, height: 16 }} alt="Logo" />',
'<Image source={{ uri: "logo.png", width: 16, height: 16 }} alt={null} aria-label="Logo" />',
'<Image source={{ uri: "logo.png", width: 16, height: 16 }} accessible={null} />',
'<Image source={{ uri: "logo.png", width: 16, height: 16 }} aria-label="Logo" accessibilityLabel="Fallback" />',
'<Image source={{ uri: "logo.png", width: 16, height: 16 }} aria-hidden={true} accessible={true} />',
'<Image source={{ uri: "logo.png", width: 16, height: 16 }} aria-busy={true} accessibilityState={{ selected: true }} />',
Expand Down Expand Up @@ -218,6 +222,14 @@ const DYNAMIC_IMAGE_PROP_ASSERTIONS = new Map<string, (props: Record<string, unk
],
]);

describe('Image parity normalization', () => {
it('ignores duplicate source aliases only on RN 0.85 and earlier', () => {
const source = [{ uri: 'logo.png' }];
expect(normalizeImage({ src: source, source }, 85)).toEqual({ source });
expect(normalizeImage({ src: source, source }, 86)).toEqual({ src: source, source });
});
});

describe('differential parity', () => {
describe.each(PLATFORMS)('Platform.OS=%s', (os) => {
it.each(TEXT_CASES)('Text: %s', async (jsx) => {
Expand All @@ -235,7 +247,9 @@ describe('differential parity', () => {
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));
expect(normalizeImage(boost.props, REACT_NATIVE_MINOR)).toEqual(
normalizeImage(wrapper.props, REACT_NATIVE_MINOR)
);
IMAGE_PROP_ASSERTIONS.get(jsx)?.(boost.props, os);
});

Expand All @@ -245,7 +259,9 @@ describe('differential parity', () => {
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));
expect(normalizeImage(boost.props, REACT_NATIVE_MINOR)).toEqual(
normalizeImage(wrapper.props, REACT_NATIVE_MINOR)
);
DYNAMIC_IMAGE_PROP_ASSERTIONS.get(jsx)?.(boost.props);
});

Expand Down
23 changes: 22 additions & 1 deletion packages/react-native-boost/src/runtime/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('processTextStyle', () => {
expect(result.verticalAlign).toBeUndefined();
});

it('handles combination of properties', () => {
it('handles combination of properties without mutating the input', () => {
const style = {
fontWeight: 700,
userSelect: 'auto',
Expand All @@ -144,6 +144,7 @@ describe('processTextStyle', () => {
expect(resultStyle.margin).toBe(10);
expect(resultStyle.userSelect).toBeUndefined();
expect(resultStyle.verticalAlign).toBeUndefined();
expect(style).toEqual({ fontWeight: 700, userSelect: 'auto', verticalAlign: 'middle', margin: 10 });
});
});

Expand Down Expand Up @@ -472,6 +473,26 @@ describe('processImageAccessibilityProps', () => {
expect(processImageAccessibilityProps({ alt: undefined, accessible: false }).accessible).toBe(false);
});

describe('Android null handling follows the installed RN version', () => {
afterEach(restorePlatformMock);

it.each([
[84, { accessibilityLabel: 'Label', accessible: true }],
[85, { accessibilityLabel: 'Label' }],
])('handles a null alt on RN 0.%i', async (minor, expected) => {
const runtime = await loadRuntime(minor);
expect(runtime.processImageAccessibilityProps({ 'alt': null, 'aria-label': 'Label' })).toEqual(expected);
});

it.each([
[84, { accessible: null }],
[85, {}],
])('handles a null accessible prop on RN 0.%i', async (minor, expected) => {
const runtime = await loadRuntime(minor);
expect(runtime.processImageAccessibilityProps({ accessible: null })).toEqual(expected);
});
});

it('uses aria-hidden to force accessible off on iOS while preserving importantForAccessibility', () => {
Platform.OS = 'ios';
expect(
Expand Down
43 changes: 23 additions & 20 deletions packages/react-native-boost/src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,30 +145,32 @@ export function processTextStyle(style: GenericStyleProp<TextStyle>): Partial<Te
props = {};
propsCache.set(style, props);

style = StyleSheet.flatten(style) as TextStyle;
const flattenedStyle = StyleSheet.flatten(style) as TextStyle;

if (!style) {
if (!flattenedStyle) {
if (defaultTextStyle) props.style = defaultTextStyle;
return props;
}

if (typeof style?.fontWeight === 'number') {
style.fontWeight = style.fontWeight.toString() as TextStyle['fontWeight'];
const processedStyle = { ...flattenedStyle };

if (typeof processedStyle.fontWeight === 'number') {
processedStyle.fontWeight = processedStyle.fontWeight.toString() as TextStyle['fontWeight'];
}

if (style?.userSelect != null) {
props.selectable = userSelectToSelectableMap[style.userSelect];
delete style.userSelect;
if (processedStyle.userSelect != null) {
props.selectable = userSelectToSelectableMap[processedStyle.userSelect];
delete processedStyle.userSelect;
}

if (style?.verticalAlign != null) {
style.textAlignVertical = verticalAlignToTextAlignVerticalMap[
style.verticalAlign
if (processedStyle.verticalAlign != null) {
processedStyle.textAlignVertical = verticalAlignToTextAlignVerticalMap[
processedStyle.verticalAlign
] as TextStyle['textAlignVertical'];
delete style.verticalAlign;
delete processedStyle.verticalAlign;
}

props.style = defaultTextStyle ? [defaultTextStyle, style] : style;
props.style = defaultTextStyle ? [defaultTextStyle, processedStyle] : processedStyle;
return props;
}

Expand Down Expand Up @@ -544,10 +546,7 @@ export function processImageAccessibilityProps(props: Record<string, any>): Reco
result.accessibilityLabelledBy = accessibilityLabelledBy;
}

// The two wrappers resolve `accessible` with DIFFERENT nullish semantics: iOS computes
// `ariaHidden !== true && (alt !== undefined ? true : accessible)`, while Android (RN >= 0.85)
// skips a nullish `alt`/`accessible` entirely (`!= null`), so an `alt={null}` forces `accessible`
// on iOS only.
// RN 0.85 changed Android from undefined checks to nullish checks.
if (Platform.OS === 'ios') {
if (ariaHidden === true) {
result.accessible = false;
Expand All @@ -556,10 +555,14 @@ export function processImageAccessibilityProps(props: Record<string, any>): Reco
} else if (accessible !== undefined) {
result.accessible = accessible;
}
} else if (alt != null) {
result.accessible = true;
} else if (accessible != null) {
result.accessible = accessible;
} else {
const minor = getReactNativeMinor();
const usesUndefinedChecks = minor !== null && minor <= 84;
if (usesUndefinedChecks ? alt !== undefined : alt != null) {
result.accessible = true;
} else if (usesUndefinedChecks ? accessible !== undefined : accessible != null) {
result.accessible = accessible;
}
}

if (ariaHidden === true && Platform.OS !== 'ios') {
Expand Down