From 869a8089b567fc0ec7bcee58daf76234d2536581 Mon Sep 17 00:00:00 2001 From: mfkrause Date: Fri, 19 Jun 2026 20:02:49 +0000 Subject: [PATCH 1/7] test(parity): add differential parity test harness for Text/View Renders the real RN wrapper components (oracle) alongside Boost-transformed output, mocks the native host components to capture exact prop bags, and asserts structural equality per Platform.OS. - Add dedicated vitest project (vitest.config.parity.mts) that transforms RN Flow source via @react-native/babel-preset, redirects native/leaf modules to mocks, and dedups React onto the copy react-dom resolves. - Wire unit + parity into a single `pnpm test` via vitest `test.projects`. - Pin react/react-dom@19.2.0 (matching RN 0.83.2) plus babel presets. The 11 known divergences (accessible default + disabled<->accessibilityState reconciliation) are intentionally red; the runtime fixes are a follow-up. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HzGmSHV9mGmFEs1QZ5XmhF --- packages/react-native-boost/package.json | 4 + .../__tests__/parity/__generated__/.gitignore | 2 + .../src/plugin/__tests__/parity/boost.ts | 44 +++++++++++ .../src/plugin/__tests__/parity/capture.tsx | 44 +++++++++++ .../plugin/__tests__/parity/mocks/Platform.ts | 18 +++++ .../parity/mocks/PressabilityDebug.ts | 4 + .../parity/mocks/ReactNativeFeatureFlags.ts | 4 + .../parity/mocks/TextNativeComponent.ts | 5 ++ .../parity/mocks/ViewNativeComponent.ts | 4 + .../__tests__/parity/mocks/processColor.ts | 6 ++ .../__tests__/parity/mocks/react-native.ts | 13 ++++ .../__tests__/parity/mocks/usePressability.ts | 5 ++ .../plugin/__tests__/parity/parity.test.ts | 61 +++++++++++++++ .../src/plugin/__tests__/parity/setup.ts | 5 ++ .../__tests__/parity/vitest.config.parity.mts | 74 +++++++++++++++++++ .../src/plugin/__tests__/parity/wrapper.ts | 33 +++++++++ packages/react-native-boost/vitest.config.ts | 29 ++++++-- pnpm-lock.yaml | 15 +++- 18 files changed, 359 insertions(+), 11 deletions(-) create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/__generated__/.gitignore create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/boost.ts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/capture.tsx create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/mocks/Platform.ts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/mocks/PressabilityDebug.ts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/mocks/ReactNativeFeatureFlags.ts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/mocks/TextNativeComponent.ts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/mocks/ViewNativeComponent.ts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/mocks/processColor.ts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/mocks/react-native.ts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/mocks/usePressability.ts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/setup.ts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/vitest.config.parity.mts create mode 100644 packages/react-native-boost/src/plugin/__tests__/parity/wrapper.ts diff --git a/packages/react-native-boost/package.json b/packages/react-native-boost/package.json index 86384bb..ecd3914 100644 --- a/packages/react-native-boost/package.json +++ b/packages/react-native-boost/package.json @@ -88,7 +88,9 @@ }, "devDependencies": { "@babel/plugin-syntax-jsx": "^7.25.0", + "@babel/preset-react": "^7.25.0", "@babel/preset-typescript": "^7.25.0", + "@react-native/babel-preset": "0.83.2", "@release-it/conventional-changelog": "^10.0.0", "@rollup/plugin-alias": "^6.0.0", "@rollup/plugin-node-resolve": "^16.0.0", @@ -99,6 +101,8 @@ "@types/node": "^24", "babel-plugin-tester": "^12.0.0", "esbuild-node-externals": "^1.18.0", + "react": "19.2.0", + "react-dom": "19.2.0", "react-native": "0.83.2", "release-it": "^19.2.4", "rollup": "^4.34.8", diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/__generated__/.gitignore b/packages/react-native-boost/src/plugin/__tests__/parity/__generated__/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/__generated__/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/boost.ts b/packages/react-native-boost/src/plugin/__tests__/parity/boost.ts new file mode 100644 index 0000000..8515c07 --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/boost.ts @@ -0,0 +1,44 @@ +import { writeFileSync } from 'node:fs'; +import { transformSync } from '@babel/core'; +import * as React from 'react'; +import boostPlugin from '../../index'; // src/plugin/index.ts — the full Boost plugin +import { RUNTIME_MODULE_NAME } from '../../utils/constants'; +import { renderAndCapture } from './capture'; + +let counter = 0; + +interface BoostBailed { + optimized: false; +} + +interface BoostOptimized { + optimized: true; + which?: string; + props: Record; +} + +/** + * Transform a JSX body with the full Boost plugin and render the result with the REAL runtime + * helpers (the components are mocked to the shared capturers by the test). Returns + * `{ optimized: false }` when Boost bailed — it then defers to the wrapper, so the case is + * equivalent by construction and the test skips it. + */ +export async function captureBoost(jsxBody: string): Promise { + const source = `import { Text, View } from 'react-native';\nexport default function Case(){ return ${jsxBody}; }`; + const out = transformSync(source, { + configFile: false, + babelrc: false, + filename: 'boost-case.jsx', + presets: [['@babel/preset-react', { runtime: 'automatic' }]], + plugins: [[boostPlugin, { silent: true }]], + }); + const code = out!.code!; + // Single-element snippet: the runtime import is injected iff that element was optimized. + if (!code.includes(RUNTIME_MODULE_NAME)) return { optimized: false }; + + const file = new URL(`./__generated__/boost-${counter++}.js`, import.meta.url).pathname; + writeFileSync(file, code); + const mod = await import(/* @vite-ignore */ file); + const { which, props } = renderAndCapture(React.createElement(mod.default)); + return { optimized: true, which, props }; +} diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/capture.tsx b/packages/react-native-boost/src/plugin/__tests__/parity/capture.tsx new file mode 100644 index 0000000..6b15d35 --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/capture.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +/** + * The single shared sink both the wrapper and Boost sides funnel their native props into. Whichever + * native host capturer renders last writes here, so {@link renderAndCapture} resets it before every + * render and reads it immediately after. + */ +interface Sink { + props?: Record; + which?: string; +} + +export const sink: Sink = {}; + +/** + * Builds a prop-recording function component standing in for a native host. It records the exact + * prop bag it receives into the shared {@link sink} and renders its children so nested hosts (e.g. + * a `NativeVirtualText` inside a `NativeText`) are captured too. + * + * It must be a function component, not a host string token (e.g. `'RCTText'`): the DOM serializer + * would lowercase attribute names and stringify values, destroying the fidelity we compare on. + */ +const makeCapturer = + (which: string): React.FC> => + (props) => { + sink.props = props; + sink.which = which; + return (props.children as React.ReactNode) ?? null; + }; + +export const NativeTextCapturer = makeCapturer('NativeText'); +export const NativeVirtualTextCapturer = makeCapturer('NativeVirtualText'); +export const NativeViewCapturer = makeCapturer('NativeView'); + +/** Render an element and return the props its single native host received (children stripped). */ +export function renderAndCapture(element: React.ReactElement): { which?: string; props: Record } { + sink.props = undefined; + sink.which = undefined; + renderToStaticMarkup(element); + const captured: Record = sink.props ?? {}; + const { children: _children, ...rest } = captured; + return { which: sink.which, props: rest }; +} 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 new file mode 100644 index 0000000..bc6f081 --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/Platform.ts @@ -0,0 +1,18 @@ +// Switchable Platform mock. `Text.js` reads `Platform.OS` / `Platform.select` at render time, so the +// parity test flips the OS via `setPlatformOS` before each wrapper render. +let os: 'ios' | 'android' = 'ios'; + +export function setPlatformOS(value: 'ios' | 'android') { + os = value; +} + +const Platform = { + get OS() { + return os; + }, + select(spec: Record): T | undefined { + return os in spec ? spec[os] : spec.default; + }, +}; + +export default Platform; diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/PressabilityDebug.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/PressabilityDebug.ts new file mode 100644 index 0000000..b2d220c --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/PressabilityDebug.ts @@ -0,0 +1,4 @@ +// `Text.js` imports `PressabilityDebug` for its dev-only debug overlay. Disable it. +export function isEnabled() { + return false; +} 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 new file mode 100644 index 0000000..930653b --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/ReactNativeFeatureFlags.ts @@ -0,0 +1,4 @@ +// `Text.js` gates its implementation on this JS feature flag (native default: false), which selects +// 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; diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/TextNativeComponent.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/TextNativeComponent.ts new file mode 100644 index 0000000..32441d8 --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/TextNativeComponent.ts @@ -0,0 +1,5 @@ +import { NativeTextCapturer, NativeVirtualTextCapturer } from '../capture'; + +// `Text.js` renders `NativeText` for a root text and `NativeVirtualText` for nested text. +export const NativeText = NativeTextCapturer; +export const NativeVirtualText = NativeVirtualTextCapturer; diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/ViewNativeComponent.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/ViewNativeComponent.ts new file mode 100644 index 0000000..e34835d --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/ViewNativeComponent.ts @@ -0,0 +1,4 @@ +import { NativeViewCapturer } from '../capture'; + +// `View.js` renders `ViewNativeComponent`'s default export; point it at the shared capturer. +export default NativeViewCapturer; diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/processColor.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/processColor.ts new file mode 100644 index 0000000..ad0ed72 --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/processColor.ts @@ -0,0 +1,6 @@ +// Identity stand-in for `react-native/Libraries/StyleSheet/processColor`. Its real implementation +// does a CJS `require('../Utilities/Platform')` that escapes vite resolution and pulls raw Flow into +// Node. We don't exercise `selectionColor`, so cutting that subtree here is safe. +export default function processColor(color: unknown) { + return color; +} 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 new file mode 100644 index 0000000..509d3a5 --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/react-native.ts @@ -0,0 +1,13 @@ +import Platform from './Platform'; +import { NativeTextCapturer, NativeViewCapturer } from '../capture'; + +// Bare-specifier `react-native` surface for the Boost side. It backs the runtime index's +// `import { StyleSheet } from 'react-native'` plus the dead leftover `import { Text, View }` the +// plugin leaves in generated code. The host components resolve to the shared capturers so any path +// that bottoms out here is still captured. +export { Platform }; +export const unstable_NativeText = NativeTextCapturer; +export const unstable_NativeView = NativeViewCapturer; +export const Text = NativeTextCapturer; +export const View = NativeViewCapturer; +export const StyleSheet = { flatten: (style: T) => style }; diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/mocks/usePressability.ts b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/usePressability.ts new file mode 100644 index 0000000..fdaf09e --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/mocks/usePressability.ts @@ -0,0 +1,5 @@ +// `Text.js` calls `usePressability` to derive event handlers. We don't compare press handlers (they +// are stripped by `normalize`), so a no-op hook returning no handlers is sufficient. +export default function usePressability() { + return {}; +} 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 new file mode 100644 index 0000000..e7b0fed --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Mock the runtime's host COMPONENTS to the shared capturers, keeping the real runtime HELPERS +// (`processAccessibilityProps` / `processTextStyle`) under test. This is also what stops +// native-text.tsx / native-view.tsx from running their CJS `require('react-native')` (see §4.5 of +// the implementation plan), which would otherwise pull raw Flow source into node. +vi.mock('../../../runtime/components/native-text', async () => ({ + NativeText: (await import('./capture')).NativeTextCapturer, +})); +vi.mock('../../../runtime/components/native-view', async () => ({ + NativeView: (await import('./capture')).NativeViewCapturer, +})); + +import { captureWrapper } from './wrapper'; +import { captureBoost } from './boost'; + +const PLATFORMS = ['ios', 'android'] as const; + +// `` cases use a string child so they render to NativeText (not NativeVirtualText). +const TEXT_CASES = [ + 'hello', + 'hello', + 'hello', + 'hello', + 'hello', + 'hello', + 'hello', + 'hello', +]; + +const VIEW_CASES = [ + '', + '', + '', + '', + '', // blacklisted → Boost bails → skipped + '', // blacklisted → Boost bails → skipped + '', // blacklisted → Boost bails → skipped +]; + +// Treat `undefined`-valued keys as absent and deep-clean nested objects (also drops function values +// such as event handlers) so the comparison is a clean structural prop-bag equality. +const normalize = (props: Record) => JSON.parse(JSON.stringify(props)); + +describe('differential parity', () => { + describe.each(PLATFORMS)('Platform.OS=%s', (os) => { + it.each(TEXT_CASES)('Text: %s', async (jsx) => { + const boost = await captureBoost(jsx); + if (!boost.optimized) return; // bailed → defers to the wrapper, equivalent by construction + const wrapper = await captureWrapper(os, jsx); + expect(normalize(boost.props)).toEqual(normalize(wrapper.props)); + }); + + it.each(VIEW_CASES)('View: %s', async (jsx) => { + const boost = await captureBoost(jsx); + if (!boost.optimized) return; // bailed → defers to the wrapper, equivalent by construction + const wrapper = await captureWrapper(os, jsx); + expect(normalize(boost.props)).toEqual(normalize(wrapper.props)); + }); + }); +}); diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/setup.ts b/packages/react-native-boost/src/plugin/__tests__/parity/setup.ts new file mode 100644 index 0000000..50e0dbc --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/setup.ts @@ -0,0 +1,5 @@ +// React Native source files reference these runtime globals at module-evaluation time. They are +// normally injected by the Metro/RN runtime; define them here so RN's `Text`/`View` modules load +// under vitest's plain `node` environment. +(globalThis as Record).__DEV__ = false; +(globalThis as Record).RN$Bridgeless = true; 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 new file mode 100644 index 0000000..95eb5c9 --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/vitest.config.parity.mts @@ -0,0 +1,74 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { transformSync } from '@babel/core'; +import { defineConfig } from 'vitest/config'; + +const require = createRequire(import.meta.url); +const u = (p: string) => new URL(p, import.meta.url).pathname; + +// React Native ships its own source as Flow `.js`. We only want to transform RN's own files. +const RN_SRC = /\/node_modules\/react-native\/(Libraries|src)\/.*\.js$/; +const rnDir = dirname(require.resolve('react-native/package.json')); + +// Pin React + its JSX runtimes to the single copy `react-dom` resolves, so the wrapper's hooks and +// the renderer share one React instance (pnpm/hoisting can otherwise leave two copies side by side). +const reactRequire = createRequire(require.resolve('react-dom')); +const exactRedirects: Record = { + 'react': reactRequire.resolve('react'), + 'react/jsx-runtime': reactRequire.resolve('react/jsx-runtime'), + 'react/jsx-dev-runtime': reactRequire.resolve('react/jsx-dev-runtime'), + 'react-native-boost/runtime': u('../../../runtime/index.ts'), +}; + +// Leaf RN modules that touch native code or can't load under node. Matched by basename so both the +// `react-native/Libraries/...` import and the relative `./Foo` / `../Foo` imports are redirected. +const basenameRedirects: Array<[RegExp, string]> = [ + [/(^|[./])ViewNativeComponent$/, u('./mocks/ViewNativeComponent.ts')], + [/(^|[./])TextNativeComponent$/, u('./mocks/TextNativeComponent.ts')], + [/(^|[./])Platform$/, u('./mocks/Platform.ts')], + [/(^|[./])usePressability$/, u('./mocks/usePressability.ts')], + [/(^|[./])PressabilityDebug$/, u('./mocks/PressabilityDebug.ts')], + [/(^|[./])ReactNativeFeatureFlags$/, u('./mocks/ReactNativeFeatureFlags.ts')], + [/(^|[./])processColor$/, u('./mocks/processColor.ts')], +]; + +export default defineConfig({ + plugins: [ + { + name: 'rn-parity', + enforce: 'pre', // run before vite's built-in alias plugin + resolveId(source) { + if (exactRedirects[source]) return exactRedirects[source]; + for (const [re, target] of basenameRedirects) if (re.test(source)) return target; + // Wrapper side: pin deep RN source to the real files (RN's exports map omits the `.js`). + if (/^react-native\/(Libraries|src)\//.test(source)) { + return join(rnDir, source.slice('react-native/'.length) + '.js'); + } + return null; + }, + transform(code, id) { + if (!RN_SRC.test(id)) return null; + const out = transformSync(code, { + configFile: false, + babelrc: false, + filename: id, + presets: [[require.resolve('@react-native/babel-preset'), { disableImportExportTransform: true }]], + }); + // Keeping ESM (no CJS transform) ensures transitive imports resolve back through vite. + return out?.code ? { code: out.code, map: out.map } : null; + }, + }, + ], + // Regex-exact so it only matches the BARE `react-native` specifier (the runtime index's + // `import { StyleSheet } from 'react-native'` and the dead leftover import in generated Boost + // code). Deep `react-native/Libraries/...` paths are handled by the pre-plugin above. + resolve: { alias: [{ find: /^react-native$/, replacement: u('./mocks/react-native.ts') }] }, + test: { + name: 'parity', + globals: true, + environment: 'node', + setupFiles: [u('./setup.ts')], + include: [u('./parity.test.ts')], + server: { deps: { inline: [/react-native/] } }, // force RN source through the transform pipeline + }, +}); diff --git a/packages/react-native-boost/src/plugin/__tests__/parity/wrapper.ts b/packages/react-native-boost/src/plugin/__tests__/parity/wrapper.ts new file mode 100644 index 0000000..425d24f --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/parity/wrapper.ts @@ -0,0 +1,33 @@ +import { writeFileSync } from 'node:fs'; +import { transformSync } from '@babel/core'; +import * as React from 'react'; +import { renderAndCapture } from './capture'; +import { setPlatformOS } from './mocks/Platform'; + +let counter = 0; + +/** + * Render the REAL React Native `Text`/`View` wrapper for a JSX body on the given platform and return + * the prop bag its native host received. This is the oracle the Boost output is compared against. + * + * The snippet is wrapped in a module importing RN's deep wrapper sources, transformed with + * `@babel/preset-react`, written to `__generated__/`, and dynamically imported so its `react-native` + * deep imports and `react/jsx-runtime` import resolve through the parity vite pipeline. + */ +export async function captureWrapper(os: 'ios' | 'android', jsxBody: string) { + setPlatformOS(os); // Text.js reads Platform.select at render time + const source = + `import Text from 'react-native/Libraries/Text/Text';\n` + + `import View from 'react-native/Libraries/Components/View/View';\n` + + `export default function Case(){ return ${jsxBody}; }`; + const out = transformSync(source, { + configFile: false, + babelrc: false, + filename: 'wrapper-case.jsx', + presets: [['@babel/preset-react', { runtime: 'automatic' }]], + }); + const file = new URL(`./__generated__/wrapper-${os}-${counter++}.js`, import.meta.url).pathname; + writeFileSync(file, out!.code!); + const mod = await import(/* @vite-ignore */ file); + return renderAndCapture(React.createElement(mod.default)); +} diff --git a/packages/react-native-boost/vitest.config.ts b/packages/react-native-boost/vitest.config.ts index 9e20c43..909f0b2 100644 --- a/packages/react-native-boost/vitest.config.ts +++ b/packages/react-native-boost/vitest.config.ts @@ -1,17 +1,30 @@ import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; const runtimeMockPath = fileURLToPath(new URL('src/runtime/__tests__/mocks/react-native.ts', import.meta.url)); +const parityConfig = fileURLToPath(new URL('src/plugin/__tests__/parity/vitest.config.parity.mts', import.meta.url)); export default defineConfig({ test: { - // babel-plugin-tester requires it and describe to be set globally - globals: true, - }, - resolve: { - alias: { - 'react-native': resolve(runtimeMockPath), - }, + projects: [ + { + // Unit suite: aliases `react-native` to a lightweight mock for the whole project. + resolve: { + alias: { + 'react-native': resolve(runtimeMockPath), + }, + }, + test: { + name: 'unit', + // babel-plugin-tester requires `it` and `describe` to be set globally + globals: true, + // The differential parity suite needs the REAL react-native and runs under its own config. + exclude: [...configDefaults.exclude, '**/__tests__/parity/**'], + }, + }, + // Parity suite: dedicated config (real RN transform + redirects + react dedup). + parityConfig, + ], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acd55b4..8392776 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,16 +157,19 @@ importers: minimatch: specifier: ^10.0.1 version: 10.2.4 - react: - specifier: '*' - version: 19.2.0 devDependencies: '@babel/plugin-syntax-jsx': specifier: ^7.25.0 version: 7.28.6(@babel/core@7.29.0) + '@babel/preset-react': + specifier: ^7.25.0 + version: 7.28.5(@babel/core@7.29.0) '@babel/preset-typescript': specifier: ^7.25.0 version: 7.28.5(@babel/core@7.29.0) + '@react-native/babel-preset': + specifier: 0.83.2 + version: 0.83.2(@babel/core@7.29.0) '@release-it/conventional-changelog': specifier: ^10.0.0 version: 10.0.5(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.2.1)(release-it@19.2.4(@types/node@24.10.15)) @@ -197,6 +200,12 @@ importers: esbuild-node-externals: specifier: ^1.18.0 version: 1.20.1(esbuild@0.27.3) + react: + specifier: 19.2.0 + version: 19.2.0 + react-dom: + specifier: 19.2.0 + version: 19.2.0(react@19.2.0) react-native: specifier: 0.83.2 version: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) From 5765097dfd7ea0c286d5bbb475e1b6e9e1416262 Mon Sep 17 00:00:00 2001 From: mfkrause Date: Fri, 19 Jun 2026 20:35:28 +0000 Subject: [PATCH 2/7] fix(text): match RN Text accessibility/disabled reconciliation Optimized `` previously dropped the platform-specific `accessible` default and skipped the `disabled` <-> `accessibilityState.disabled` reconciliation that the RN `Text` wrapper performs, diverging from the native prop bag. - runtime: add `getDefaultTextAccessible()` (render-time `Platform.select`) for the common no-accessibility path; extend `processAccessibilityProps` to reconcile `disabled` with `accessibilityState.disabled`, mirroring Text.js exactly. - plugin: route `disabled` + accessibility props through the runtime helper, and inject the lightweight `accessible` default otherwise. - parity: render the Boost side under the same `Platform.OS` as the wrapper so render-time platform defaults are compared correctly. - tests/fixtures: cover both platforms and the disabled reconciliation; regenerate affected text fixtures. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HzGmSHV9mGmFEs1QZ5XmhF --- .../src/plugin/__tests__/parity/boost.ts | 8 +- .../plugin/__tests__/parity/parity.test.ts | 4 +- .../text/__tests__/fixtures/basic/output.js | 7 +- .../fixtures/complex-example/output.js | 9 ++- .../fixtures/default-props/output.js | 11 ++- .../expo-router-link-alias-as-child/output.js | 9 ++- .../output.js | 7 +- .../output.js | 7 +- .../expo-router-link-as-child/output.js | 9 ++- .../output.js | 7 +- .../flattens-styles-at-runtime/output.js | 6 +- .../fixtures/force-comment/output.js | 8 +- .../fixtures/ignore-comment/output.js | 7 +- .../fixtures/mixed-children-types/output.js | 7 +- .../output.js | 7 +- .../non-react-native-import/output.js | 7 +- .../fixtures/number-of-lines/output.js | 11 ++- .../__tests__/fixtures/pressables/output.js | 7 +- .../resolvable-spread-props/output.js | 7 +- .../output.js | 12 ++- .../__tests__/fixtures/text-content/output.js | 9 ++- .../fixtures/two-components/output.js | 9 ++- .../variable-child-no-string/output.js | 9 ++- .../src/plugin/optimizers/text/index.ts | 59 +++++++++++--- .../src/runtime/__tests__/index.test.ts | 79 ++++++++++++++++--- .../react-native-boost/src/runtime/index.ts | 43 ++++++++-- .../src/runtime/index.web.ts | 5 ++ 27 files changed, 287 insertions(+), 83 deletions(-) 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 8515c07..9fb8ccc 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/boost.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/boost.ts @@ -4,6 +4,7 @@ import * as React from 'react'; import boostPlugin from '../../index'; // src/plugin/index.ts — the full Boost plugin import { RUNTIME_MODULE_NAME } from '../../utils/constants'; import { renderAndCapture } from './capture'; +import { setPlatformOS } from './mocks/Platform'; let counter = 0; @@ -22,8 +23,13 @@ interface BoostOptimized { * helpers (the components are mocked to the shared capturers by the test). Returns * `{ optimized: false }` when Boost bailed — it then defers to the wrapper, so the case is * equivalent by construction and the test skips it. + * + * Rendered under the given platform (like {@link captureWrapper}) because the runtime helpers read + * `Platform.OS`/`Platform.select` at render time to resolve platform-specific defaults such as + * `accessible`. */ -export async function captureBoost(jsxBody: string): Promise { +export async function captureBoost(os: 'ios' | 'android', jsxBody: string): Promise { + setPlatformOS(os); const source = `import { Text, View } from 'react-native';\nexport default function Case(){ return ${jsxBody}; }`; const out = transformSync(source, { configFile: false, 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 e7b0fed..5acfcca 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 @@ -45,14 +45,14 @@ const normalize = (props: Record) => JSON.parse(JSON.stringify( describe('differential parity', () => { describe.each(PLATFORMS)('Platform.OS=%s', (os) => { it.each(TEXT_CASES)('Text: %s', async (jsx) => { - const boost = await captureBoost(jsx); + const boost = await captureBoost(os, jsx); if (!boost.optimized) return; // bailed → defers to the wrapper, equivalent by construction const wrapper = await captureWrapper(os, jsx); expect(normalize(boost.props)).toEqual(normalize(wrapper.props)); }); it.each(VIEW_CASES)('View: %s', async (jsx) => { - const boost = await captureBoost(jsx); + const boost = await captureBoost(os, jsx); if (!boost.optimized) return; // bailed → defers to the wrapper, equivalent by construction const wrapper = await captureWrapper(os, jsx); expect(normalize(boost.props)).toEqual(normalize(wrapper.props)); diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/basic/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/basic/output.js index 702ae14..48f15c1 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/basic/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/basic/output.js @@ -1,3 +1,6 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; -<_NativeText allowFontScaling={true} ellipsizeMode={'tail'} />; +<_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()} />; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/complex-example/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/complex-example/output.js index 08f48f4..a6fcd2e 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/complex-example/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/complex-example/output.js @@ -1,4 +1,7 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; export default function TextBenchmark(props) { const optimizedViews = Array.from( @@ -6,7 +9,7 @@ export default function TextBenchmark(props) { length: props.count, }, (_, index) => ( - <_NativeText key={index} allowFontScaling={true} ellipsizeMode={'tail'}> + <_NativeText key={index} allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> Nice text ) @@ -22,7 +25,7 @@ export default function TextBenchmark(props) { ); if (props.status === 'pending') return ( - <_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> + <_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> Pending... ); diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/default-props/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/default-props/output.js index c339d81..c65f5ba 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/default-props/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/default-props/output.js @@ -1,10 +1,13 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; const someFunction = () => ({}); -<_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> +<_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> Hello ; -<_NativeText allowFontScaling={false} ellipsizeMode={'tail'}> +<_NativeText allowFontScaling={false} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> No Scaling ; const unknownProps = someFunction(); @@ -13,6 +16,6 @@ const partialProps = { color: 'blue', ellipsizeMode: 'clip', }; -<_NativeText {...partialProps} allowFontScaling={true}> +<_NativeText {...partialProps} allowFontScaling={true} accessible={_getDefaultTextAccessible()}> Partial props ; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-alias-as-child/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-alias-as-child/output.js index 91a2993..293ba75 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-alias-as-child/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-alias-as-child/output.js @@ -1,15 +1,18 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; import { Link as RouterLink } from 'expo-router'; <> - <_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> + <_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> This should be optimized This should NOT be optimized due to aliased Link asChild - <_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> + <_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> This should be optimized (aliased Link without asChild) diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-as-child-false-static/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-as-child-false-static/output.js index e715810..a43462d 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-as-child-false-static/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-as-child-false-static/output.js @@ -1,9 +1,12 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; import { Link } from 'expo-router'; <> - <_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> + <_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> This should be optimized because asChild is false diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-as-child-nested-view/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-as-child-nested-view/output.js index 22b8ba4..db2d7a9 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-as-child-nested-view/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-as-child-nested-view/output.js @@ -1,9 +1,12 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text, View } from 'react-native'; import { Link } from 'expo-router'; - <_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> + <_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> This should be optimized because View is the direct child diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-as-child/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-as-child/output.js index e99c818..e729899 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-as-child/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-as-child/output.js @@ -1,15 +1,18 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; import { Link } from 'expo-router'; <> - <_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> + <_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> This should be optimized This should NOT be optimized due to Link asChild - <_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> + <_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> This should be optimized (Link without asChild) diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-namespace-as-child/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-namespace-as-child/output.js index 62e37e0..e0779b6 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-namespace-as-child/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-namespace-as-child/output.js @@ -1,4 +1,7 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; import * as ExpoRouter from 'expo-router'; <> @@ -6,7 +9,7 @@ import * as ExpoRouter from 'expo-router'; This should NOT be optimized for namespace Link asChild - <_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> + <_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> This should be optimized without asChild diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/flattens-styles-at-runtime/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/flattens-styles-at-runtime/output.js index 7ac2349..1e0cb3e 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/flattens-styles-at-runtime/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/flattens-styles-at-runtime/output.js @@ -1,4 +1,8 @@ -import { processTextStyle as _processTextStyle, NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + processTextStyle as _processTextStyle, + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; <_NativeText {..._processTextStyle({ diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/force-comment/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/force-comment/output.js index ad5b876..db3003a 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/force-comment/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/force-comment/output.js @@ -1,4 +1,7 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; <> + ellipsizeMode={'tail'} + accessible={_getDefaultTextAccessible()}> Force optimized despite blacklisted prop ; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/ignore-comment/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/ignore-comment/output.js index 55d494b..82f3e4d 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/ignore-comment/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/ignore-comment/output.js @@ -1,7 +1,10 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; <> - <_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> + <_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> Optimize this {/* @boost-ignore */} diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/mixed-children-types/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/mixed-children-types/output.js index 4fddb61..9687b86 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/mixed-children-types/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/mixed-children-types/output.js @@ -1,7 +1,10 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; const name = 'John'; -<_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> +<_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> Hello {name}! ; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/nested-in-object-with-ignore-comment/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/nested-in-object-with-ignore-comment/output.js index 4aa0f47..ef30185 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/nested-in-object-with-ignore-comment/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/nested-in-object-with-ignore-comment/output.js @@ -1,11 +1,14 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; const benchmarks = [ { title: 'Text', count: 10_000, optimizedComponent: ( - <_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> + <_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> Nice text ), diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/non-react-native-import/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/non-react-native-import/output.js index 020e5e5..e859a4e 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/non-react-native-import/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/non-react-native-import/output.js @@ -1,7 +1,10 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'some-other-package'; import { Text as RNText } from 'react-native'; Hello, world!; -<_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> +<_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> This is from React Native ; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/number-of-lines/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/number-of-lines/output.js index 3f66782..81fde47 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/number-of-lines/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/number-of-lines/output.js @@ -1,11 +1,14 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; -<_NativeText numberOfLines={10} allowFontScaling={true} ellipsizeMode={'tail'}> +<_NativeText numberOfLines={10} allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> 10 lines ; -<_NativeText numberOfLines={0} allowFontScaling={true} ellipsizeMode={'tail'}> +<_NativeText numberOfLines={0} allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> -10 lines ; -<_NativeText numberOfLines={0} allowFontScaling={true} ellipsizeMode={'tail'}> +<_NativeText numberOfLines={0} allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> 0 lines ; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/pressables/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/pressables/output.js index ab892cc..9b41544 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/pressables/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/pressables/output.js @@ -1,6 +1,9 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; -<_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> +<_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> Hello, world! ; ; +<_NativeText {...props} allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()} />; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/text-content-as-explicit-child-prop/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/text-content-as-explicit-child-prop/output.js index 0b62d8d..ed145eb 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/text-content-as-explicit-child-prop/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/text-content-as-explicit-child-prop/output.js @@ -1,3 +1,11 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; -<_NativeText children="Hello, world!" allowFontScaling={true} ellipsizeMode={'tail'} />; +<_NativeText + children="Hello, world!" + allowFontScaling={true} + ellipsizeMode={'tail'} + accessible={_getDefaultTextAccessible()} +/>; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/text-content/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/text-content/output.js index 415a786..b9d9b82 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/text-content/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/text-content/output.js @@ -1,9 +1,12 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; -<_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> +<_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> Hello, world! ; const text = 'Hello again, world!'; -<_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> +<_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> {text} ; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/two-components/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/two-components/output.js index af006d6..4687df1 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/two-components/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/two-components/output.js @@ -1,4 +1,7 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; -<_NativeText allowFontScaling={true} ellipsizeMode={'tail'} />; -<_NativeText allowFontScaling={true} ellipsizeMode={'tail'}>; +<_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()} />; +<_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}>; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/variable-child-no-string/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/variable-child-no-string/output.js index 1ebf04a..c697ff0 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/variable-child-no-string/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/variable-child-no-string/output.js @@ -1,10 +1,13 @@ -import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; import { Text } from 'react-native'; -<_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> +<_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> Hello, world! ; const test = ( - <_NativeText allowFontScaling={true} ellipsizeMode={'tail'}> + <_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> Test ); diff --git a/packages/react-native-boost/src/plugin/optimizers/text/index.ts b/packages/react-native-boost/src/plugin/optimizers/text/index.ts index 75333e8..27728e6 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/text/index.ts @@ -42,6 +42,13 @@ export const textBlacklistedProperties = new Set([ 'selectionColor', // TODO: we can use react-native's internal `processColor` to process this at runtime ]); +/** + * Props handed off to `processAccessibilityProps` at runtime: the accessibility props plus `disabled`, + * which `Text` reconciles against `accessibilityState.disabled`. They are collected into a single + * helper call and stripped from the element so they are not also emitted verbatim. + */ +const NORMALIZED_PROPERTIES = new Set([...ACCESSIBILITY_PROPERTIES, 'disabled']); + export const textOptimizer: Optimizer = (path, logger) => { if (!isValidJSXComponent(path, 'Text')) return; if (!isReactNativeImport(path, 'Text')) return; @@ -175,20 +182,31 @@ function processProps(path: NodePath, file: HubFile) { const currentAttributes = [...path.node.attributes]; const { styleExpr, styleAttribute } = extractStyleAttribute(currentAttributes); - const hasA11y = hasAccessibilityProperty(path, currentAttributes); + + // `Text` always resolves a platform-specific `accessible` default and reconciles `disabled` with + // `accessibilityState.disabled`. When any accessibility prop or `disabled` is present we hand the + // element off to `processAccessibilityProps` (which also performs the aria/label merges); otherwise + // only the `accessible` default is needed, so we inject it directly to keep the common path cheap. + const shouldNormalize = + hasAccessibilityProperty(path, currentAttributes) || + currentAttributes.some( + (attribute) => t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name, { name: 'disabled' }) + ); // ============================================ - // 1. Prepare spread attributes (style / a11y) + // 1. Prepare spread attributes (a11y / style) // ============================================ const spreadAttributes: t.JSXSpreadAttribute[] = []; - // --- Accessibility --- - if (hasA11y) { - const accessibilityAttributes = currentAttributes.filter((attribute) => { - if (!t.isJSXAttribute(attribute)) return false; - return t.isJSXIdentifier(attribute.name) && ACCESSIBILITY_PROPERTIES.has(attribute.name.name as string); - }); + // --- Accessibility & `disabled` --- + if (shouldNormalize) { + const normalizedAttributes = currentAttributes.filter( + (attribute): attribute is t.JSXAttribute => + t.isJSXAttribute(attribute) && + t.isJSXIdentifier(attribute.name) && + NORMALIZED_PROPERTIES.has(attribute.name.name) + ); const normalizeIdentifier = addFileImportHint({ file, @@ -198,7 +216,7 @@ function processProps(path: NodePath, file: HubFile) { moduleName: RUNTIME_MODULE_NAME, }); - const accessibilityObject = buildPropertiesFromAttributes(accessibilityAttributes); + const accessibilityObject = buildPropertiesFromAttributes(normalizedAttributes); const accessibilityExpr = t.callExpression(t.identifier(normalizeIdentifier.name), [accessibilityObject]); spreadAttributes.push(t.jsxSpreadAttribute(accessibilityExpr)); } @@ -236,12 +254,12 @@ function processProps(path: NodePath, file: HubFile) { // Skip the style attribute (we have replaced it with a spread) if (styleAttribute && attribute === styleAttribute) continue; - // Skip accessibility attributes if we processed them + // Skip the props we routed through `processAccessibilityProps` if ( - hasA11y && + shouldNormalize && t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name) && - ACCESSIBILITY_PROPERTIES.has(attribute.name.name as string) + NORMALIZED_PROPERTIES.has(attribute.name.name) ) { continue; } @@ -252,4 +270,21 @@ function processProps(path: NodePath, file: HubFile) { path.node.attributes = [...spreadAttributes, selectableAttribute, ...remainingAttributes].filter( (attribute): attribute is t.JSXAttribute | t.JSXSpreadAttribute => attribute !== undefined ); + + // ============================================ + // 3. `accessible` default for the common path + // ============================================ + // With no accessibility/`disabled` prop the helper is skipped, but `Text` still applies a + // platform-specific `accessible` default. Inject a call to the lightweight runtime resolver so it is + // not silently dropped, while keeping the common path off the full `processAccessibilityProps`. + if (!shouldNormalize) { + const accessibleIdentifier = addFileImportHint({ + file, + nameHint: 'getDefaultTextAccessible', + path, + importName: 'getDefaultTextAccessible', + moduleName: RUNTIME_MODULE_NAME, + }); + addDefaultProperty(path, 'accessible', t.callExpression(t.identifier(accessibleIdentifier.name), [])); + } } 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 e86922c..5912c60 100644 --- a/packages/react-native-boost/src/runtime/__tests__/index.test.ts +++ b/packages/react-native-boost/src/runtime/__tests__/index.test.ts @@ -1,11 +1,12 @@ -import { vi, describe, it, expect } from 'vitest'; +import { vi, describe, it, expect, afterEach } from 'vitest'; import { processTextStyle, processAccessibilityProps, + getDefaultTextAccessible, userSelectToSelectableMap, verticalAlignToTextAlignVerticalMap, } from '..'; -import { TextStyle } from 'react-native'; +import { Platform, TextStyle } from 'react-native'; vi.mock('../components/native-text', () => ({ NativeText: () => 'MockedNativeText', @@ -15,16 +16,29 @@ vi.mock('../components/native-view', () => ({ NativeView: () => 'MockedNativeView', })); -vi.mock('react-native', () => ({ - View: () => 'View', - Text: () => 'Text', - Platform: { - OS: 'ios', - }, - StyleSheet: { - flatten: (style: any) => style, - }, -})); +// 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 Platform = { + OS: 'ios' as 'ios' | 'android', + select(spec: Record): T | undefined { + return Platform.OS in spec ? spec[Platform.OS] : spec.default; + }, + }; + return { + View: () => 'View', + Text: () => 'Text', + Platform, + StyleSheet: { + flatten: (style: any) => style, + }, + }; +}); + +afterEach(() => { + Platform.OS = 'ios'; +}); describe('processTextStyle', () => { it('returns empty object for falsy style', () => { @@ -85,6 +99,18 @@ describe('processTextStyle', () => { }); }); +describe('getDefaultTextAccessible', () => { + it('returns true on iOS', () => { + Platform.OS = 'ios'; + expect(getDefaultTextAccessible()).toBe(true); + }); + + it('returns false on Android', () => { + Platform.OS = 'android'; + expect(getDefaultTextAccessible()).toBe(false); + }); +}); + describe('processAccessibilityProps', () => { it('sets default accessible to true and has no accessibilityLabel if not provided', () => { const props = {}; @@ -94,6 +120,11 @@ describe('processAccessibilityProps', () => { expect(normalized.accessibilityState).toBeUndefined(); }); + it('defaults accessible to false on Android', () => { + Platform.OS = 'android'; + expect(processAccessibilityProps({}).accessible).toBe(false); + }); + it('merges accessibility labels using aria-label over accessibilityLabel', () => { const props = { 'accessibilityLabel': 'Label one', @@ -166,4 +197,28 @@ describe('processAccessibilityProps', () => { const normalized = processAccessibilityProps(props); expect(normalized.accessible).toBe(false); }); + + it('derives disabled from accessibilityState.disabled when the disabled prop is absent', () => { + const normalized = processAccessibilityProps({ accessibilityState: { disabled: true } }); + expect(normalized.disabled).toBe(true); + expect(normalized.accessibilityState).toEqual({ disabled: true }); + }); + + it('mirrors a standalone disabled prop into accessibilityState', () => { + const normalized = processAccessibilityProps({ disabled: true }); + expect(normalized.disabled).toBe(true); + expect(normalized.accessibilityState).toEqual({ disabled: true }); + }); + + it('lets an explicit disabled prop win over accessibilityState.disabled', () => { + const normalized = processAccessibilityProps({ disabled: true, accessibilityState: { disabled: false } }); + expect(normalized.disabled).toBe(true); + expect(normalized.accessibilityState).toEqual({ disabled: true }); + }); + + it('does not synthesize accessibilityState when disabled is false and state is absent', () => { + const normalized = processAccessibilityProps({ disabled: false }); + expect(normalized.disabled).toBe(false); + expect(normalized.accessibilityState).toBeUndefined(); + }); }); diff --git a/packages/react-native-boost/src/runtime/index.ts b/packages/react-native-boost/src/runtime/index.ts index 296e767..6d2e46b 100644 --- a/packages/react-native-boost/src/runtime/index.ts +++ b/packages/react-native-boost/src/runtime/index.ts @@ -1,4 +1,4 @@ -import { TextProps, TextStyle, StyleSheet } from 'react-native'; +import { TextProps, TextStyle, StyleSheet, Platform } from 'react-native'; import { GenericStyleProp } from './types'; import { userSelectToSelectableMap, verticalAlignToTextAlignVerticalMap } from './utils/constants'; @@ -49,14 +49,27 @@ export function processTextStyle(style: GenericStyleProp): Partial` path (no accessibility props), where it is the only + * normalization `Text` would otherwise apply. Evaluated per render — like `Text`'s own + * `Platform.select` — rather than hoisted to a constant, so it always reflects the current platform. + */ +export const getDefaultTextAccessible = (): boolean | undefined => Platform.select({ ios: true, android: false }); + +/** + * Normalizes accessibility and ARIA props for runtime native components, mirroring the reconciliation + * `Text` performs before handing off to its native host. * * @param props - Accessibility and ARIA props. * @returns Props with normalized accessibility fields. * @remarks * - Merges `aria-label` with `accessibilityLabel` * - Merges ARIA state fields into `accessibilityState` - * - Defaults `accessible` to `true` when omitted + * - Reconciles `disabled` with `accessibilityState.disabled` (the explicit `disabled` prop wins) + * - Resolves the platform-specific `accessible` default (see {@link getDefaultTextAccessible}) */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function processAccessibilityProps(props: Record): Record { @@ -70,6 +83,7 @@ export function processAccessibilityProps(props: Record): Record): Record) => ({ style }) as Partial; +// On Web there is no platform-specific `accessible` default to apply; react-native-web's `Text` +// derives accessibility from the rendered DOM. Returning `undefined` makes the injected +// `accessible={getDefaultTextAccessible()}` a no-op. +export const getDefaultTextAccessible = (): boolean | undefined => undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function processAccessibilityProps(props: Record): Record { return props; From 04a67a862255d6ab0d5acc6ed0dfc33bd96ce7a7 Mon Sep 17 00:00:00 2001 From: mfkrause Date: Fri, 19 Jun 2026 20:43:56 +0000 Subject: [PATCH 3/7] refactor(text): dedupe normalized-property predicate, tidy imports Apply code-review feedback on the accessibility/disabled change: - extract the duplicated `NORMALIZED_PROPERTIES` filter into a single `isNormalizedProperty` type guard, used by both the routing and the attribute-skip pass - consolidate the split `react-native-boost` plugin-utils imports No behavior change; all parity/unit fixtures unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HzGmSHV9mGmFEs1QZ5XmhF --- .../src/plugin/optimizers/text/index.ts | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/packages/react-native-boost/src/plugin/optimizers/text/index.ts b/packages/react-native-boost/src/plugin/optimizers/text/index.ts index 27728e6..4763048 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/text/index.ts @@ -15,10 +15,10 @@ import { replaceWithNativeComponent, isStringNode, hasExpoRouterLinkParentWithAsChild, + extractStyleAttribute, + extractSelectableAndUpdateStyle, } from '../../utils/common'; -import { RUNTIME_MODULE_NAME } from '../../utils/constants'; -import { ACCESSIBILITY_PROPERTIES } from '../../utils/constants'; -import { extractStyleAttribute, extractSelectableAndUpdateStyle } from '../../utils/common'; +import { ACCESSIBILITY_PROPERTIES, RUNTIME_MODULE_NAME } from '../../utils/constants'; export const textBlacklistedProperties = new Set([ // The `Text` wrapper translates `aria-hidden` into `accessibilityElementsHidden` / @@ -49,6 +49,12 @@ export const textBlacklistedProperties = new Set([ */ const NORMALIZED_PROPERTIES = new Set([...ACCESSIBILITY_PROPERTIES, 'disabled']); +/** + * Type guard for a direct JSX attribute whose name is in {@link NORMALIZED_PROPERTIES}. + */ +const isNormalizedProperty = (attribute: t.JSXAttribute | t.JSXSpreadAttribute): attribute is t.JSXAttribute => + t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name) && NORMALIZED_PROPERTIES.has(attribute.name.name); + export const textOptimizer: Optimizer = (path, logger) => { if (!isValidJSXComponent(path, 'Text')) return; if (!isReactNativeImport(path, 'Text')) return; @@ -201,12 +207,7 @@ function processProps(path: NodePath, file: HubFile) { // --- Accessibility & `disabled` --- if (shouldNormalize) { - const normalizedAttributes = currentAttributes.filter( - (attribute): attribute is t.JSXAttribute => - t.isJSXAttribute(attribute) && - t.isJSXIdentifier(attribute.name) && - NORMALIZED_PROPERTIES.has(attribute.name.name) - ); + const normalizedAttributes = currentAttributes.filter((attribute) => isNormalizedProperty(attribute)); const normalizeIdentifier = addFileImportHint({ file, @@ -255,14 +256,7 @@ function processProps(path: NodePath, file: HubFile) { if (styleAttribute && attribute === styleAttribute) continue; // Skip the props we routed through `processAccessibilityProps` - if ( - shouldNormalize && - t.isJSXAttribute(attribute) && - t.isJSXIdentifier(attribute.name) && - NORMALIZED_PROPERTIES.has(attribute.name.name) - ) { - continue; - } + if (shouldNormalize && isNormalizedProperty(attribute)) continue; remainingAttributes.push(attribute); } From 04b66ae9f3d9cfb04b27a04c0f348e7713115ecd Mon Sep 17 00:00:00 2001 From: mfkrause Date: Fri, 19 Jun 2026 21:19:02 +0000 Subject: [PATCH 4/7] perf(text): resolve the `accessible` default at build time The common optimized `` path applied `Text`'s platform-specific `accessible` default via a runtime `getDefaultTextAccessible()` call. Metro (and Expo) bundle per platform and report the target on the Babel caller, so the value is known at compile time. Read it via `api.caller` and inline the literal (`true` on iOS, `false` on Android, omitted on web), dropping the import and call from the hot path. The runtime resolver is kept as a fallback for bundlers that don't expose `caller.platform`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HzGmSHV9mGmFEs1QZ5XmhF --- .../build-time-accessible-default.test.ts | 39 +++++++++++++++++++ .../src/plugin/__tests__/parity/boost.ts | 9 +++-- .../react-native-boost/src/plugin/index.ts | 6 ++- .../src/plugin/optimizers/text/index.ts | 32 ++++++++------- .../src/plugin/types/index.ts | 8 +++- .../react-native-boost/src/runtime/index.ts | 5 ++- 6 files changed, 78 insertions(+), 21 deletions(-) create mode 100644 packages/react-native-boost/src/plugin/__tests__/build-time-accessible-default.test.ts diff --git a/packages/react-native-boost/src/plugin/__tests__/build-time-accessible-default.test.ts b/packages/react-native-boost/src/plugin/__tests__/build-time-accessible-default.test.ts new file mode 100644 index 0000000..84124ab --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/build-time-accessible-default.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { transformSync, type TransformCaller } from '@babel/core'; +import boostPlugin from '../index'; + +// Compile a bare `` (common path: no accessibility props) with the given Babel caller platform, +// mirroring how Metro invokes the plugin per platform bundle. +const compile = (platform?: string): string => + transformSync(`import { Text } from 'react-native';\nexport default () => hi;`, { + configFile: false, + babelrc: false, + filename: 'case.jsx', + caller: (platform ? { name: 'metro', platform } : { name: 'test' }) as TransformCaller, + plugins: ['@babel/plugin-syntax-jsx', [boostPlugin, { silent: true }]], + })!.code!; + +describe('build-time `accessible` default', () => { + it('inlines accessible={true} for iOS without importing the runtime resolver', () => { + const code = compile('ios'); + expect(code).toContain('accessible={true}'); + expect(code).not.toContain('getDefaultTextAccessible'); + }); + + it('inlines accessible={false} for Android without importing the runtime resolver', () => { + const code = compile('android'); + expect(code).toContain('accessible={false}'); + expect(code).not.toContain('getDefaultTextAccessible'); + }); + + it('omits the accessible default entirely on web (default is undefined there)', () => { + const code = compile('web'); + expect(code).not.toContain('accessible'); + expect(code).not.toContain('getDefaultTextAccessible'); + }); + + it('falls back to the runtime resolver when the platform is unknown', () => { + const code = compile(); + expect(code).toContain('accessible={_getDefaultTextAccessible()}'); + }); +}); 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 9fb8ccc..4ab9e25 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/boost.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/boost.ts @@ -1,5 +1,5 @@ import { writeFileSync } from 'node:fs'; -import { transformSync } from '@babel/core'; +import { transformSync, type TransformCaller } from '@babel/core'; import * as React from 'react'; import boostPlugin from '../../index'; // src/plugin/index.ts — the full Boost plugin import { RUNTIME_MODULE_NAME } from '../../utils/constants'; @@ -24,9 +24,9 @@ interface BoostOptimized { * `{ optimized: false }` when Boost bailed — it then defers to the wrapper, so the case is * equivalent by construction and the test skips it. * - * Rendered under the given platform (like {@link captureWrapper}) because the runtime helpers read - * `Platform.OS`/`Platform.select` at render time to resolve platform-specific defaults such as - * `accessible`. + * Compiled and rendered under the given platform (like {@link captureWrapper}): the caller `platform` + * mirrors how Metro builds per platform, so the plugin inlines build-time defaults (e.g. `accessible`) + * exactly as it would in production, and the runtime helpers read the matching `Platform.OS` at render. */ export async function captureBoost(os: 'ios' | 'android', jsxBody: string): Promise { setPlatformOS(os); @@ -35,6 +35,7 @@ export async function captureBoost(os: 'ios' | 'android', jsxBody: string): Prom configFile: false, babelrc: false, filename: 'boost-case.jsx', + caller: { name: 'metro', platform: os } as TransformCaller, presets: [['@babel/preset-react', { runtime: 'automatic' }]], plugins: [[boostPlugin, { silent: true }]], }); diff --git a/packages/react-native-boost/src/plugin/index.ts b/packages/react-native-boost/src/plugin/index.ts index 13b8c37..eeb4e62 100644 --- a/packages/react-native-boost/src/plugin/index.ts +++ b/packages/react-native-boost/src/plugin/index.ts @@ -15,6 +15,10 @@ type PluginState = { export default declare((api) => { api.assertVersion(7); + // 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); + return { name: 'react-native-boost', visitor: { @@ -24,7 +28,7 @@ export default declare((api) => { const logger = getOrCreateLogger(pluginState, options); if (isIgnoredFile(path, options.ignores ?? [])) return; - if (options.optimizations?.text !== false) textOptimizer(path, logger); + if (options.optimizations?.text !== false) textOptimizer(path, logger, options, platform); if (options.optimizations?.view !== false) viewOptimizer(path, logger, options); }, }, diff --git a/packages/react-native-boost/src/plugin/optimizers/text/index.ts b/packages/react-native-boost/src/plugin/optimizers/text/index.ts index 4763048..5aa19d1 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/text/index.ts @@ -55,7 +55,7 @@ const NORMALIZED_PROPERTIES = new Set([...ACCESSIBILITY_PROPERTIES, 'disabled']) const isNormalizedProperty = (attribute: t.JSXAttribute | t.JSXSpreadAttribute): attribute is t.JSXAttribute => t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name) && NORMALIZED_PROPERTIES.has(attribute.name.name); -export const textOptimizer: Optimizer = (path, logger) => { +export const textOptimizer: Optimizer = (path, logger, _options, platform) => { if (!isValidJSXComponent(path, 'Text')) return; if (!isReactNativeImport(path, 'Text')) return; @@ -114,7 +114,7 @@ export const textOptimizer: Optimizer = (path, logger) => { fixNegativeNumberOfLines({ path, logger }); addDefaultProperty(path, 'allowFontScaling', t.booleanLiteral(true)); addDefaultProperty(path, 'ellipsizeMode', t.stringLiteral('tail')); - processProps(path, file); + processProps(path, file, platform); // Replace the Text component with NativeText replaceWithNativeComponent(path, parent, file, 'NativeText'); @@ -183,7 +183,7 @@ function fixNegativeNumberOfLines({ path, logger }: { path: NodePath, file: HubFile) { +function processProps(path: NodePath, file: HubFile, platform?: string) { // Grab the up-to-date list of attributes const currentAttributes = [...path.node.attributes]; @@ -269,16 +269,22 @@ function processProps(path: NodePath, file: HubFile) { // 3. `accessible` default for the common path // ============================================ // With no accessibility/`disabled` prop the helper is skipped, but `Text` still applies a - // platform-specific `accessible` default. Inject a call to the lightweight runtime resolver so it is - // not silently dropped, while keeping the common path off the full `processAccessibilityProps`. + // platform-specific `accessible` default (`true` on iOS, `false` on Android, omitted on web). Metro + // bundles per platform and reports the target on the Babel caller, so the value is known at build + // time and we inline the literal directly. Only when the platform is unknown (non-Metro bundlers, + // fixture tests) do we defer to the lightweight runtime resolver, so the default is never dropped. if (!shouldNormalize) { - const accessibleIdentifier = addFileImportHint({ - file, - nameHint: 'getDefaultTextAccessible', - path, - importName: 'getDefaultTextAccessible', - moduleName: RUNTIME_MODULE_NAME, - }); - addDefaultProperty(path, 'accessible', t.callExpression(t.identifier(accessibleIdentifier.name), [])); + if (platform === 'ios' || platform === 'android') { + addDefaultProperty(path, 'accessible', t.booleanLiteral(platform === 'ios')); + } else if (platform !== 'web') { + const accessibleIdentifier = addFileImportHint({ + file, + nameHint: 'getDefaultTextAccessible', + path, + importName: 'getDefaultTextAccessible', + moduleName: RUNTIME_MODULE_NAME, + }); + addDefaultProperty(path, 'accessible', t.callExpression(t.identifier(accessibleIdentifier.name), [])); + } } } diff --git a/packages/react-native-boost/src/plugin/types/index.ts b/packages/react-native-boost/src/plugin/types/index.ts index ddb7166..feb4c17 100644 --- a/packages/react-native-boost/src/plugin/types/index.ts +++ b/packages/react-native-boost/src/plugin/types/index.ts @@ -78,7 +78,13 @@ export interface PluginLogger { warning: (payload: WarningLogPayload) => void; } -export type Optimizer = (path: NodePath, logger: PluginLogger, options?: PluginOptions) => void; +export type Optimizer = ( + path: NodePath, + 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 +) => void; export type HubFile = t.File & { opts: { diff --git a/packages/react-native-boost/src/runtime/index.ts b/packages/react-native-boost/src/runtime/index.ts index 6d2e46b..bfff4e1 100644 --- a/packages/react-native-boost/src/runtime/index.ts +++ b/packages/react-native-boost/src/runtime/index.ts @@ -53,8 +53,9 @@ export function processTextStyle(style: GenericStyleProp): Partial` path (no accessibility props), where it is the only - * normalization `Text` would otherwise apply. Evaluated per render — like `Text`'s own + * Runtime fallback for the common optimized `` path (no accessibility props) when the target + * platform is unknown at build time. When it is known (Metro reports it on the Babel caller), the + * plugin inlines the literal instead and this is not emitted. Evaluated per render — like `Text`'s own * `Platform.select` — rather than hoisted to a constant, so it always reflects the current platform. */ export const getDefaultTextAccessible = (): boolean | undefined => Platform.select({ ios: true, android: false }); From 30e6e7442773a965e38dc08f693891f4b4001edb Mon Sep 17 00:00:00 2001 From: mfkrause Date: Fri, 19 Jun 2026 21:28:21 +0000 Subject: [PATCH 5/7] fix(text): don't drop the `accessible` default on styled Text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the common (no-a11y) path the `accessible` default was injected with `addDefaultProperty`, which gives up as soon as it encounters an unresolvable spread. Once `style` is present the rebuilt attributes contain a `{...processTextStyle(...)}` call spread, so `addDefaultProperty` bailed and the default was silently dropped — leaving styled `` without the platform `accessible` value RN applies (and a dangling `getDefaultTextAccessible` import). Build the attribute explicitly and append it instead; the cheap path guarantees no `accessible` is already set, so this is safe. Adds styled parity cases (which previously had no coverage) and updates the flattens-styles fixture that was capturing the buggy output. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HzGmSHV9mGmFEs1QZ5XmhF --- .../plugin/__tests__/parity/parity.test.ts | 2 + .../flattens-styles-at-runtime/output.js | 2 + .../src/plugin/optimizers/text/index.ts | 61 ++++++++++++------- 3 files changed, 44 insertions(+), 21 deletions(-) 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 5acfcca..70b9c65 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 @@ -26,6 +26,8 @@ const TEXT_CASES = [ 'hello', 'hello', 'hello', + 'hello', // styled, no a11y: `accessible` default must survive the style spread + 'hello', ]; const VIEW_CASES = [ diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/flattens-styles-at-runtime/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/flattens-styles-at-runtime/output.js index 1e0cb3e..f75e2bf 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/flattens-styles-at-runtime/output.js +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/flattens-styles-at-runtime/output.js @@ -10,6 +10,7 @@ import { Text } from 'react-native'; })} allowFontScaling={true} ellipsizeMode={'tail'} + accessible={_getDefaultTextAccessible()} />; <_NativeText {..._processTextStyle([ @@ -23,4 +24,5 @@ import { Text } from 'react-native'; selectable={true} allowFontScaling={true} ellipsizeMode={'tail'} + accessible={_getDefaultTextAccessible()} />; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/index.ts b/packages/react-native-boost/src/plugin/optimizers/text/index.ts index 5aa19d1..8a5eecc 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/text/index.ts @@ -261,30 +261,49 @@ function processProps(path: NodePath, file: HubFile, platfo remainingAttributes.push(attribute); } - path.node.attributes = [...spreadAttributes, selectableAttribute, ...remainingAttributes].filter( - (attribute): attribute is t.JSXAttribute | t.JSXSpreadAttribute => attribute !== undefined - ); - // ============================================ // 3. `accessible` default for the common path // ============================================ // With no accessibility/`disabled` prop the helper is skipped, but `Text` still applies a - // platform-specific `accessible` default (`true` on iOS, `false` on Android, omitted on web). Metro - // bundles per platform and reports the target on the Babel caller, so the value is known at build - // time and we inline the literal directly. Only when the platform is unknown (non-Metro bundlers, - // fixture tests) do we defer to the lightweight runtime resolver, so the default is never dropped. - if (!shouldNormalize) { - if (platform === 'ios' || platform === 'android') { - addDefaultProperty(path, 'accessible', t.booleanLiteral(platform === 'ios')); - } else if (platform !== 'web') { - const accessibleIdentifier = addFileImportHint({ - file, - nameHint: 'getDefaultTextAccessible', - path, - importName: 'getDefaultTextAccessible', - moduleName: RUNTIME_MODULE_NAME, - }); - addDefaultProperty(path, 'accessible', t.callExpression(t.identifier(accessibleIdentifier.name), [])); - } + // platform-specific `accessible` default. We build it explicitly (rather than via `addDefaultProperty`, + // which gives up when it hits the unresolvable `{...processTextStyle(...)}` spread and would silently + // drop the default on styled text). The cheap path guarantees no `accessible` is already set — any + // such prop would have set `shouldNormalize`, and unresolvable spreads bail before this point — so + // appending it unconditionally is safe. + const accessibleAttribute = shouldNormalize ? undefined : buildAccessibleDefault(path, file, platform); + + path.node.attributes = [...spreadAttributes, selectableAttribute, ...remainingAttributes, accessibleAttribute].filter( + (attribute): attribute is t.JSXAttribute | t.JSXSpreadAttribute => attribute !== undefined + ); +} + +/** + * Builds the `accessible` default attribute `Text` applies when the prop is omitted: `true` on iOS, + * `false` on Android, omitted on web. Metro bundles per platform and reports the target on the Babel + * caller, so a known platform is inlined as a literal; an unknown platform (non-Metro bundlers, + * fixture tests) defers to the lightweight runtime resolver. Returns `undefined` when nothing should + * be emitted. + */ +function buildAccessibleDefault( + path: NodePath, + file: HubFile, + platform?: string +): t.JSXAttribute | undefined { + if (platform === 'web') return undefined; + + let value: t.Expression; + if (platform === 'ios' || platform === 'android') { + value = t.booleanLiteral(platform === 'ios'); + } else { + const accessibleIdentifier = addFileImportHint({ + file, + nameHint: 'getDefaultTextAccessible', + path, + importName: 'getDefaultTextAccessible', + moduleName: RUNTIME_MODULE_NAME, + }); + value = t.callExpression(t.identifier(accessibleIdentifier.name), []); } + + return t.jsxAttribute(t.jsxIdentifier('accessible'), t.jsxExpressionContainer(value)); } From b9443384ad5047bd3eb86fba97f68c80ee176fea Mon Sep 17 00:00:00 2001 From: mfkrause Date: Fri, 19 Jun 2026 21:32:13 +0000 Subject: [PATCH 6/7] test(parity): assert optimization intent and native host kind The parity it.each blocks only compared prop bags. A bailed Boost output silently returns `{ optimized: false }` and the test skipped it, so a regression that stopped optimizing a case (a real loss) would pass as green. Assert each Text case optimizes, each View case matches its expected bail status (tracked in BAILED_VIEW_CASES), and that the native host kind (`which`) agrees with the wrapper oracle. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HzGmSHV9mGmFEs1QZ5XmhF --- .../plugin/__tests__/parity/parity.test.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) 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 70b9c65..e4b1b1e 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 @@ -35,11 +35,19 @@ const VIEW_CASES = [ '', '', '', - '', // blacklisted → Boost bails → skipped - '', // blacklisted → Boost bails → skipped - '', // blacklisted → Boost bails → skipped + '', // blacklisted → Boost bails → defers to wrapper + '', // blacklisted → Boost bails → defers to wrapper + '', // blacklisted → Boost bails → defers to wrapper ]; +// Cases Boost is expected to bail on (blacklisted props). Asserting the bail explicitly stops an +// unexpected bailout — a silent loss of optimization — from masquerading as a passing parity test. +const BAILED_VIEW_CASES = new Set([ + '', + '', + '', +]); + // Treat `undefined`-valued keys as absent and deep-clean nested objects (also drops function values // such as event handlers) so the comparison is a clean structural prop-bag equality. const normalize = (props: Record) => JSON.parse(JSON.stringify(props)); @@ -48,15 +56,19 @@ describe('differential parity', () => { describe.each(PLATFORMS)('Platform.OS=%s', (os) => { it.each(TEXT_CASES)('Text: %s', async (jsx) => { const boost = await captureBoost(os, jsx); - if (!boost.optimized) return; // bailed → defers to the wrapper, equivalent by construction + expect(boost.optimized).toBe(true); // every Text case here is expected to optimize + if (!boost.optimized) return; const wrapper = await captureWrapper(os, jsx); + expect(boost.which).toEqual(wrapper.which); // same native host kind expect(normalize(boost.props)).toEqual(normalize(wrapper.props)); }); it.each(VIEW_CASES)('View: %s', async (jsx) => { const boost = await captureBoost(os, jsx); + expect(boost.optimized).toBe(!BAILED_VIEW_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); // same native host kind expect(normalize(boost.props)).toEqual(normalize(wrapper.props)); }); }); From 472d15b8e9ba5a4f6ce99ede46e3333626fc83d2 Mon Sep 17 00:00:00 2001 From: mfkrause Date: Fri, 19 Jun 2026 21:33:36 +0000 Subject: [PATCH 7/7] test(parity): convert file URLs to paths with fileURLToPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new URL(...).pathname` percent-encodes URL-unsafe characters — a space in a parent directory becomes `%20`, so the subsequent `writeFileSync` / dynamic `import` target a path that doesn't exist — and on Windows leaves a stray leading slash before the drive letter (`/C:/...`). `fileURLToPath` decodes both correctly. Affects the parity harness's generated-file paths (boost.ts, wrapper.ts) and the vite config's `u()` path helper. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HzGmSHV9mGmFEs1QZ5XmhF --- .../react-native-boost/src/plugin/__tests__/parity/boost.ts | 3 ++- .../src/plugin/__tests__/parity/vitest.config.parity.mts | 5 ++++- .../src/plugin/__tests__/parity/wrapper.ts | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) 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 4ab9e25..859801f 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/boost.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/boost.ts @@ -1,4 +1,5 @@ import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { transformSync, type TransformCaller } from '@babel/core'; import * as React from 'react'; import boostPlugin from '../../index'; // src/plugin/index.ts — the full Boost plugin @@ -43,7 +44,7 @@ export async function captureBoost(os: 'ios' | 'android', jsxBody: string): Prom // Single-element snippet: the runtime import is injected iff that element was optimized. if (!code.includes(RUNTIME_MODULE_NAME)) return { optimized: false }; - const file = new URL(`./__generated__/boost-${counter++}.js`, import.meta.url).pathname; + const file = fileURLToPath(new URL(`./__generated__/boost-${counter++}.js`, import.meta.url)); writeFileSync(file, code); const mod = await import(/* @vite-ignore */ file); const { which, props } = renderAndCapture(React.createElement(mod.default)); 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 95eb5c9..b1119ae 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 @@ -1,10 +1,13 @@ import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { transformSync } from '@babel/core'; import { defineConfig } from 'vitest/config'; const require = createRequire(import.meta.url); -const u = (p: string) => new URL(p, import.meta.url).pathname; +// `fileURLToPath` (not `URL.pathname`) so paths survive percent-encodable characters (e.g. a space +// in a parent directory becomes `%20` in `.pathname`) and the Windows drive-letter prefix. +const u = (p: string) => fileURLToPath(new URL(p, import.meta.url)); // React Native ships its own source as Flow `.js`. We only want to transform RN's own files. const RN_SRC = /\/node_modules\/react-native\/(Libraries|src)\/.*\.js$/; 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 425d24f..b9f9217 100644 --- a/packages/react-native-boost/src/plugin/__tests__/parity/wrapper.ts +++ b/packages/react-native-boost/src/plugin/__tests__/parity/wrapper.ts @@ -1,4 +1,5 @@ import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { transformSync } from '@babel/core'; import * as React from 'react'; import { renderAndCapture } from './capture'; @@ -26,7 +27,7 @@ export async function captureWrapper(os: 'ios' | 'android', jsxBody: string) { filename: 'wrapper-case.jsx', presets: [['@babel/preset-react', { runtime: 'automatic' }]], }); - const file = new URL(`./__generated__/wrapper-${os}-${counter++}.js`, import.meta.url).pathname; + const file = fileURLToPath(new URL(`./__generated__/wrapper-${os}-${counter++}.js`, import.meta.url)); writeFileSync(file, out!.code!); const mod = await import(/* @vite-ignore */ file); return renderAndCapture(React.createElement(mod.default));