Skip to content

Match RN Text accessibility/disabled behavior + add differential parity tests#23

Merged
mfkrause merged 7 commits into
mainfrom
test/differential-parity
Jun 19, 2026
Merged

Match RN Text accessibility/disabled behavior + add differential parity tests#23
mfkrause merged 7 commits into
mainfrom
test/differential-parity

Conversation

@mfkrause

@mfkrause mfkrause commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Adds a differential parity harness that renders real React Native Text/View against Boost's optimized output and asserts the native prop bags match per platform. This is the only reliable way to catch where the optimizer silently diverges from RN's wrapper, since those wrappers do non-trivial prop reconciliation before hitting the native host.

The harness immediately surfaced two Text divergences, which this PR fixes: optimized <Text> dropped the platform-specific accessible default (iOS opts text in, Android defaults off), and it skipped the disabledaccessibilityState.disabled reconciliation. The fix mirrors Text.js exactly — a lightweight getDefaultTextAccessible() for the common no-a11y path, and the full reconciliation in processAccessibilityProps when accessibility/disabled props are present.

Both values are resolved at render time (like RN's own Platform.select) rather than hoisted, so they track the platform correctly. Affected text fixtures are regenerated; all View parity cases were already passing and are unchanged.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Tests

    • Added comprehensive testing infrastructure to verify text optimization correctness across iOS and Android platforms.
  • Bug Fixes

    • Improved accessibility handling with platform-specific defaults for the accessible property.
    • Enhanced reconciliation between disabled prop and accessibility state properties.

mfkrause and others added 3 commits June 19, 2026 20:02
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGmSHV9mGmFEs1QZ5XmhF
Optimized `<Text>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGmSHV9mGmFEs1QZ5XmhF
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGmSHV9mGmFEs1QZ5XmhF
@netlify

netlify Bot commented Jun 19, 2026

Copy link
Copy Markdown

Deploy Preview for react-native-boost ready!

Name Link
🔨 Latest commit 472d15b
🔍 Latest deploy log https://app.netlify.com/projects/react-native-boost/deploys/6a35b730b0a5dd0008808c41
😎 Deploy Preview https://deploy-preview-23--react-native-boost.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6af88755-71b8-4285-a87a-7a667b7466f3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The PR adds platform-aware getDefaultTextAccessible to the native and web runtimes, reconciles the disabled prop with accessibilityState in processAccessibilityProps, and wires the new default into the Babel text optimizer via a shouldNormalize path and NORMALIZED_PROPERTIES set. All existing text optimizer fixtures are updated. A new differential parity test suite is introduced using a custom Vite plugin that runs real React Native source through Babel under Vitest.

Changes

Runtime accessibility & plugin optimizer

Layer / File(s) Summary
getDefaultTextAccessible and disabled reconciliation
src/runtime/index.ts, src/runtime/index.web.ts
Adds Platform import; exports getDefaultTextAccessible returning true/false/undefined per platform; extends processAccessibilityProps to accept and reconcile disabled with accessibilityState.disabled and compute accessible via Platform.select; web stub returns undefined.
Plugin optimizer: NORMALIZED_PROPERTIES and accessible injection
src/plugin/optimizers/text/index.ts
Adds NORMALIZED_PROPERTIES set (a11y props + disabled) and isNormalizedProperty guard; refactors processProps to use shouldNormalize; skips normalized attrs from final attribute list; injects accessible={getDefaultTextAccessible()} when shouldNormalize is false.
Fixture outputs
src/plugin/optimizers/text/__tests__/fixtures/*/output.js
All text optimizer fixture outputs import getDefaultTextAccessible and add accessible={_getDefaultTextAccessible()} to _NativeText elements.
Runtime unit tests
src/runtime/__tests__/index.test.ts
Switchable Platform mock; afterEach reset; new getDefaultTextAccessible iOS/Android assertions; Android accessible default test; disabled/accessibilityState precedence tests.

Differential Parity Test Infrastructure

Layer / File(s) Summary
Capture infrastructure and RN mocks
src/plugin/__tests__/parity/capture.tsx, src/plugin/__tests__/parity/mocks/*
capture.tsx exports sink, makeCapturer, three capturer components, and renderAndCapture; mocks provide switchable Platform, PressabilityDebug, ReactNativeFeatureFlags, TextNativeComponent, ViewNativeComponent, processColor, react-native, and usePressability.
Vitest parity config, setup, and deps
src/plugin/__tests__/parity/vitest.config.parity.mts, src/plugin/__tests__/parity/setup.ts, vitest.config.ts, package.json
vitest.config.parity.mts adds rn-parity Vite pre-plugin that redirects React/JSX runtimes, routes RN modules to mocks, and Babel-transforms RN .js sources; setup.ts sets __DEV__/RN$Bridgeless; root config becomes multi-project; Babel and React 19 devDependencies added.
captureWrapper and captureBoost helpers
src/plugin/__tests__/parity/wrapper.ts, src/plugin/__tests__/parity/boost.ts
captureWrapper builds and Babel-transforms a wrapper module around JSX then calls renderAndCapture; captureBoost runs the Boost plugin, detects runtime injection, writes the file, imports it, and returns optimized/bailed result.
Differential parity test suite
src/plugin/__tests__/parity/parity.test.ts
Defines TEXT_CASES/VIEW_CASES, mocks native-text/native-view, normalizes props via JSON round-trip, iterates platforms × cases comparing captureBoost against captureWrapper, skipping bailed cases.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 Hop, hop, the rabbit taps away,
accessible defaults are here to stay!
iOS says true, Android says false,
parity tests run — no props are lost.
The fixtures all shimmer, updated and bright,
differential capture checks left and right. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: implementing React Native Text accessibility/disabled behavior matching and adding differential parity tests, which are the primary focus of this PR.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/differential-parity

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts`:
- Around line 47-58: The test cases in the TEXT_CASES and VIEW_CASES test blocks
silently return when boost.optimized is false, which can hide regressions.
Instead of returning early, add an assertion to explicitly verify that the
bailout is expected or handle it as a test condition. Additionally, the parity
assertion between boost and wrapper should not only compare props but also
assert the `which` property to ensure both the optimization intent and host kind
match correctly. Update both the it.each blocks for 'Text: %s' and 'View: %s' to
include these assertions rather than silently skipping the validation.

In
`@packages/react-native-boost/src/plugin/__tests__/parity/vitest.config.parity.mts`:
- Around line 7-11: The RN_SRC regex pattern uses forward slashes which will not
match Windows file paths containing backslashes from Vite's id parameter. To fix
this, normalize the id parameter from Vite to use forward slashes before
applying it against the RN_SRC regex pattern in the transform hook. You can use
the u function approach or simply replace backslashes with forward slashes in
the id parameter before matching. Apply this same normalization fix to the
transform hooks in wrapper.ts and boost.ts for consistency across all Vite
config files.

In `@packages/react-native-boost/src/plugin/optimizers/text/index.ts`:
- Around line 264-283: The issue is that addDefaultProperty is called after the
attributes array has been rebuilt with spreadAttributes, which contain
unresolvable spreads that cause addDefaultProperty to skip insertion. Move the
call to addDefaultProperty (which injects the accessible default with
getDefaultTextAccessible) to occur before the attributes array is modified with
the spread calls on line 264, ensuring the accessible property is added to the
path before spreads are introduced into the attributes.
- Around line 196-200: The shouldNormalize variable in the text optimizer only
checks for the disabled attribute as a direct JSX attribute using
t.isJSXAttribute, which misses cases where disabled is passed through spread
props like {...{ disabled: true }}. Extend the attribute checking logic to also
detect when disabled might be present in spread attributes (JSXSpreadAttribute),
so that processAccessibilityProps is always called and disabled is properly
reconciled with accessibilityState.disabled regardless of whether it's a direct
attribute or part of a spread object.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d5a8086b-2412-4c7c-afcd-06bcf610e8a7

📥 Commits

Reviewing files that changed from the base of the PR and between 150c6a4 and 04a67a8.

⛔ Files ignored due to path filters (2)
  • packages/react-native-boost/src/plugin/__tests__/parity/__generated__/.gitignore is excluded by !**/__generated__/**
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (41)
  • packages/react-native-boost/package.json
  • packages/react-native-boost/src/plugin/__tests__/parity/boost.ts
  • packages/react-native-boost/src/plugin/__tests__/parity/capture.tsx
  • packages/react-native-boost/src/plugin/__tests__/parity/mocks/Platform.ts
  • packages/react-native-boost/src/plugin/__tests__/parity/mocks/PressabilityDebug.ts
  • packages/react-native-boost/src/plugin/__tests__/parity/mocks/ReactNativeFeatureFlags.ts
  • packages/react-native-boost/src/plugin/__tests__/parity/mocks/TextNativeComponent.ts
  • packages/react-native-boost/src/plugin/__tests__/parity/mocks/ViewNativeComponent.ts
  • packages/react-native-boost/src/plugin/__tests__/parity/mocks/processColor.ts
  • packages/react-native-boost/src/plugin/__tests__/parity/mocks/react-native.ts
  • packages/react-native-boost/src/plugin/__tests__/parity/mocks/usePressability.ts
  • packages/react-native-boost/src/plugin/__tests__/parity/parity.test.ts
  • packages/react-native-boost/src/plugin/__tests__/parity/setup.ts
  • packages/react-native-boost/src/plugin/__tests__/parity/vitest.config.parity.mts
  • packages/react-native-boost/src/plugin/__tests__/parity/wrapper.ts
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/basic/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/complex-example/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/default-props/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-alias-as-child/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-as-child-false-static/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-as-child-nested-view/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-as-child/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/expo-router-link-namespace-as-child/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/flattens-styles-at-runtime/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/force-comment/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/ignore-comment/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/mixed-children-types/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/nested-in-object-with-ignore-comment/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/non-react-native-import/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/number-of-lines/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/pressables/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/resolvable-spread-props/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/text-content-as-explicit-child-prop/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/text-content/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/two-components/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/variable-child-no-string/output.js
  • packages/react-native-boost/src/plugin/optimizers/text/index.ts
  • packages/react-native-boost/src/runtime/__tests__/index.test.ts
  • packages/react-native-boost/src/runtime/index.ts
  • packages/react-native-boost/src/runtime/index.web.ts
  • packages/react-native-boost/vitest.config.ts

Comment thread packages/react-native-boost/src/plugin/__tests__/parity/vitest.config.parity.mts Outdated
Comment on lines +196 to +200
const shouldNormalize =
hasAccessibilityProperty(path, currentAttributes) ||
currentAttributes.some(
(attribute) => t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name, { name: 'disabled' })
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

disabled in spread props currently bypasses reconciliation.

Line 196 only checks disabled as a direct JSX attribute. Cases like <Text {...{ disabled: true }} /> won’t set shouldNormalize, so processAccessibilityProps is skipped and disabled is not reconciled with accessibilityState.disabled as intended.

Suggested fix
-  const shouldNormalize =
-    hasAccessibilityProperty(path, currentAttributes) ||
-    currentAttributes.some(
-      (attribute) => t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name, { name: 'disabled' })
-    );
+  const hasDisabledProp = currentAttributes.some((attribute) => {
+    if (t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name, { name: 'disabled' })) return true;
+    if (t.isJSXSpreadAttribute(attribute) && t.isObjectExpression(attribute.argument)) {
+      return attribute.argument.properties.some(
+        (p) =>
+          t.isObjectProperty(p) &&
+          ((t.isIdentifier(p.key) && p.key.name === 'disabled') ||
+            (t.isStringLiteral(p.key) && p.key.value === 'disabled'))
+      );
+    }
+    // Keep conservative behavior for unresolved spreads
+    return t.isJSXSpreadAttribute(attribute) && !t.isObjectExpression(attribute.argument);
+  });
+
+  const shouldNormalize = hasAccessibilityProperty(path, currentAttributes) || hasDisabledProp;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-native-boost/src/plugin/optimizers/text/index.ts` around lines
196 - 200, The shouldNormalize variable in the text optimizer only checks for
the disabled attribute as a direct JSX attribute using t.isJSXAttribute, which
misses cases where disabled is passed through spread props like {...{ disabled:
true }}. Extend the attribute checking logic to also detect when disabled might
be present in spread attributes (JSXSpreadAttribute), so that
processAccessibilityProps is always called and disabled is properly reconciled
with accessibilityState.disabled regardless of whether it's a direct attribute
or part of a spread object.

Comment thread packages/react-native-boost/src/plugin/optimizers/text/index.ts Outdated
mfkrause and others added 4 commits June 19, 2026 21:19
The common optimized `<Text>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGmSHV9mGmFEs1QZ5XmhF
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 `<Text>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGmSHV9mGmFEs1QZ5XmhF
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGmSHV9mGmFEs1QZ5XmhF
`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGmSHV9mGmFEs1QZ5XmhF
@mfkrause mfkrause merged commit 39253c6 into main Jun 19, 2026
8 checks passed
@mfkrause mfkrause deleted the test/differential-parity branch June 19, 2026 21:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant