Limits and troubleshooting

What the static analyser recognises and what it deliberately does not, plus fixes for the errors you are most likely to hit

Limits and assumptions

The static analyser is deliberately narrow — it favours predictability over cleverness.

  • Component shape. A const X: React.FC<Props> = (props) => … declaration, an export default function X(props: Props) { … }, or either wrapped in memo()/forwardRef() — the declared props survive the wrapper (FC<Props>, memo<Props>, forwardRef<E, Props> all feed extraction). Other forms — arbitrary HOCs, decorators, dynamic component factories — are not detected.
  • Props type. Same-file types resolve directly, and imported ones are chased two hops — including types from the repo’s own workspace packages (@scope/shared), resolved through the workspace map into the package’s source tree with barrel re-exports (export * from, export { A as B } from) chased to the declaring file. Intersections (A & B), host-element props (ComponentProps<"div">), ComponentProps<typeof LocalX> and cva tables (VariantProps<typeof buttonVariants>) all resolve. z.infer/z.input aliases resolve too, by reading the zod schema syntactically — enums, objects, primitives, arrays, z.coerce, and the .optional/.nullable/.default/.partial/.extend modifiers; .transform, unions, records and dates bail rather than guess, and zod itself is never installed or executed. What does not resolve: unions of non-literals, Omit/Pick/Partial, and anything in node_modules. Those push the prop into unresolvedProps and the type that defeated it into unresolvedTypes; you then either pass --props '{…}' or edit the generated spec’s DEFAULTS constant.
  • Type to default value. Primitives, arrays (always []), inline object types (recursive), and literals are mapped. A prop no type resolves can still be recovered when the body uses it to index a same-file object literal with literal keys — those keys are the union, written out. A string prop defaults to "", except under a Next page’s params: an empty segment is a request Next cannot route, so those are filled with flawless-<key> instead. The same rule applies one layer down: a top-level string prop ending in Id never stays empty, because providers validate ids rather than render them — Liveblocks threw RoomProvider id property is required over the empty string a fixture passed through.
  • Fixtures satisfy the component’s own predicates. A field the source compares with === to a literal takes that literal, and a field compared against one of the component’s props takes that prop’s value, read from the same fixture the spec’s DEFAULTS carries. Without this, a page that filters m.status === "active" rejects every synthesised row, calls notFound(), and its spec passes having rendered nothing — which is what 22 of 32 page specs did on the dogfood app before it existed. !== is not used: it says what a value must not be, which does not pick one out.
  • Mock shapes come from declarations. A data dependency the analyser could not read from usage falls back to its own Promise<T> return annotation, resolved through workspace barrels and zod schemas. An interface X extends Y merges its bases, own members last, and bails whole if any base fails — a fixture missing half its fields is worse than none. Record<K,V> and Partial<T> resolve to {}, which is a complete inhabitant of both; every other generic instantiation bails, because for those it is not. A helper that is called but never awaited into a variable — a formatter used inside JSX — is read from its declaration too, and stubbed synchronously when it is synchronous.
  • Variant matrix. Only union-of-literal props produce variants. The matrix is pairwise — one prop varied at a time around the defaults, not the full cartesian product.
  • Addressing an element. Test id, then aria-label, then role plus accessible name, then id, then the literal copy. The last resort is the bare host tag, and only when it is the only element of its kind in that render. Whether a <Button> counts towards button is decided by resolving that component and reading its own root element — one hop, from source — so a <LinkButton> that renders an <a> does not count. A tag that cannot be resolved at all (anything in node_modules) falls back to its name, which can only over-count. When the tag has competition it names nothing, so the test becomes a skip that says so rather than a click on whichever element happened to be first.
  • What counts as closed. A Radix-style Dialog/Menu/Popover/Tooltip content subtree gated by a trigger is content that renders only after a click. Static-copy assertions skip it entirely, and interaction and form tests carry the gate: the spec clicks the trigger (by prop label, literal text, or its fixture child), clears the spies, then queries with findBy* retries. Unit and browser specs only — route e2e specs do not open dialogs. A root written open, defaultOpen or open={true} mounts its content right away, so nothing is gated; open={expr} stays gated, because the initial value is render state this structural pass cannot see.
  • How a binding is classified. A prop handler becomes an assertion on that spy; an inline arrow becomes one on the prop it calls; a named local — onClick={handleConfirm} — is resolved to its declaration once, and only upgraded to a prop assertion when the body calls exactly one prop handler as a plain statement. Two props claimed by one handler means it claims neither, and the test stays a canary that dispatches and asserts nothing fired. A children value that flows into an asChild/Slot site is mounted as a <span>, never bare text, because slot cloning rejects a text child.
  • Submitting a form. Read from the markup, never from likely wording. A host <button> inside a form submits it unless its type says otherwise; a component tag counts only with an explicit type="submit", because shadcn and base-ui both render type="button" by default. A form with no submit control in its source gets no submit test.
  • Filling a field. Decided by tag and type. Checkboxes and radios are checked, <select>s selected, and hidden, file, range and colour inputs skipped — typing into them asserts nothing. A change handler bound to a <form> is the event delegation it is, not a text box.
  • Console assertions. A component that calls console.error, or .error() on an imported binding, reports errors by design — an error boundary logging what it caught cannot also satisfy “mounts without console errors”. Its browser spec keeps the uncaught-exception and rendered-output assertions and drops the one it cannot justify.
  • Mounting a compound part. A root tag that HTML confines to a particular parent — <tbody>, <tr>, <option>, <li> — is mounted inside that chain in the browser host. That chain is the HTML specification, not an inference about your app.
  • What a mount spec claims about markup. Three outcomes, and the spec says which one it got. An unguarded component must render something, so an empty container is a failure. A guarded one — returns null behind a store hook — may legitimately render nothing, so the claim becomes conditional on the copy it would have printed. And a component whose entire output is a Radix disclosure primitive (Toast.Root, Dialog.Root, Popover.Root) has an empty container as a legitimate default: the library decides nothing renders until something opens it. That spec skips the markup claim by name, and only when the container really is empty, while the mount itself stays asserted — no console error, and a resolved mount() is the host reporting a committed render of that component by name. The list is the open-state primitives, not @radix-ui/ wholesale: Slot, Label and Switch render eagerly, so an empty container from one of those IS broken.
  • Variant distinctness needs a witness. Each option has to render a different class list, and the witness is an element that carries a class. A component whose render carries none at all (Radix’s Toast.Root hands jsdom one without) skips that test with the reason instead of failing every option in the table — a fact about the component, not a regression.
  • What the perf and browser hosts stand in for. They bundle a component without Next’s router and without a database, so those get substitutes: next/link, next/image, next/script (an inert script element with type="text/plain" rendered in place — src is not fetched, inline code does not run, so third-party analytics stays out of a benchmark), next/router (navigation recorded, never performed) and a signed-out stub for the auth libraries. Database drivers resolve to an inert proxy, so a component that queries one renders with no data rather than not at all. A named substitute always outranks a package-pattern one for the same specifier. What is deliberately not invented is a chainable client: a component that awaits a real backend in a mount effect or a submit handler still rejects, and the plane says so. Green with fabricated rows is worse than red that names the cause.
  • Children. Locally-imported components (relative paths and tsconfig paths aliases) are walked. Anything from node_modules, or a TypeScript path alias that resolves into it, is skipped. Depth defaults to 3 and the walk is cycle-guarded.
  • A component rendered from a hook. const { Component } = useTabs() above {Component && <Component />} leaves the analyser no import to follow, so what that mount owes is derived by a bounded walk rather than a guess: the tag must be an uppercase name bound by shorthand or plain destructuring from a useX() call (/^use[A-Z]/, not a core hook) that resolves to a project file or sits beside the component, and the renderables are the hook body’s own JSX plus one import hop into the top-level definition of each value identifier the body holds, read two levels deep inside that definition (array and object members step in, an arrow body steps into its return, as const / satisfies / parens unwrap for free) to sixteen components. Declined rather than guessed: an aliased or defaulted binding, a member-expression tag, and a value that is only read (pick.Component ? … : null), which is not a tag. The walk ends at a barrel that only re-exports the registry, because a re-export table declares nothing to walk — the same re-export hole the context chain hits when a barrel hands the library itself along. Both are named in the fix below rather than worked around.
  • Hooks. Detected by the name pattern /^use[A-Z]/, so custom hooks show up too. Hook detection is informational: it appears in spec output but does not yet drive different rerender strategies.

Troubleshooting

“could not locate a React component in …”

The analyser did not recognise the export shape. Refactor to a const X: React.FC<Props> = … declaration, or pass --include-children=false --depth=0 and run the tool against the actual component file directly.

unresolvedProps warning

One or more props use an imported type, a generic, or a non-literal union. Either:

  • Re-run with --props '{"propName": "value", …}', or
  • Edit the generated profile/<Component>.profile.spec.ts and add the missing fields to the DEFAULTS constant.

Generated suite fails on bun install with a peer-dep mismatch

The suite’s react and react-dom follow the app’s own major — React 17, 18 and 19 are all supported — and Testing Library follows it too. A React ≤17 app gets @testing-library/react@^12.1.5 plus @testing-library/dom@^8.20.1, because 13 and 14 peer-require react ^18 and 14 hard-requires the react-dom/client entry React 17 does not have. A stale 13+ range left behind by an older scaffold run is repaired by the next one, so re-running the tool is the fix; you no longer edit the pins by hand. If a mismatch persists, two React copies are in the tree — find the second one before touching anything.

ERR_PNPM_IGNORED_BUILDS and the app’s own pnpm run dev dies

pnpm ≥10 treats an unrecorded build-script decision as an error, and msw and @playwright/test — the packages the dependency merge introduces — both ship postinstall scripts. The merge records its own decision by writing pnpm.ignoredBuiltDependencies, leaving anything you approved through onlyBuiltDependencies approved, so the app boots again. If it resurfaces after you hand-edited dependencies, add the offending package to that same list.

Playwright cannot find a browser

The first time you run a generated suite you may need:

bunx playwright install chromium

If a Playwright upgrade pulled in a new browser version, run the same command again.

window.__profile is undefined inside tests

The host bundle did not load. Check the Playwright stderr — bun host/server.ts should print host listening on http://127.0.0.1:5173. If it does not, change into the suite directory and run the host directly to surface the build error:

bun host/server.ts

A spec that fails with ReferenceError: <name> is not defined while the host build printed no error is the same failure in disguise: a resolve plugin answered a bare specifier it should have left alone, and the module was silently replaced by one lacking that export.

A module throws at import time (”… URL is required”)

Something reads an environment variable at module scope. The suite’s Vitest config loads the app’s .env.test.local, .env.test, .env.local, .env and .env.example in that order, first value wins — and .env.example rides last because template repositories ship only the example, whose placeholder values exist precisely to satisfy module-scope construction. Keys already in the environment beat all of them, so export an override to redirect the suite.

Every e2e route 404s or redirects while / loads fine in a browser

The app’s own middleware is an auth wall — Clerk’s auth.protect(), a next-auth matcher — and with placeholder keys every route behind it answers 404 or bounces to sign-in. That is the app working, not the suite breaking: fill real credentials in flawless/e2e/global-setup.ts, or do not claim e2e coverage for the routes behind the wall.

The e2e run times out before its first spec

Timed out waiting 120000ms from config.webServer, with [WebServer] GET / 404 repeating in the log and not one spec executed, is that same wall one stage earlier. Playwright calls a dev server up only when the readiness url answers 2xx or 3xx, and the readiness url is the app root. A root the middleware 404s is a server that never started, so no assertion ever runs. Credentials make the root answer 200 or a sign-in redirect and the run proceeds. To point the suite at a server you started yourself, export FLAWLESS_E2E_BASEURL — the emitted config drops its own webServer block in that case — or repoint the readiness url at a route that answers.

The component reads useSelector or useContext and throws “could not find … in the context”

Most of these contexts are already wrapped by the derived nest — the router (and its data-router variant), react-intl, the query client, and your app’s own convention-named providers; if the mount still throws, the provider is one the derivation cannot see. That includes the component you never render by name: when the throw comes from inside a tab your app hands over as <Component /> after const { Component } = useTabs(), the provider belongs to that tab, and the walk which would have found it is the bounded one the Limits list above records — it ends at a barrel that only re-exports the registry, and it declines an aliased, defaulted or member-expression tag rather than guessing at one. Pass --provider <file> pointing at a .tsx that default-exports a wrapper component containing your Redux Provider, QueryClientProvider, ThemeProvider, and so on. It renders outside the derived nest, and both the host and the unit specs get it. Under --nextjs, the auto-derived wrapper skips any provider layer whose own module directly imports an auth library (@clerk/*, next-auth, @supabase/auth-helpers-*, @supabase/ssr): the unit and browser planes replace those libraries with a signed-out stub, so a provider that gates its children on a session renders nothing and every spec silently inherits the blank screen — while the stub already satisfies the context reads auth-consuming components make. Supply your own --provider if you want that layer mounted under test.

A library context the app never supplies at all gets a substitute provider generated for it: today that is React Query (a fresh QueryClient per mount, retry: false), React Router (a MemoryRouter — or createMemoryRouter + RouterProvider when the component uses the data router, which supersedes it and needs router ≥ 6.4), react-hook-form (a fresh empty useForm()), and react-intl (locale="en", nothing fetched). So useQuery resolves against an empty client and useFormContext / useController / useWatch resolve against a form instead of the null the library dereferences. A component that supplies its own control or client outranks the substitute, which makes the wrap harmless when it was not needed.

A spec skips and names the library in the reason

Two skips are the tool telling you the component, not your code, decided the outcome. “renders nothing into the container at these props — X is closed at rest” is a component whose whole output is a Radix disclosure primitive; the library renders nothing until something opens it. The mount is still proven, so if you want the markup claimed, drive it open in the spec — click the trigger the generated interaction steps already found. “no element in this component’s render carries a class” is the variant table with no witness to compare, and the same answer applies: assert on whatever that component does expose. Neither skip fires when the component renders, so a run that grew one after an edit is worth reading, not silencing.

The component fetches /api/... and gets a 404

Either pass --api <openapi.json|yaml> to scaffold a mock server, or — when --api was already passed — edit the generated mock/routes.ts after the fact to add the missing route. Without --api no mock server is generated, and /api/* requests return 404 from the host server.

Mock server says YAML parsing requires Bun >= 1.2

Either upgrade Bun, or convert the spec to JSON and pass the JSON file to --api:

bunx js-yaml api.yaml > api.json

“biome executable not found”

This is the one failure that is specific to the standalone binary, so it is worth understanding on its own.

Five of the seven tasks — perf-harness, unit-browser, perf-static, e2e and load — are self-contained and work anywhere. The other two, a11y and react-rules, are Biome-driven: they write a temporary rule config and shell out to a biome executable. The binary is a self-contained Bun build, but it cannot carry biome inside itself, so that executable has to be found on the machine.

When it is not, the task fails with:

biome executable not found. Looked in this package's node_modules, /your/project/node_modules/.bin/biome, and on PATH.
  Source install:   bun install
  Compiled binary:  install biome in the project you are scanning (bun add -d @biomejs/biome) or put it on PATH.

The failure is scoped to that task. Any other tasks you selected in the same run still execute, and the run’s summary table reports the biome-backed one as failed.

The three places it looks

In order:

  1. This package’s own node_modules/.bin/biome. This is the source-install case, and it stays first so that a source install always uses the biome version pinned in the package’s own dependencies.
  2. <cwd>/node_modules/.bin/biome — the project you are scanning. Any React codebase that already lints with biome satisfies this for free.
  3. Anything named biome on PATH — a global install, or a distribution package.

The binary only ever reaches steps 2 and 3. It is produced with bun build --compile, and inside a compiled binary import.meta.url points into Bun’s virtual filesystem, so the walk up to the package root finds nothing on disk and falls back to the current working directory. Step 1 therefore never resolves there. Checking the extra locations is what makes the binary useful whenever biome is reachable at all, while leaving the source install behaving exactly as before.

The two fixes

Install biome in the project you are scanning. This satisfies candidate 2 and keeps the binary as your install channel.

bun add -d @biomejs/biome

Or use the source package. It carries @biomejs/biome as a real dependency, so all seven tasks work with no further setup. It needs Bun >= 1.1 on the target machine.

bun add -g @lariocpt/flawless-react