diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89a392f..852b03c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 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 02c5fb8..ac34a10 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 @@ -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'; @@ -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' }; diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/Platform.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/Platform.ts index 99e7924..8f3b54e 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/Platform.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/Platform.ts @@ -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(spec: Record): T | undefined { return os in spec ? spec[os] : spec.default; }, 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 74316d4..49387fd 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 @@ -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; 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 2ceaa4c..03ee519 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/normalize.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/normalize.ts @@ -36,9 +36,13 @@ export const normalize = (props: Record) => { return normalized; }; -export const normalizeImage = (props: Record) => { +export const normalizeImage = (props: Record, 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. 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 57d49ab..5377824 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 @@ -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; // `` cases use a primitive child (string, number, or template literal) so they render to // NativeText (not NativeVirtualText). @@ -127,6 +129,8 @@ const IMAGE_CASES = [ '', '', 'Logo', + '{null}', + '', '', '', '', @@ -218,6 +222,14 @@ const DYNAMIC_IMAGE_PROP_ASSERTIONS = new Map { + 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) => { @@ -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); }); @@ -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); }); 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 5177915..a407d1b 100644 --- a/packages/react-native-boost/src/runtime/__tests__/index.test.ts +++ b/packages/react-native-boost/src/runtime/__tests__/index.test.ts @@ -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', @@ -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 }); }); }); @@ -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( diff --git a/packages/react-native-boost/src/runtime/index.ts b/packages/react-native-boost/src/runtime/index.ts index 5255b85..70222f5 100644 --- a/packages/react-native-boost/src/runtime/index.ts +++ b/packages/react-native-boost/src/runtime/index.ts @@ -145,30 +145,32 @@ export function processTextStyle(style: GenericStyleProp): Partial): 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; @@ -556,10 +555,14 @@ export function processImageAccessibilityProps(props: Record): 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') {