Expo / React Native mode
How --expo reads an Expo project, which tasks run and which refuse by name, and why the native unit plane uses jest-expo and renderRouter rather than the DOM suite
What --expo does
cd my-expo-app
bunx flawless-react --expo
It reads the project off disk — the Expo SDK major, the expo-router app/ root, whether the React Compiler is on, whether NativeWind is wired through babel, and the port expo start --web serves on — then defaults the entry glob to the router root and writes to <expoRoot>/flawless/.
Mutually exclusive with --nextjs. A project is one or the other.
React Native is a second element vocabulary
Not a Next.js variant. Four facts drive almost everything below:
<View>is capitalised and imported. The rule that decided what a host element was — lowercase — is wrong here, so a tag is a host element iff the file imports that name from a known React Native specifier. Never guessed from the name.- There are no implicit ARIA roles.
getByRole("button")matches nothing on a bare<Pressable>. - There is no
<form>. classNameis compiled away by babel before anything can observe it.
Every task runs, or refuses by a measured decision
The Expo mode was built one task at a time and is now complete: six of the seven tasks run under --expo, and the seventh refuses for a reason that was measured, not deferred.
| task | under --expo |
|---|---|
unit-browser | runs — jest-expo + @testing-library/react-native |
perf-static | runs |
react-rules | runs |
a11y | runs — the native accessibility analyser (six rules over the parsed tree, not biome) |
e2e | runs — expo-router route specs against expo start --web |
load | runs — API-only crust pipes, harvested through the app’s fetch wrapper |
perf-harness | refuses — measured-and-declined: a browser host cannot mount a route screen, and nearly every component in a real Expo app is one |
A refusal is a failed TaskResult, not a thrown error, so it lands in results[], in the --ci blob and in REPORT.md where a machine can read it. A mixed selection still runs what it can.
Refusing beats emitting. The DOM suite pointed at React Native produces specs that run, pass, and touch nothing: container.querySelector("Pressable") matches no element, and getByRole("button") finds nothing. A green suite that tested nothing is the one outcome this tool must never produce — which is exactly why three tasks needed a different engine rather than a refusal, and got one:
a11yneeded a native engine, not the biome sweep. Biome’s 29 rules are HTML/ARIA rules. Over 31 React Native files carrying 82 unlabelled<Pressable>elements they return zero findings, while the same configuration finds 3 of 3 on the tool’s own DOM offender fixture. So--expo --tasks a11yruns the tool’s own analyser instead — see the native a11y analyser below.- The React Compiler changes what
perf-staticmeans. On a project withexperiments.reactCompiler: true, 136 of 168 findings were the three memoisation rules — work the compiler already performs.--exporeads the flag and surfaces those findings for you to weigh; the tool deliberately does not suppress them for you. perf-harnessis the one honest refusal. Its browser host mounts a component by bundling it and callingcreateRoot— but an expo-router route screen needs a navigator (useNavigation,useLocalSearchParams,useFocusEffect), and on a real Expo app nearly every component is a route screen. Measured ceiling on the dogfood app: ~0 of 32 mountable. Rather than emit specs that redden healthy screens, it refuses and says so. (A Metro-served perf lane that mounts the real app is mapped as a probe-gated option, but stays declined until a probe clears — the React Compiler nulls its one honest signal.)
The native unit plane
--expo --tasks unit-browser writes jest.config.cjs, a write-once jest.setup.ts, and one spec per exported component under <outDir>/unit/, then merges flawless:unit into the app’s package.json.
The version pairing is derived, never guessed
| package | pin | why |
|---|---|---|
jest-expo | your own SDK major | jest-expo@54 pairs with Expo SDK 54 |
jest | ^29.7.0 | jest-expo’s own tooling is ^29.2.1; a bare jest resolves to 30 and fails as clearMocksOnScope is not a function |
@testing-library/react-native | ^13.3.3 | 13, not 14 — expo-router 6’s testing-library is built against react-test-renderer |
When the SDK major cannot be read, jest-expo is left undeclared and the run says so. It never substitutes a number.
Three spec shapes
A route screen mounts through renderRouter at its own URL. Not a preference: useFocusEffect reaches useNavigation(), which throws outside a navigator, and useLocalSearchParams is empty. Inside a real navigator the screen is focused, so focus effects run and the spec can assert loaded state rather than the loading skeleton.
A _layout mounts through its own navigator with one synthesised child — <Tabs> and <Stack> call useContextKey and throw when rendered alone.
Everything else mounts through render().
Navigation intent is a unit assertion
Where the source names a literal route, the spec asserts where it lands:
fireEvent(screen.getAllByText("Add")[0], "press");
expect(screen).toHavePathname("/items/new");
That is the React Native analogue of asserting a link’s href.
No emitted spec touches the network
A module that resolves project-locally and performs fetch() is mocked. Before this existed, the fixture suite made live HTTP calls and crashed a jest worker against a real server.
Each stub’s shape comes from the mocked function’s own Promise<T> annotation, resolved through workspace barrels and zod schemas. Promise<void> mocks to undefined and carries no marker — that is its realistic return value. Only a shape that could not be resolved falls back to {}, and only that one keeps a TODO(flawless).
The fallback is not cosmetic. const { items } = await listBuildings() against {} binds items to undefined, setBuildings(undefined) follows, and the next buildings.length throws into the app’s own error boundary — so the screen renders “Something went wrong” and the failure looks like the component rather than the fixture.
The loaded state gets its own test, where that is fair
The default mock returns empty collections, so every assertion derived from the initial render holds — and nothing exercises the row markup. A separate test re-mocks one function to return a single row and asserts a synthesised witness appears.
It is only emitted where three things hold, each read off the component’s own AST:
- Mounting calls the function — reachable from a
useEffect/useFocusEffectcallback or the component body, and not inside a branch, acatch, or a JSX event handler. A mutation called from a submit handler is not a loader, and a call behindif (isTrustee)does not run. - What it returns is rendered as a list — the result reaches a
useStatesetter whose state variable is mapped in JSX or handed to a list asdata. A collection the screen reads to decide a permission flag changes a boolean, not the screen. - The row has a witness — a string field this list’s row renders as a JSX child. Not an attribute value:
key={r.id}is never displayed, and a witness taken from it would be a string the screen does not show.
Where any of the three fails, the emitter writes a test.skip naming which one. On the dogfood app 14 of 31 screens qualify, and all 14 pass.
This shipped as a blanket skip until it was measured. The comment on it blamed “an external store the harness does not reach” — a hypothesis written as a finding. Enabling all 21 recipes live gave 11 failures with three causes, every one of them visible in the AST.
router.back() gets a history entry
renderRouter mounts one screen at one URL with nothing behind it, so GO_BACK dispatches into an empty stack and fails with The action ‘GO_BACK’ was not handled by any navigator. A route spec pushes its own URL once before the press:
mount();
testRouter.push(INITIAL_URL);
fireEvent(screen.getAllByLabelText("Go back")[0], "press");
A component spec has no router to push into, so the handler is a skip that says so. Twelve real screens failed this way the moment their back buttons became addressable — a missing precondition being reported as a defect.
Skips name the fix
There is deliberately no last-resort locator. A control with no testID, no accessibilityLabel, no accessibilityRole and no text under it becomes a test.skip carrying the reason and the remedy:
test.skip("onPress on <Pressable> — <Pressable> renders only an icon, so no
text names it — add an accessibilityLabel, which makes it announceable as
well as addressable")
The same absence is what makes the control silent to a screen reader, so one accessibilityLabel retires both the skip and the accessibility gap. Answer a skip by adding the label to the app, not by loosening the ladder.
That is not a rhetorical pairing — it was measured. Adding 48 accessibilityLabel and 28 accessibilityRole props to the dogfood app, over exactly the shapes the skips named, took its generated suite from 96 to 124 passing tests and 64 to 36 skips, and function coverage from 40.7% to 44.6%. The controls had been unreachable to VoiceOver and TalkBack the whole time.
The native a11y analyser
--expo --tasks a11y runs the tool’s own analyser instead of biome — six rules judged from the same parsed tree the rest of the analysis uses, because biome’s HTML/ARIA rules say nothing about React Native primitives:
| rule | what it catches |
|---|---|
a11y/unnamed-control | a press target that provably renders no text and has no accessibilityLabel — silent to a screen reader and unaddressable to a spec |
a11y/missing-role | a press target with no accessibilityRole — RN has no implicit roles, so its text is announced without “button” |
a11y/textinput-unlabelled | a TextInput whose only name is a placeholder, which vanishes on input |
a11y/icon-unhidden | a free-standing vector icon exposed to the a11y tree with nothing to announce (warn) |
a11y/label-text-mismatch | an accessibilityLabel that does not contain the visible text — voice-control users say what they see (WCAG 2.5.3) |
a11y/invalid-role | a role value RN does not recognise — the union widens with | string, so tsc never catches the typo |
Two properties matter more than the rule list. The denominator: the summary and reports/a11y.json always say how many press targets, text inputs and icons were examined, so a zero can never be confused with “the rules do not apply”. Zero false positives over completeness: a dynamic accessibilityLabel={t("back")} is recorded as presence and never flagged, and a pressable rendering {item.name} is never called unnamed because it does announce at runtime.
Measured on the dogfood app: 85 findings across 32 files at baseline (82 press targets / 49 inputs / 40 icons examined), remediated to 0 — and the same absence that made each control silent to a screen reader made it unaddressable to a spec, so answering the a11y findings took the unit plane’s locator addressability from 46.8% to ~100%.
Route e2e against Expo web
--expo --tasks e2e emits one Playwright spec per expo-router route, run against expo start --web. Metro under SDK 54’s static output serves per-route rendered HTML with real status semantics (an unknown path answers 404), all measured before the wave was built.
The machinery is the Next.js flavour’s — the same params.json id fixture (expo-router’s [param] templates are byte-compatible), the same write-once global-setup.ts, the same GET <route> titles for --changed narrowing — plus three Expo facts:
- Auth is a cookie on the API origin. On web the app calls its API with
credentials: "include", so the setup stub’s login persistsstorageState. No token injection. - An auth wall has two faces, and the specs catch both. A client redirect fails the landing assertion; a wall rendered in place keeps the URL and answers 200 — but the API’s 401s land in the console assertion, and a copy witness (a third test asserting the screen’s longest analysis-proven literal is visible) fails on any screen that is not the one the analysis described.
- Two servers. The config boots Metro; your API is a prerequisite it cannot start. Set
EXPO_PUBLIC_API_URLfirst — Metro inlines it, and an unset value falls back to whatever the source hardcodes.
Run live on the dogfood app: 29 route specs, 67 pass / 1 skip / 0 fail.
API-only load pipes
--expo --tasks load emits crust load pipes for the GET endpoints the screens actually hit. An Expo app has no server-rendered page routes, so the pipes are API endpoints only — and finding them is the wave’s new capability.
A React Native screen never writes fetch(): it calls a named api function (listBuildings()) whose body calls a module-local wrapper with a literal path (apiFetch("/api/buildings")). So the direct-fetch harvest finds nothing — the endpoint lives two hops from the screen. The load harvest reads exactly that chain: the screen must call the imported name, which must resolve (through barrels) to a function calling a proven fetch wrapper (its single fetch() addresses one of its own parameters — trusted for what it does, never for being named apiFetch) with a literal or template path. A store-mediated call or a non-literal URL is dropped rather than guessed.
The pipe/params/env machinery is reused verbatim, with one Expo difference: the write-once routes.setup.ts fails on a 401 (an API endpoint keeps its URL on a dead session, so the Next flavour’s redirect probe would pass vacuously). Measured on the dogfood app: 30 GET endpoints across 28 of 31 screens, run live at 29 pass / 0 fail, p95 30–36ms.
Limits worth knowing
- Colocation is impossible under
app/. expo-router’srequire.contextmatches every.tsxunder the router root, so a colocated spec would register as a route. Native specs for route files always go to the out dir. - Check your own
testscript. A bare runner with no path filter sweeps the generated specs into the project suite and runs them under a runner they were not written for. The tool warns; scope the script. - Three DOM assertions have no honest native equivalent and are dropped rather than faked: the root-tag query, the className-flow check, and anything asserting a style token.