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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/react-native-boost/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { transformSync, type TransformCaller } from '@babel/core';
import boostPlugin from '../index';

// Compile a bare `<Text>` (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 () => <Text>hi</Text>;`, {
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()}');
});
});

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions packages/react-native-boost/src/plugin/__tests__/parity/boost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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
import { RUNTIME_MODULE_NAME } from '../../utils/constants';
import { renderAndCapture } from './capture';
import { setPlatformOS } from './mocks/Platform';

let counter = 0;

interface BoostBailed {
optimized: false;
}

interface BoostOptimized {
optimized: true;
which?: string;
props: Record<string, unknown>;
}

/**
* 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.
*
* 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<BoostBailed | BoostOptimized> {
setPlatformOS(os);
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',
caller: { name: 'metro', platform: os } as TransformCaller,
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 = 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));
return { optimized: true, which, props };
}
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<Record<string, unknown>> =>
(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<string, unknown> } {
sink.props = undefined;
sink.which = undefined;
renderToStaticMarkup(element);
const captured: Record<string, unknown> = sink.props ?? {};
const { children: _children, ...rest } = captured;
return { which: sink.which, props: rest };
}
Original file line number Diff line number Diff line change
@@ -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<T>(spec: Record<string, T>): T | undefined {
return os in spec ? spec[os] : spec.default;
},
};

export default Platform;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// `Text.js` imports `PressabilityDebug` for its dev-only debug overlay. Disable it.
export function isEnabled() {
return false;
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { NativeViewCapturer } from '../capture';

// `View.js` renders `ViewNativeComponent`'s default export; point it at the shared capturer.
export default NativeViewCapturer;
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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: <T>(style: T) => style };
Original file line number Diff line number Diff line change
@@ -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 {};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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;

// `<Text>` cases use a string child so they render to NativeText (not NativeVirtualText).
const TEXT_CASES = [
'<Text>hello</Text>',
'<Text aria-label="x">hello</Text>',
'<Text accessibilityLabel="x">hello</Text>',
'<Text accessible={false}>hello</Text>',
'<Text disabled={true}>hello</Text>',
'<Text accessibilityState={{ disabled: true }}>hello</Text>',
'<Text numberOfLines={2}>hello</Text>',
'<Text aria-busy={true}>hello</Text>',
'<Text style={{ color: "red" }}>hello</Text>', // styled, no a11y: `accessible` default must survive the style spread
'<Text style={{ color: "red" }} accessibilityLabel="x">hello</Text>',
];

const VIEW_CASES = [
'<View testID="v" />',
'<View accessibilityRole="button" />',
'<View accessibilityValue={{ now: 5 }} />',
'<View pointerEvents="none" />',
'<View aria-label="x" />', // blacklisted → Boost bails → defers to wrapper
'<View tabIndex={0} />', // blacklisted → Boost bails → defers to wrapper
'<View style={{ width: 1 }} />', // 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([
'<View aria-label="x" />',
'<View tabIndex={0} />',
'<View style={{ width: 1 }} />',
]);

// 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<string, unknown>) => 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(os, jsx);
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));
Comment thread
mfkrause marked this conversation as resolved.
});
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).__DEV__ = false;
(globalThis as Record<string, unknown>).RN$Bridgeless = true;
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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);
// `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$/;
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<string, string> = {
'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
},
});
Loading
Loading