npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-native-a11y-tree

v0.1.7

Published

Render a React Native component headlessly (real Metro + Fabric/Hermes) and print its accessibility and layout tree; interact with it and check a11y/design rules. For agents and CI.

Downloads

1,322

Readme

react-native-a11y-tree

Render a React Native component file headlessly and print its accessibility and layout tree as JSON; drive it with taps, typing, scrolling and gestures.

rn-a11y-tree render App.tsx --platform android > tree.json

The component is bundled with real Metro (Expo's @expo/metro-config) and runs in a headless React Native Fabric host (React Native's "Fantom" tester: C++ + Hermes, no simulator). The output is the committed ShadowTree with on-screen layout boxes. The native side is documented in native/README.md; the e2e coverage in docs/e2e-coverage.md.

Status

macOS arm64 only (the host is built by bun run build:host).

| Feature | How it is real | E2E | Known gaps | | --- | --- | --- | --- | | Rendering and layout | Real Fabric (React, ShadowTree, Yoga) in the Fantom host | e2e/render.test.ts | One surface per run; native platform UI is not instantiated | | Text measurement | CoreText TextLayoutManager in the host; or the portable layout (stb_truetype, embedded Roboto; RN_A11Y_TEXT_LAYOUT=portable, see native/README.md) | e2e/render.test.ts (heights > 10) | macOS fonts, not Android/iOS fonts; portable: Roboto stands in for SF | | Accessibility tree | Host NativeFantom.getA11yTree (typed ShadowTree dump) → src/tree.ts | e2e/render.test.ts | Role/name derivation is simpler than real screen readers | | TextInput, Switch | Host AndroidTextInput / iOS TextInput (CoreText measured) and AndroidSwitch / Switch shadow nodes | e2e/render.test.ts, e2e/run.test.ts (both presets) | | | Tap, long press, typing | Host hitTest + by-tag native events, Pressable responder events, setTextInputTextByTag | e2e/run.test.ts | No multi-touch responder events; click does not bubble | | Scrolling, FlatList | Host scroll events and ScrollView state; onLayout delivered by settling the event queue | e2e/scrolling.test.ts | One scroll event per scroll action (no fling) | | Session mode | Host --interactive mode, one bundle | e2e/session.test.ts | No recovery after a host crash | | react-native-screens | Library C++ compiled into the host; screen state emulated by the host | e2e/navigation-stack.test.ts | No native transitions or native-tab controller | | react-native-safe-area-context | Library C++ compiled into the host; insets from --safe-area-insets | e2e/navigation-stack.test.ts, e2e/expo-modules.test.ts (preset insets) | Insets are configured, not measured from a device | | react-native-gesture-handler | Host descriptors for detector/root/button; JS module on RNGH's web handlers fed by the runner; worklet callbacks through Reanimated | e2e/gestures.test.ts | No v3 Reanimated detector events, virtual detectors, or transforms in absoluteToLocal | | react-native-reanimated | Reanimated + worklets C++ in the host; UI frames from wait (produceFramesForDuration per 16.333 ms); mounted-view values for layout animations | e2e/reanimated.test.ts | | | @expo/ui (Expo module views) | expo-modules-core Fabric descriptors in the host; Expo's JS globalThis.expo polyfill + view configs + module stubs (runtime/expo/); direct events and modifier callbacks | e2e/expo-ui.test.ts | Frames come from the host's SwiftUI and Compose layout engines (emulations of the frameworks, checked against reference harnesses in native/tools/); see Expo support for other packages |

App roots and custom Metro configuration

For a shared component outside the consuming app, select that app explicitly:

rn-a11y-tree render ../ui/Screen.tsx --project-root ./apps/mobile --preset android-phone

React and React Native imports from shared packages resolve through the consuming app to keep one runtime identity. The selected root supplies dependencies, Babel/tsconfig settings, native fixtures, and a11y-tree.json. It must contain package.json.

Metro customization is explicit:

rn-a11y-tree render App.tsx --preset android-phone --metro-config ./metro.config.js

CLI --metro-config paths resolve from the working directory. Or set "metroConfig": "./metro.config.js" in a11y-tree.json (relative to the app root). Supported fields are watchFolders, resolver assetExts, sourceExts, nodeModulesPaths, extraNodeModules, resolveRequest, and transformer babelTransformerPath. This includes the tested react-native-svg-transformer 1.5.3 Expo integration. Importing SVG components does not verify native SVG pixels; existing native fallback diagnostics still apply.

Custom worker, serializer, polyfill, and other resolver/transformer changes are rejected. Native CSS integrations such as NativeWind need a separately verified adapter. Default callback recognition compares function source because Metro recreates its closures; this is not a semantic guarantee for arbitrary plugins. Use a dedicated headless configuration when the app config exceeds this surface.

Explicit custom configuration disables persistent bundle, transform, and file-map caches: arbitrary plugin/helper inputs cannot yet be fingerprinted safely. This costs rebuild time, but config-helper edits take effect on the next invocation. The normal configuration retains its disk caches and needs no Metro daemon. Detected app-root Metro configs that are not selected produce a BUILD_CONFIGURATION diagnostic and fail strict fallback policy unless allowed.

Execution deadlines

render, run, and check terminate a hung host after 30 seconds by default. Use --timeout <ms> to choose a positive integer deadline. It starts when the host launches, after Metro bundling, and reports TIMEOUT with exit code 5. The deadline is shared across bytecode fallback attempts. Shutdown allows a bounded grace period (up to 1.25 seconds). A timeout does not retry the app with JavaScript after a bytecode attempt. On POSIX, SIGINT/SIGTERM during host execution cancel that run and terminate its host process group. Session mode continues to apply --timeout separately to each request.

One-shot host execution accepts at most 32 MiB of combined stdout and stderr, including the result and application logs. Exceeding this limit terminates the host with HOST_CRASHED (exit 5), error.details.outputLimit: true, and no bytecode retry. Reduce console output or render a smaller screen. This bounds protocol buffering even when a line has no newline. Diagnostic stderr files contain only output accepted before the limit.

Sessions apply the same 32 MiB combined host-output budget separately to each request, including startup. Output between requests counts toward the next request; extra completion markers cannot reset it. Valid long sessions may produce more than 32 MiB overall. Retained diagnostics are deduplicated and bounded by both bytes and unique entries; overflow fails explicitly rather than dropping evidence. Stderr retention is limited to a 64 KiB tail, while accepted stderr can stream to RN_A11Y_HOST_STDERR_LOG.

Session shutdown and inherited-pipe cleanup are bounded too. An idle host failure emits {id: null, ok: false, error: ...}; an active request retains its request ID, and startup failures use ready: false. Late shutdown logs or diagnostics may produce an additional id: null record. Rejected late evidence remains an error and causes exit 6 in strict mode. SIGINT/SIGTERM cancel the session with HOST_CRASHED, details.cancelled: true, and exit 5. POSIX host process groups are terminated. Windows requests bounded process-tree termination through the system taskkill.exe before killing the runner itself. A failed or unavailable tree cleanup is reported through details.cleanupIncomplete and details.cleanupReason, preserving the original timeout/cancellation error. Windows cannot guarantee descendant cleanup after the runner has already exited; the pipe cutoff still bounds the CLI wait. This is best-effort cleanup, not a Windows Job Object ownership guarantee.

Dependency checks and application fixtures

See production adoption and remaining validation for the supported use case, maintenance policy and evidence still needed.

Run rn-a11y-tree doctor ./path/to/app before bundling. JSON output reports app-resolved dependency versions, local host metadata, and compatibility issues. --strict also rejects dependency versions outside the tested tuple. Doctor does not execute or download a host; a successful result is a dependency preflight, not proof that all native APIs or device behavior are supported. Rendering rejects known React Native major/minor host mismatches before Metro starts.

For native services specific to your app, provide an explicit fixture file:

// fixtures.ts
export default {
  turboModules: {
    MyNativeService: {getValue: () => 'deterministic fixture value'},
  },
  expoModules: {
    MyExpoService: {getStatusAsync: async () => 'available'},
  },
  nitroModules: {
    // A factory per HybridObject creation, not a shared module singleton.
    MyHybridService: () => ({getValue: () => 'fixture value'}),
  },
};
rn-a11y-tree render App.tsx --preset android-phone --setup ./fixtures.ts

Fixtures install before the app imports. They override named modules, emit an APPLICATION_FIXTURE diagnostic when used, and reset with each fresh CLI process. Their source participates in bundle cache invalidation. Implement the actual JS library's native contract, including callback and Promise behavior; these fixtures do not verify native implementation, persistence, permissions, or device lifecycle. See the AsyncStorage example for a real third-party JS library backed by an explicit in-memory native fixture.

Nitro fixtures require the app's react-native-nitro-modules dependency. They provide explicit HybridObject factories through the package's normal JS bootstrap; used factories report APPLICATION_FIXTURE with target nitro/<name>. Unknown objects and unsupported native proxy operations throw and report NATIVE_API_UNSUPPORTED, including when the app catches the error. No native Nitro/JSI objects, persistence, cross-runtime sharing, or native state are created. Nitro 0.37.1 also attempts boxing during import when Worklets is installed; that caught error remains a NitroModules.box diagnostic and fails strict mode even when all fixture names are allowed. Nitro views use the existing unsupported-view fallback: layout and standard View props can be inspected, but native drawing, hybridRef, and native callbacks are not executed. Strict mode requires an explicit allowance for each view. See the Nitro example for MMKV and Nitro Image coverage and the exact package versions and limitations.

Commit shared policy in the app's a11y-tree.json:

{
  "setup": "./fixtures.ts",
  "failOnFallback": true,
  "allowFallback": ["turbo/MyNativeService", "expo/MyExpoService"]
}

Config setup paths resolve from the project root; CLI --setup paths resolve from the working directory. --no-fail-on-fallback overrides configured strict policy. Allowances acknowledge specific simulated modules; unsupported built-in adapter APIs still fail strict policy. Native UI libraries that need descriptors, layout, gestures, or rendering may require host integration beyond these fixtures.

Install

Changes per version: CHANGELOG.md.

From version 0.1.0 (macOS arm64 only), in an Expo SDK 58 project:

npx react-native-a11y-tree render App.tsx --preset android-phone --format text
# or: npm install -D react-native-a11y-tree && npx rn-a11y-tree render ...

The CLI and its optional native runtimes:

  • react-native-a11y-tree: the CLI. Its bin is dist/rn-a11y-tree.js, one ES module that bun build makes from packages/react-native-a11y-tree/src/cli.ts for Node (bun run build, run by prepack together with bun run schema --check); in a repo checkout, bun run rn-a11y-tree runs node packages/react-native-a11y-tree/src/cli.ts (Node strips the types). Files: dist/, runtime/, schema/, tools/, README.md, LICENSE. Commander and runtime selection are bundled into the CLI. The package installs its tested Vitest runner and Vite automatically for rn-a11y-tree test. Supported Node versions: 22.x from 22.12, 24.x, and 26+. Peer dependencies: expo (>= 58), react-native, react. Metro, metro-config, expo/metro-config and hermes-compiler are loaded from the project (the copies that Expo and React Native install), so the bundle uses the project's Metro.
  • The CLI directly depends on these exact-version optional runtime packages: @react-native-a11y-tree/runtime-darwin-universal (macOS arm64/x64), @react-native-a11y-tree/runtime-linux-x64-gnu (glibc 2.28+), and @react-native-a11y-tree/runtime-win32-x64-msvc. Their os/cpu fields (and Linux libc) let the package manager install only the matching binary. Resolution lives inside the CLI's single JS bundle; there is no separate resolver package, postinstall download, or extra CLI command. Keep optional dependencies enabled; for an installation made with --omit=optional, reinstall with npm install --include=optional. The existing RN_A11Y_HOST_* overrides and binary filenames still work.

Local check of this flow: e2e/package.test.ts packs the CLI and current platform package (npm pack), installs them with [email protected] [email protected] [email protected] into a scratch project and runs npx rn-a11y-tree render App.tsx --preset android-phone --format text (skipped without npm or a native/dist host). The release workflow does the same with the packages it builds (scripts/verify-packages.sh), on Linux, macOS, and Windows.

Quick start

git clone --recurse-submodules --shallow-submodules <this repo>
bun install
bun run build:host        # builds native/dist/<arch>/rn-a11y-host
bun run rn-a11y-tree render examples/basic/App.tsx --platform android
bun run check             # bun run typecheck, bun run schema --check, bun run test, bun run test:e2e

The CLI uses native/dist/<arch>/rn-a11y-host (rn-a11y-host.exe on Windows). Set RN_A11Y_HOST_BIN to use another host binary, or RN_A11Y_HOST_BASE_URL to download a prebuilt host (see Prebuilt host). RN_A11Y_HOST_RUNNER=<program> starts the host as <program> <host> <args> (the unit tests run the script fake host with bun, also on Windows).

Vitest flow tests

Write flows with Vitest and Testing Library-style queries. Tests drive the headless Fabric host; no test runner configuration is required.

npm install --save-dev react-native-a11y-tree
npx rn-a11y-tree skill
npx rn-a11y-tree test a11y/notes.a11y.test.ts
npx rn-a11y-tree test a11y/notes.a11y.test.ts -t 'should create a note'
npx rn-a11y-tree test --reporter=json --outputFile=flow-results.json

For behavior changes, write assertions and run the affected file once. After a repair, rerun the failing name with -t, then run the whole affected file after the last edit. An unchanged green run already satisfies the final check. Check executed counts: a name filter matching no tests can exit 0 with all tests skipped. Run rn-a11y-tree test without a file to discover the whole suite. test --help prints a short command reference; other Vitest flags pass through.

Use render for one-time tree inspection, run for one-time interaction probes, and check for rule audits. A passing file covers its asserted host-supported flows; verify launch, requested visuals, and native-only behavior on a simulator. Read rn-a11y-tree skill once for the recommended agent workflow.

// a11y/notes.a11y.test.ts
import {test, expect, renderRoute, screen, user} from 'react-native-a11y-tree/test';

test('should create a note', async () => {
  await renderRoute('/', {fixtures: 'expo'});
  await user.press(screen.getByRole('button', {name: 'New Note'}));
  await user.type(await screen.findByTestId('note-body-input'), 'Groceries');
  await user.back();
  expect(await screen.findAllByTestId(/^note-row-/)).toHaveLength(1);
  expect(screen.getByTestId('note-row-0')).toHaveTextContent('Groceries');
});

For a standalone component, use await render('src/App.tsx'). Metro loads the component file in Hermes. Both render functions accept existing project options such as projectRoot, preset, platform, setup, network, networkFile, failOnFallback, and allowFallback. Device settings come from a11y-tree.json; without configured device settings the test API uses android-phone.

  • Queries: getBy*, queryBy*, findBy*, and their *All* variants for TestId, Role (including {name}), Text, and LabelText. Singular queries reject duplicate matches. Hidden accessibility elements and inactive stack screens are excluded unless includeHiddenElements: true is supplied.
  • Actions: awaited user.press, longPress, type, clear, scroll, swipe, and back. type replaces the input value; scroll accepts {x, y} and swipe accepts the existing pan fields {dx, dy, steps, durationMs}. back uses Expo Router and rejects attempts to pop its root. Covered presses fail before dispatching to the covering view.
  • Matchers register automatically: toHaveTextContent, toHaveAccessibleName, toBeVisible, toBeDisabled, toBeChecked, toBeSelected, and toHaveTouchTarget(44) (dimensions in dp). Text content and accessible names are checked separately. Visibility checks accessibility hiding, display, and opacity; press actions check native hit-testing.
  • findBy* advances host timers and refreshes the tree while waiting. Its default timeout is 2 seconds; the third argument accepts {timeout, interval}. screen.debug() prints the compact tree on demand.

Tests run sequentially within each file. Each render starts a fresh host, resetting app modules, router state, timers, and in-memory storage; bundles use the existing cache. Vitest mocks affect Node test code; use setup for fixtures loaded into Hermes. Queries return host tree nodes rather than React TestInstances.

The command discovers *.a11y.test.ts, .tsx, .js, and .jsx, runs once, forwards Vitest flags, and preserves its exit status. Run these tests with rn-a11y-tree test; the package supplies the tested runner and configuration. Import Vitest's test, it, describe, expect, and setup/teardown hooks from react-native-a11y-tree/test alongside the host helpers. No separate Vitest install or configuration is needed. Use the simulator for native-only behavior and visual appearance. Passing tests establish behavior supported by the host and selected fixtures.

CLI reference

The file must have a default export or an App named export that is a React component. Metro's project root is the directory of the nearest package.json above the file, so the project's own dependencies resolve. react and react-native resolve from that project first. stdout has only the result; see Errors and exit codes for failures.

| Command | What it does | Output | | --- | --- | --- | | render <file> | Render once and print the tree | {viewport, source, root} (schema) | | run <file> --script <json> | Render, run the actions, print steps and trees | {viewport, source, steps, snapshots, final, fallbacks, capabilities} (Interactions) | | session <file> | Render, then serve JSON-line requests on stdin | one JSON object per line (Session mode) | | test [files] | Run flow tests with Vitest and the headless host | Vitest reporter output and exit status | | skill | Print the bundled guidance for an agent to read once | Skill Markdown on stdout | | check <file> --rules <json> | Render (or run --script), then evaluate accessibility and design-token rules; exit 2 on violations | {ok, summary, nodes, violations} (Check) | | schema [name] | Print schema/<name>.json (e.g. script, rules-file, session-request); no name: list the schemas | JSON Schema, or name<TAB>description lines (Schemas) |

| Option | Commands | Default | Description | | --- | --- | --- | --- | | --preset <name> | all | none | Device preset: android-phone, ios-phone, android-tablet, ios-tablet (see Presets) | | --platform <name> | all | required unless a preset or a11y-tree.json sets it | Metro platform: android, ios, a11ytree, or any Metro platform name (see Platform) | | --width <dp> | all | preset, else 390 | Viewport width | | --height <dp> | all | preset, else 844 | Viewport height | | --header-height <dp> | all | preset, else 44 for --platform ios, else 56 (host) | react-native-screens native header height | | --safe-area-insets <t,l,r,b> | all | preset, else 0,0,0,0 | react-native-safe-area-context insets, e.g. 47,0,0,34 | | --scale <n> | all | preset, else 3 | Device pixel ratio: PixelRatio.get() and the scale of Dimensions / useWindowDimensions() | | --font-scale <n> | all | preset (1), else 1 | PixelRatio.getFontScale(), fontScale of Dimensions, and the text size multiplier (<Text> without allowFontScaling={false}) | | --tz <zone> | all | UTC | Time zone of the app: the host runs with TZ=<zone> (IANA name like America/Los_Angeles, or POSIX like JST-9), so date strings do not depend on the machine's time zone. On Windows only POSIX values work (UTC, JST-9, PST8PDT; the MSVC runtime does not read IANA names): a name with / prints a warning (unless --quiet) | | --no-mounted | all | mounted on | Do not read mounted-view values (getA11yTree includeMountedProps; used for visualBox, effectiveOpacity) | | --timing | all | off | Print phase timings as JSON on stderr (see docs/perf-analysis.md) | | --reset-cache | all | off | Ignore Metro's caches and the bundle cache (cold bundle) | | --no-cache | all | cache on | Do not use the bundle cache (always run Metro) | | --bytecode <mode> | all | auto | Hermes bytecode: auto (use it when cached; compile in the background after a build), on (compile now), off | | --dev | all | off | Development bundle (__DEV__ = true) | | --keep-bundle | all | off | Keep the bundle and print its path to stderr | | -v, --verbose | all | off | Metro progress, host glog and console output on stderr | | -q, --quiet / --no-quiet | all | quiet when stdout is not a terminal | Suppress ordinary app console output and CLI warnings; native fallback warnings still print | | --no-stderr | all | off | Suppress all CLI stderr output, including fallback warnings, errors, verbose logs and timings; preserve stdout diagnostics and exit codes | | --out <file> | render, run | stdout | Write the output to a file | | --format <f> | render, run | text when piped, else json | json, compact (no defaults/empties, no style), text (one line per node), ndjson (one node per line) | | --router --route <path> | render, run, check | off, / | Load an Expo Router app directly from the project root | | --network <mode> | render, run, check, session | replay if a recording exists, else live | live, off, record, or replay for fetch, expo/fetch, and XMLHttpRequest | | --network-file <file> | render, run, check, session | a11y-tree.network.json | Record or replay network responses from this file | | --fixtures expo | render, run, check, session | off | In-memory AsyncStorage and labeled WebView/map placeholders, with fallback diagnostics | | --select <sel> | render, run | all | Only nodes matching field=value or field~text (fields: testID, role, name, type, key, ref, sel, text); repeat to AND. Matches only, unless --depth | | --depth <n> | render, run | all | Levels of children below each output root (0 = node only) | | --subtree <sel> | render, run | root | Start the output at the first node matching the selector | | --raw, --all-screens, --diagnostics | render, run | off | Show layout wrappers, covered stack screens, or detailed diagnostics in text output | | --final | run | off | Print the final tree even when the script contains snapshots | | --style | render, run | off | Keep style in compact/ndjson | | --bundle-only | render, run | off | Build the bundle and stop (no host needed) | | --debug-props | render | off | Add raw host debug props to each node (debugProps) | | --script <json> | run, check | required for run | Script file {"$schema": ..., "actions": [...]} or [...], or that JSON itself (a value that starts with [ or {). check: run them, then check the final tree | | --tap-mode <mode> | run, session, check | touch | Events for taps: touch (responder touches), click, or both | | --rules <json> | check | rules in a11y-tree.json | Rules file {"rules": {...}}, or that JSON itself (a value that starts with {) (see Check) | | --diff | run | off | Add diff: {added, removed, changed} (by key) to each step | | --timeout <ms> | session | 30000 | Host-frame, output-write and graceful-shutdown timeout; on timeout the host is killed and the exit code is 5 |

Presets and a11y-tree.json

A preset sets the platform, viewport, safe area insets, header height and device scale:

| Preset | Platform | Viewport | Insets (t,l,r,b) | Header | Scale | | --- | --- | --- | --- | --- | --- | | android-phone | android | 412x915 (Pixel 8) | 24,0,0,0 | 56 | 3 | | ios-phone | ios | 393x852 (iPhone 15/16) | 59,0,0,34 | 44 | 3 | | android-tablet | android | 800x1280 (Pixel Tablet, portrait) | 24,0,0,0 | 64 | 2 | | ios-tablet | ios | 834x1194 (iPad 11", portrait) | 24,0,0,20 | 50 | 2 |

rn-a11y-tree render App.tsx --preset android-phone
rn-a11y-tree render App.tsx --preset ios-phone --platform android   # iPhone size, Android components

Dimensions (window and screen) and PixelRatio follow the viewport, scale and fontScale: the bundle calls NativeFantom.setDeviceMetrics before the app module loads (apps often read Dimensions at import time). Hosts without it (no deviceMetrics capability) report 1280x720, scale 0.

An optional a11y-tree.json in the project root (the directory of the app file's nearest package.json) holds defaults for the project. Allowed keys: preset, platform, width, height, safeAreaInsets ({top, left, right, bottom}), headerHeight, scale, fontScale, tapMode, format, rules. Unknown keys and wrong types are usage errors (exit 1).

{"$schema": "./node_modules/react-native-a11y-tree/schema/a11y-tree-config.json", "preset": "android-phone", "format": "text"}

For each setting the first value found wins: command-line flag, a11y-tree.json, preset (from --preset, else the config's preset), built-in default. --platform therefore overrides the preset's platform. Note that the config's own values also override a --preset given on the command line.

Check

check renders the component (or runs --script and uses the final tree), evaluates rules on the tree and prints the result. The exit code is 0 when all checks pass and 2 when there are violations. --format text prints one line per violation; --subtree <sel> checks only one part of the screen.

rn-a11y-tree check examples/basic/App.tsx --platform android --rules examples/basic/rules-fail.json --format text
# FAIL touchTarget email height: touch target height 36.333 < 48
# FAIL touchTarget remember height: touch target height 31 < 48
# FAIL tokens submit backgroundColor: backgroundColor #1e6fff is not a token color
# FAIL contrast submit/Paragraph:1 contrast: contrast 4.4:1 < 4.5:1 (#ffffff on #1e6fff)
# failed: 4 violation(s), 7 of 8 nodes checked

Rules file (the rules object can also be in a11y-tree.json, which check uses when there is no --rules):

{
  "$schema": "./node_modules/react-native-a11y-tree/schema/rules-file.json",
  "rules": {
    "names": true,
    "touchTarget": {"min": 48, "ignore": ["testID=remember"]},
    "hiddenFocusable": true,
    "contrast": {"min": 4.5, "minLarge": 3},
    "tokens": {
      "colors": {"background": "#ffffff", "text": "#000000", "primary": "#1e6fff"},
      "spacing": 8,
      "fonts": ["System"],
      "fontSizes": [14, 16, 28]
    }
  }
}

| Rule | Nodes | Passes when | | --- | --- | --- | | names | Focusable nodes (interactive role, or accessible: true) and images, not inside an accessible ancestor, not hidden | name is not empty. For textbox, the placeholder counts (from: "placeholder") | | touchTarget (min, default 48) | Nodes with an interactive role, not grouped, not hidden | box.width >= min and box.height >= min | | hiddenFocusable | Focusable nodes | Not hidden from screen readers. Hidden = own a11y.hidden, or an ancestor with importantForAccessibility: "no-hide-descendants" / accessibilityElementsHidden ("no" hides only the node itself) | | contrast (min, default 4.5; minLarge) | Nodes with text and a color (not TextInput), not hidden | WCAG 2.x ratio of color (composited over the background) to the background >= min. Large text (>= 24 dp, or >= 18.66 dp bold) uses minLarge when set | | tokens.colors (array, or name -> color) | color, backgroundColor, borderColors (not transparent) | The color is in the list (alpha to 0.01) | | tokens.spacing (grid step, or array) | Numeric non-zero margin*, padding*, gap, rowGap, columnGap | Multiple of the step, or in the array | | tokens.fonts | Paragraphs with text | fontFamily is in the list ("System" = no fontFamily) | | tokens.fontSizes | Paragraphs with text and a fontSize | fontSize is in the list |

Every rule object accepts ignore: [selector, ...] (the --select syntax). The contrast background is style.effectiveBackground from the host (the ancestors' backgrounds composited over the white window) when present, else the CLI composites the ancestors' backgroundColor values over white (bgFrom: "host" or "ancestors"). Opacity (effectiveOpacity), images and gradients behind text are not taken into account.

Output:

{
  "ok": false,
  "viewport": {"width": 390, "height": 844},
  "source": "shadowTree",
  "summary": {"nodes": 8, "checked": 7, "violations": 4, "byRule": {"touchTarget": 2, "tokens": 1, "contrast": 1}},
  // Every node with at least one evaluated property (some props left out here).
  "nodes": [
    {"ref": "n6", "key": "submit", "sel": "#submit", "props": {
      "width": {"rule": "touchTarget", "got": 342, "want": 48, "op": ">=", "pass": true},
      "backgroundColor": {"rule": "tokens", "got": "#1e6fff", "pass": false,
        "wantToken": ["background", "text", "primary"], "wantResolved": ["#ffffff", "#000000", "#0055d4"]}
    }},
    {"ref": "n7", "key": "submit/Paragraph:1", "sel": "RootView>View>View>Paragraph", "props": {
      "contrast": {"rule": "contrast", "got": 4.4, "want": 4.5, "op": ">=", "pass": false,
        "fg": "#ffffff", "bg": "#1e6fff", "bgFrom": "host"}
    }}
  ],
  "violations": [
    {"rule": "contrast", "key": "submit/Paragraph:1", "sel": "RootView>View>View>Paragraph", "prop": "contrast",
     "expected": ">= 4.5", "actual": 4.4, "message": "contrast 4.4:1 < 4.5:1 (#ffffff on #1e6fff)"}
  ]
}

A passing token color has token: <name>. With --script, each failed step adds a violation with rule: "step" and key: "step:<index>".

Schemas and tool descriptors

  • schema/*.json: JSON Schema (draft-07) for the outputs and input files. They are generated from the types in src/schema.ts with ts-json-schema-generator (bun run schema; bun run check fails when they are out of date). Objects do not allow unknown keys. rn-a11y-tree schema <name> prints one (the installed copy); input files point to theirs with $schema, e.g. "$schema": "./node_modules/react-native-a11y-tree/schema/script.json" (the examples use ../../packages/react-native-a11y-tree/schema/*.json).

    | File | Type | | --- | --- | | render-result.json | render (--format json) | | query-result.json | render --select (--format json) | | run-result.json | run (--format json, no --select/--subtree/--depth) | | check-result.json | check (--format json) | | error-output.json | {"error": {code, message, hint?, details?}} | | session-request.json / session-output-line.json | session stdin / stdout lines | | script.json | --script files ({$schema?, actions} or an array) | | rules-file.json | --rules files | | a11y-tree-config.json | a11y-tree.json |

  • tools/*.json: MCP-style tool descriptors {name, description, inputSchema, outputSchema, examples: [{input, argv}], x-cli} for doctor, render, query, act (run), diff (run --diff), check and session (with x-protocol for the stdin/stdout lines). Input types are in src/tools.ts; toolArgv(name, input) maps an input to CLI arguments (actions and rules are passed inline as JSON). Every tool accepts noStderr: true to suppress CLI stderr while retaining structured output. Example:

    rn-a11y-tree run examples/basic/App.tsx --platform android \
      --script '[{"type":{"testID":"email","text":"[email protected]"}},{"tap":{"testID":"submit"}}]' --format compact

test/schema.test.ts validates CLI output (fake host), the example scripts and rules files, and each tool example (input, argv, and the output of the argv) against these files. e2e/schema.test.ts validates the real host output of every example.

Caching

The CLI is one-shot (no daemon). To make repeated runs fast:

  • Bundle cache. A finished bundle is stored under a key made of the generated entry (app path, options, script) and the tool versions. It is valid while every module file in Metro's dependency graph, and every directory that holds one, keeps its mtime and size (a new file in a module directory could change resolution). A valid entry skips Metro completely.
  • Hermes bytecode. After a build, hermesc from hermes-compiler compiles the bundle in the background (about 2 s for the medium example); the next run of the unchanged app loads the bytecode. If the host cannot load it, the CLI deletes it and uses the JS bundle.
  • Metro caches. Transforms and the file map are kept in the same cache directory, with a fixed entry directory so the file map cache key stays the same.
  • Location: <project>/node_modules/.cache/rn-a11y-tree when the project has node_modules, else ~/.cache/rn-a11y-tree (RN_A11Y_TREE_CACHE_DIR overrides).

Medium example, Release host, median of 5 (render --timing):

| Case | Wall | Metro | Host | | --- | --- | --- | --- | | Unchanged app, cache hit, bytecode | 191 ms | 5 ms | 119 ms | | Unchanged app, cache hit, JS (--bytecode off) | 437 ms | 5 ms | 358 ms | | One-line change (Metro with warm caches, --bytecode off) | 1404 ms | 932 ms | 358 ms | | Bundle cache off (--no-cache), warm Metro, --bytecode off | 1269 ms | 833 ms | 344 ms | | Cold (--reset-cache) | 5440 ms | 4882 ms | 396 ms |

(Before caching: 1.3 s warm, Metro 0.86 s, bundle eval 150 ms; with bytecode bundle eval is 13 ms.) The two --bytecode off rows are medians of two alternating series of 5 on a shared machine (±80 ms between series).

A one-line change costs about 100 ms more than a warm --no-cache build: the changed file is transformed, and the first Babel transform in a new process loads babel-preset-expo (about 150 ms; the next transform takes 20 ms). With at most 8 changed inputs, Metro transforms in the CLI process instead of starting worker processes (saves about 85 ms of worker start and stop), and the bundle cache takes its file list from the serializer instead of getOrderedDependencyPaths (which builds the graph a second time, about 55 ms). Measure with --bytecode off: in auto mode the background hermesc after a build competes with the next run for CPU.

Errors and exit codes

Errors are {"error": {code, message, hint?, details?}}: on stdout with an explicit --format json or --format ndjson (one line for NDJSON), else on stderr (one JSON line when stderr is not a terminal or with --quiet, a readable message otherwise). --no-stderr suppresses stderr entirely; use an explicit machine format to retain error details on stdout. Exit codes are unchanged. For example:

rn-a11y-tree render App.tsx --preset android-phone --format ndjson --no-stderr

Fallback diagnostics remain in stdout, including the NDJSON diagnostics record. --no-stderr overrides --verbose, --no-quiet and --timing; it does not change --fail-on-fallback policy.

| Exit | Codes | Meaning | | --- | --- | --- | | 0 | | ok (step errors inside a run are reported in the steps) | | 1 | USAGE | bad arguments, script, selector, unknown option | | 2 | CHECK_FAILED | check found violations | | 3 | BUNDLE_FAILED | Metro failed (syntax error, missing import) | | 4 | APP_THREW | the app threw while loading or rendering (details.stack) | | 5 | HOST_MISSING, HOST_UNAVAILABLE, HOST_INCOMPATIBLE, HOST_CRASHED, TIMEOUT | host problems (details.stderrTail) | | 6 | UNSUPPORTED_NATIVE | --fail-on-fallback rejected an observed native/runtime limitation |

Uncaught React render/effect failures are captured from the renderer and reported as APP_THREW; they cannot silently produce an empty successful tree. Errors handled by an application Error Boundary still render its fallback normally. A plain console.error is a log, not an uncaught render exception.

  • Step errors are {code, message} with TARGET_NOT_FOUND, TARGET_COVERED, TIMEOUT or APP_THREW; session error responses use the same object (USAGE for invalid requests).
  • App console output is returned in logs: [{level, message, known?}] (render/run output, and each session response). known: true marks common noise (getViewManagerConfig('RNCMaskedView'), deprecation warnings). With --no-quiet, errors and warnings that are not known noise are also printed on stderr.

Observed native limitations and CI policy

Successful renders are not proof of native equivalence. diagnostics reports observed native component substitutions, module adapters (including core TurboModule stubs), unsupported adapter API calls, and action-runner fallbacks. Each entry has {code, target, message}. Entries are deduplicated by code and target. An empty list does not certify device behavior: platform emulation and unexercised APIs still need device validation. See Expo support.

Diagnostics survive --select (including no matches), --subtree, --depth, and compact output. Text output prints warning lines; render/run NDJSON can include an envelope {diagnostics: [...]} before node/step lines. Session ready lines and responses report cumulative observed limitations, so querying later cannot hide an earlier fallback. App logs remain available separately.

The default stays permissive for exploration. Opt in to a CI policy:

rn-a11y-tree render App.tsx --preset android-phone --format json --fail-on-fallback
# After reviewing the reported limitations, allow only the intentional ones:
rn-a11y-tree render App.tsx --preset android-phone --format compact \
  --fail-on-fallback --allow-fallback ExpoImage --allow-fallback StatusBarManager

--allow-fallback is repeatable and matches exact target names; there are no wildcards. Allowed limitations remain in diagnostics. Unsupported adapter API calls cannot be allowed, even when app code catches the exception. Model those operations explicitly in an application fixture instead. The flags work with render, run, check, session, and generated agent tool descriptors.

A policy rejection emits UNSUPPORTED_NATIVE with the rejected diagnostics in error.details and exits 6. A strict session refuses startup or ends after the first request observing an unapproved limitation. This is result validation, not an execution sandbox: a one-shot script runs before its result is checked. Existing app/host failures retain their error codes. --bundle-only does not execute native code and therefore does not evaluate this policy.

Fresh-process agent loop

Use render, run --script, or check after each edit. No Metro server or daemon is required. Finished bundles skip Metro when valid; source edits reuse Metro's disk transform cache. Bundle keys include project-resolved tooling, ancestor package manifests/lockfiles/Babel configs, dotenv files, and NODE_ENV, BABEL_ENV, and EXPO_PUBLIC_* values. Build-input changes also invalidate Metro transforms. Each rebuilt bundle has a unique bytecode output, so a background compiler finishing after another edit cannot replace the current bytecode. Arbitrary files/environment variables read by custom Babel plugins are not automatically tracked; use --reset-cache for those changes. require.context directories are tracked even when empty, including recursive subdirectories. Adding, renaming, or deleting a matching route invalidates the finished bundle; ordinary apps without context imports do not scan directory trees. If publishing a finished cache entry fails (for example, a Windows file lock), the invocation uses its fresh temporary JavaScript bundle without cached bytecode. Verbose output reports the skipped publication. Before execution, cached JS and any selected bytecode are copied into a private temporary snapshot. The cache generation and inputs are rechecked after copying; a racing replacement becomes a miss. Later cache replacement or eviction cannot change that invocation's artifacts. A failed bytecode load attempts to discard only its selected generation; a locked cache file does not prevent the JS retry. Snapshots are removed after the invocation unless --keep-bundle or --bundle-only retains them. These snapshots do not make concurrent edits to the application's sources an atomic transaction.

Measure cold startup, unchanged invocations, and actual source edits separately:

RN_A11Y_HOST_BIN=/path/to/rn-a11y-host bun scripts/agent-loop.ts \
  --app examples/medium/App.tsx --iterations 5 --out /tmp/agent-loop.json

The benchmark creates a disposable wrapper next to the app, verifies a visible revision after each edit, checks expected cache hits/misses, and removes the wrapper afterward. It never changes the original app. Every sample starts a new CLI process. The report includes per-phase timings, wall time and output bytes; --bytecode off can isolate JS-only performance. It measures the selected screen, not an agent's complete task or device fidelity.

Formats and queries

For agents, --format and the query options make the output small:

rn-a11y-tree render App.tsx --platform android --format text
# n0 RootView {0,0,390x844}
#   n1 View {0,0,390x844}
#     n2 Paragraph role=header "Sign in" {24,24,342x33.3}
#     n5 AndroidSwitch #remember role=switch "Remember me" {24,205.7,51x31} [checked]
#     n6 View #submit role=button "Submit" {24,252.7,342x48}
rn-a11y-tree render App.tsx --platform android --format text --select role=button
rn-a11y-tree render App.tsx --platform android --format compact --subtree testID=card-3 --depth 1
  • text: <key> <type> [#testID] [role=…] ["name"] {x,y,wxh} [visual={…}] [flags]; flags: hidden, disabled, checked, mixed, selected, virtual, opacity=….
  • compact: one-line JSON without null/false/empty fields, a11y.raw and style (--style keeps style, minus layoutDirection: "ltr").
  • ndjson: one compact node per line with depth and parent; for run, one line per step first, then nodes tagged with tree (snapshot name or final).
  • run --format text: one line per step, then each snapshot and the final changes. Passing scripts with assertions and no snapshots print one summary line; --final prints the final tree and --diff prints step changes.
  • Session tree and snapshot requests take the same format, select (string or list), depth, subtree, style fields; text/ndjson trees are returned as a string.

Output size for the medium example (832 nodes): json 1,598,904 bytes, ndjson 246,853, compact 230,446, text 60,844.

Output schema

TypeScript types: src/schema.ts.

{
  "viewport": {"width": 390, "height": 844},
  "source": "shadowTree",      // or "mounted", see "Tree sources"
  "root": {
    "ref": "n0",               // pre-order id within this tree (changes when the tree changes)
    "key": "RootView",         // stable: testID, else <parent key>/<type>:<n>; root = its type
    "type": "RootView",        // host component name from the shadow tree
    "sel": "RootView",         // "#testID" or a type path, e.g. "RootView>View>Paragraph:2"
    "role": null,              // role, else accessibilityRole, else implicit (Paragraph/Text: "text", Image: "image",
                               // Switch: "switch", TextInput: "textbox"); "none"/"presentation" -> null
    "name": null,              // accessibilityLabel, else own text, else descendant text if accessible
    "a11y": {
      "accessible": true,      // only when reported
      "label": "...",
      "hint": "...",
      "state": {"disabled": false, "selected": false, "checked": true, "busy": false, "expanded": true},
      "hidden": true,          // importantForAccessibility no/no-hide-descendants, accessibilityElementsHidden, aria-hidden
      "raw": {"accessibilityRole": "button"}  // accessibility props as reported (strings)
    },
    "box": {"x": 0, "y": 0, "width": 390, "height": 844},  // on screen, dp (ShadowTree layout)
    "visualBox": {...},        // only if a transform/mounted frame applies: where it is drawn
    "effectiveOpacity": 0.5,   // only if < 1: own x ancestors' opacity (mounted opacity if reported)
    "style": {"backgroundColor": "rgba(255, 255, 255, 1)"}, // other props: visual (incl. effectiveBackground), Yoga style, font, component props
    "text": null,              // text content of Paragraph / Text fragment / TextInput nodes
    "testID": null,
    "virtual": true,           // only on nodes without their own frame (box = parent's box)
    "debugProps": {},          // only with --debug-props (shadowTree source)
    "children": []
  }
}

Tree sources

The entry uses NativeFantom.getA11yTree(surfaceId, includeDebugProps) when the host implements it, else NativeFantom.getRenderedOutput.

  • shadowTree (getA11yTree, in the host built by bun run build:host): the committed ShadowTree. The hierarchy is complete (no view flattening), values are typed (numbers, booleans), and role, accessibilityValue and text fragments are available. Mapping tables are at the top of the shadowTree section in src/tree.ts. Notes:
    • Children are placed at parent position + parent contentOriginOffset
      • child frame. The host emits contentOriginOffset where it is non-zero: ScrollView -contentOffset, RNSScreen (0, topInset + headerHeight). So box is the position on screen (e.g. an RNSScreenStackHeaderConfig frame of {0,-56,390,56} inside a screen with offset 56 is at y = 0).
    • RNSScreenStackHeaderConfig gets its title as name (role stays null). Screen and header props (activityState, stackPresentation, title, hidden, ...) and safe-area insets are in style.
    • visualBox: box is the layout. When the node or an ancestor has a non-identity transform (4x4 matrix in style.transform), or the host reports a different mounted frame, visualBox is the axis-aligned bounding box of the drawn frame after the transforms. Like React Native, a transform applies about the view's center; ancestors' transforms compose. style.mounted has the mounted-view values that differ from the ShadowNode (e.g. during a Reanimated entering animation); they are used for visualBox and effectiveOpacity.
    • box values are rounded to 1/1000 dp (layout is pixel-snapped, which leaves float noise such as 63.99999).
    • yogaStyle edge and gutter objects are flattened to React Native style names: padding: {all: 24, top: 8} becomes padding: 24, paddingTop: 8 (same for margin; border -> borderWidth, borderTopWidth, ...; position -> inset, insetInline, insetBlock, left, top, ...; gap -> gap, rowGap, columnGap). Other Yoga keys keep their Yoga names (for example positionType).
    • A Paragraph's RawText children are dropped (the text is in text). Nested <Text> spans are dropped unless they have accessibilityLabel, role, accessibilityRole, accessible or testID; kept spans have no frame of their own, so they get the Paragraph's box and "virtual": true. Any other node without a frame is handled the same way.
  • mounted (getRenderedOutput, upstream Fantom): the mounted view tree. Notes:
    • Props are React Native debug-string props (getDebugProps): only non-default props, all values are strings. The host must be built with RN_DEBUG_STRING_CONVERTIBLE=1; without it there are no props and no boxes.
    • Fabric view flattening applies, the same as on iOS/Android:
      • A <View> with only layout styles is removed from the tree. Use collapsable={false} to keep it.
      • A <View> that draws something (for example backgroundColor) but does not form a stacking context is kept, but its children are moved up to the nearest ancestor that forms a stacking context. They appear as siblings that come after the View. Their frames are relative to their new parent, so box is still correct.
    • <Text> renders as a Paragraph host node. Nested <Text> spans appear as Text child nodes without their own layout; they get the Paragraph's box.
    • The role prop is not in the debug props (only accessibilityRole is), so role="..." without accessibilityRole gives role: null.

Interactions

bun run rn-a11y-tree run examples/basic/App.tsx --platform android --script examples/basic/actions.json

run <file> --platform <p> --script <json> [--tap-mode touch|click|both] renders the component, runs the actions in order, and prints:

{
  "viewport": {"width": 390, "height": 844},
  "source": "shadowTree",
  "steps": [
    {
      "index": 2, "action": "tap",
      "target": {"tag": 16, "ref": "n7", "testID": "submit", "type": "View", "box": {...}},
      "hit": {"tag": 14, "ref": "n8", "testID": null, "type": "Paragraph", "box": {...}, "viaHitSlop": false},
      "events": ["touchStart", "touchEnd"],
      "via": {"hitTest": "js", "events": "js"},   // host methods ("native") or the JS fallback
      "warnings": ["..."],                        // optional, e.g. the target is covered
      "error": "..."                              // optional; later steps still run
    }
  ],
  "snapshots": {"after-submit": { /* tree, same schema as render's root */ }},
  "final": { /* tree after the last step */ }
}

The script is a JSON object with $schema (so editors and agents find the schema) and actions, or a bare array of actions:

{
  "$schema": "./node_modules/react-native-a11y-tree/schema/script.json",
  "actions": [
    {"type": {"testID": "email", "text": "[email protected]"}},
    {"tap": {"testID": "submit"}},
    {"snapshot": "after-submit"}
  ]
}

$schema is a path relative to the file (the examples use ../../packages/react-native-a11y-tree/schema/script.json) or a URL; the CLI ignores its value. The script is validated before bundling; errors name the step index. run --help, check --help and session --help list every action; rn-a11y-tree schema script prints the full schema.

| Action | Form | Events | | --- | --- | --- | | tap | {"x":..,"y":..}, {"testID":".."} or {"ref":"n5"} | touchStart, touchEnd to the hit node (--tap-mode touch, default); click (click); both (both). On a Switch: change {value: !value} instead. | | longPress | same as tap | touchStart, wait 600, touchEnd | | type | {"testID":"..","text":"..","submit":false} | focus; per character keyPress {key} and change {text, eventCount}; submitEditing if submit; endEditing, blur | | scroll | {"testID":"..","x":0,"y":300} | one scroll event on a ScrollView (zoomScale: 1), which also updates the ScrollView's state | | pan | {"testID":"..","dx":100,"dy":0,"steps":10,"durationMs":200} (or x,y instead of a target) | touchStart, steps x (wait durationMs/steps, touchMove), touchEnd; the same pointer samples go to react-native-gesture-handler | | pinch | {"testID":"..","scale":2,"steps":10,"durationMs":300} | two pointers around the target's center, moved apart/together; react-native-gesture-handler only (no multi-touch responder events yet) | | wait | milliseconds | in 16.333 ms slices (the host's frame length): produceFramesForDuration (stub clock + one UI tick, which drives C++ animation backends), mocked JS timers, work loop, queued native events | | snapshot | name | stores the tree at this point |

Rules:

  • Targets: testID, key, sel or ref. key is stable (the testID, else <parent key>/<type>:<n>, n = index among siblings of the same type); ref is resolved against the tree at the time of the step and changes when the UI changes.
  • Diffs: run --diff, or "diff": true on a session action request, adds diff: {added, removed, changed: [{key, before, after}]} to the step: nodes matched by key; compared fields box, visualBox, text, name, role, state, hidden, effectiveOpacity. --format text prints them under the step as + key …, - key …, ~ key field: a -> b. Unkeyed nodes after an inserted sibling of the same type get new keys, so give important nodes a testID.
  • Target taps hit-test at the center of the target's box. If the hit node is not the target or inside it, the step gets a "Target is covered" warning. Touch events go to the hit node (the responder system bubbles them). click goes to the target, because it does not bubble from a child to a Pressable in this host.
  • The host hitTest honors pointerEvents, transforms, overflow clipping, ScrollView offsets, zIndex, display: none and hitSlop; viaHitSlop is true when the point was only inside the hitSlop area.
  • Touch payloads: touchStart {touches, changedTouches, targetTouches} and touchEnd {touches: [], changedTouches, targetTouches: []}, each touch {pageX, pageY, locationX, locationY, screenX, screenY, identifier: 0, target, timestamp, force: 1}.
  • type: for each character, keyPress, then setTextInputTextByTag (the input's ShadowTree state, so text in the tree changes), then change.
  • Timers are mocked during the script (Fantom.installTimerMock), so wait and longPress are deterministic.
  • Host methods: hitTest, enqueueNativeEventByTag, enqueueScrollEventByTag and setTextInputTextByTag are used when the host has them. Without them, the runner hit-tests in JS over the getA11yTree boxes (deepest node, later siblings on top, pointerEvents honored; zIndex, transforms and clipping ignored) and sends events with Fantom's enqueueNativeEvent / enqueueScrollEvent to the element found by tag in root.document. Without setTextInputTextByTag, the input's own text in the tree does not change (the app's state does); the step gets a warning.
  • When any JS fallback is used, the CLI prints one warning line on stderr, for example warning: JS fallbacks used because the host lacks native methods: events: js, hitTest: js, scrollOffset: dom. The payload also lists them in fallbacks. Host methods are always used when present.
  • After the initial render and after every step, queued native events are delivered (flushEventQueue + work loop) until the host's shadow tree and mounted revisions (getShadowTreeRevision / getMountedRevision) stop changing (at most 10 rounds; hosts without them: until the tree dump stops changing). Fabric emits onLayout into the event queue from a commit hook; without this, onLayout never reaches JS and FlatList cannot compute its window. This also applies to render.
  • Scrolling: ShadowTree frames do not move when a ScrollView scrolls (the offset is in the ScrollView's state). box values are on-screen positions (see contentOriginOffset), and the JS hit test uses the same positions. For hosts that report contentOffset from props only, the runner reads the state offset through the DOM API (element.scrollTop / scrollLeft) (fallback scrollOffset: dom).
  • run needs a host with getA11yTree. The output's capabilities lists the optional host methods found plus NativeFantom.getCapabilities() (e.g. getA11yTree.mounted); session mode reports it in the ready line.

Examples: examples/basic/actions.json (typing, Switch, Pressable) and examples/scrolling/actions.json (ScrollView offset, tap after scroll, FlatList windowing).

Expo UI

@expo/ui views render as Fabric views named ViewManagerAdapter_ExpoUI_<View> (see native/README.md "Expo UI"). The tree type is ExpoUI.<View>: Compose names with the universal @expo/ui entry and --platform android (HostView, ColumnView, TextView, Button, SwitchView, ...), SwiftUI names with @expo/ui/swift-ui on any platform (VStackView, ToggleView, ...).

rn-a11y-tree run examples/expo-ui/App.tsx --platform android --script examples/expo-ui/actions.json --format text

JS load path. When the project's package.json lists expo, expo-modules-core or @expo/ui and expo-modules-core resolves from the project, the entry runs, before the app module:

  1. installExpoGlobalPolyfill() from expo-modules-core/src/polyfill/dangerous-internal (the project's copy): globalThis.expo with EventEmitter, NativeModule, SharedObject, modules.
  2. runtime/expo/prelude.ts: globalThis.expo.getViewConfig(module, view) from runtime/expo/viewConfigs.json (152 views, iOS and Android attributes and events merged; views not in the table get the union of all @expo/ui prop and event names; children, key, ref, style are left out), and module stubs ExpoUI, ExpoAsset, ExponentConstants / ExpoConstants.

Other projects get none of this (the basic example bundle has no Expo code). Listing the package is required because in a hoisted monorepo every project resolves expo-modules-core. bun scripts/gen-expo-view-configs.ts regenerates viewConfigs.json from native/tools/expo-view-configs/out/viewConfigs.json and native/tests/fantomExpoUIViewConfig.json. The Fantom tests' dev-bundle workaround (NativeSourceCode scriptURL: null) is not included: only __DEV__ bundles open the dev-server socket, so --dev bundles of Expo apps may need it.

Tree. ExpoUI nodes have:

  • expo: the props the native view received (modifiers verbatim; modifier callbacks are "eventListener": null);
  • layout: emulated when a SwiftUI/Compose layout engine laid out the node (the Host and its descendants), placeholder when no engine handled the subtree (the frame is not the drawn one). Hosts built before this label change mark only the Host emulated and its descendants placeholder, even when an engine laid them out;
  • emulatedBy (on the Host): swiftui or compose, the engine that laid out the subtree;
  • role from the view name: Button/*Button → button (ToggleButton → togglebutton, RadioButton → radio), SwitchView/ToggleView → switch, CheckboxView → checkbox, SliderView → adjustable, TextField*/SecureField* → textbox, TextView → text, Image*/Icon* → image, PickerView → radiogroup (pickerStyle segmented, inline, palette) else combobox, ProgressView → progressbar. An explicit role/accessibilityRole wins;
  • text = expo.text (TextView, text fields); name = accessibility label (the host maps the accessibilityLabel modifier), else the label/title prop, else the text; buttons take their descendants' text;
  • a11y.state.checked from value/isOn/checked, disabled from enabled: false/disabled.

Actions (runtime/expo/actions.ts). The event is chosen from the on… callbacks that the JS component passed to the native view (its React props), else from the view name:

| Action | Callback prop | Event sent | | --- | --- | --- | | tap | onButtonPress (SwiftUI Button) | buttonPress {} | | tap | onButtonPressed (Compose buttons) | buttonPressed {} | | tap | onCheckedChange (Compose Switch, Checkbox) | checkedChange {value: !value} | | tap | onIsOnChange (SwiftUI Toggle) | isOnChange {isOn: !isOn} | | type | onTextChange (SwiftUI TextField) | textChange {value} per character | | type | onValueChange (Compose TextField) | valueChange {text, selection} per character |

  • The actionable view is the target (or hit) node or its nearest ancestor inside the Host (under the fake layout, a tap hits the Button's Text child).
  • If that node or an ancestor inside the Host has a tap modifier (onTapGesture, clickable, combinedClickable; long press: onLongPressGesture), the runner also calls NativeFantom.dispatchExpoModifierEvent(tag, type, {}) (step event modifier:<type>). Hosts without it add a warning.
  • A target given by testID/key gets the events even when its box center does not hit it (placeholder frames); the step has a warning then.

Host capabilities. expoUI: Expo module views render. expoModifierEvents: dispatchExpoModifierEvent and Host frames written by the layout emulation. expoUI.fakeLayout: the frames are the fake layout (each child a full-width row, 40 high), not SwiftUI/Compose layout. expoUI.swiftUILayout / expoUI.composeLayout: that engine lays out the Hosts of its kind (@expo/ui/swift-ui views: SwiftUI; Compose views: Compose). e2e/expo-ui.test.ts checks modifier callbacks only with expoModifierEvents, and boxes only with the engine of the screen's kind (Compose: 14sp Text 16 dp high, Button 66x48, Switch 52x48; SwiftUI: body Text 20.333 dp high).

react-native-gesture-handler

The host has no native gesture handler engine. RNGH 3.2.1's native module and native v3 detector are replaced in the bundle (src/bundle.ts RESOLVED_ALIASES, matched on the resolved file path):

| RNGH file | Replaced by | | --- | --- | | src/specs/NativeRNGestureHandlerModule.ts | runtime/gh/NativeRNGestureHandlerModule.ts | | src/v3/detectors/HostGestureDetector.tsx | runtime/gh/HostGestureDetector.tsx |

  • The module implements the 8 spec methods with RNGH's own web classes (src/web/: handlers, GestureHandlerOrchestrator, InteractionManager, NodeManager). Views are HostView objects over the element found by tag (hasAttribute → false, dispatchEvent no-op, bounds from getBoundingClientRect; a display: contents detector uses the union of its children). Input comes from the action runner through a HostEventManager (an RNGH EventManager without DOM listeners).
  • Pointer routing: on DOWN, the handlers attached to the hit view and its ancestors get the pointer (deepest first) if it is inside their view; MOVE and UP go to the same handlers. time comes from the mocked clock, so Tap/LongPress timers, pan slop (15 dp) and velocity behave.
  • Events go back the way the native platforms send them: v2 (GestureDetector with Gesture.*, old handler components) as flat payloads on DeviceEventEmitter onGestureHandlerEvent / onGestureHandlerStateChange (Android); v3 (NativeDetector, e.g. RectButton) as Fabric events gestureHandlerEvent / gestureHandlerStateChange / gestureHandlerTouchEvent on the RNGestureHandlerDetector element.
  • HostGestureDetector.tsx renders the real RNGestureHandlerDetector and attaches each handler to the detector, or, for Native gestures, to its only child (like the Android detector view). A Native handler attached to an RNGestureHandlerButton gets the button role, so it activates on release like on Android.
  • tap, longPress, pan and pinch feed RNGH as well as the responder system; step.gestureHandlers counts the handlers that got the pointer.
  • v2 gestures with worklet callbacks (no .runOnJS(true); action type REANIMATED_WORKLET) and NATIVE_ANIMATED_EVENT: Fabric events gestureHandlerEvent / gestureHandlerStateChange on the attached view (enqueued by tag), with the flat payload, like Android's Reanimated path. Fabric names them topGestureHandler*; Reanimated maps top* to on* and runs the `useEvent(..., ['onGestureHandlerStateChang