Extending a suite
Patterns for editing a generated suite — providers, mocks, hard budgets and richer interactions — without regretting it later
The output is your test code
What flawless-react writes is your test code, not a black box. A re-run is never refused:
a fresh out dir is generated, a re-run over an existing suite refreshes every derived
file (write-once files untouched, exit 0), and a --changed run keeps the harness
entirely while running the affected specs. The files that can hold your work stay yours even
under --force (see below). The point of the tool is to skip the boilerplate and then hand
you something you can shape to your project.
What follows are the patterns that have worked well.
Treat the suite as a fresh project
Commit it. Run linters and formatters over it. Add it to CI. Pin its dependencies the way you pin any other project’s. The generator deliberately doesn’t try to be “framework-aware” beyond the React + Playwright basics — once you’ve generated the suite, it’s just files.
Mount components inside a provider
Most of this is derived for you. Every mount — unit spec and perf host alike — opens with a nest
of providers built from hook-call, namespace-compound and transitive-requirement evidence: your
app’s own context providers, convention matches (useTheme → the exported ThemeProvider that
renders the same module’s binding), and generated shims for library contexts
(@tanstack/react-query, react-router-dom — including the data router, react-hook-form,
react-intl). The file-by-file story, with the shim list, is in the
generated-suite guide.
Manual wrapping stays for what derivation cannot see: a provider no analysed file imports and
whose name breaks the convention, a store that needs runtime values no static derivation would
invent, or a wrapper you want forced around everything. To wrap every perf-host mount, edit
host/main.tsx at the registry render site — unit specs take the same wrapper through
--provider, which renders it outside the derived nest:
import { ThemeProvider } from "your-theme";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
// in window.__profile.mount:
ensureRoot().render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
{createElement(Comp as never, props)}
</ThemeProvider>
</QueryClientProvider>
);
If different components need different providers, factor the wrapping into a
wrap(name, element) helper and let the registry entries declare which wrapper they need.
If you know your provider up front, the --provider <file> flag threads a wrapper through
mount and rerender for you at scaffold time — see the CLI reference.
Mock data sources
For a component that calls fetch, install MSW in the suite (bun add msw) and start a
worker in host/main.tsx before the first render:
import { setupWorker } from "msw/browser";
import { handlers } from "./mocks/handlers.ts";
await setupWorker(...handlers).start({ quiet: true });
Then write host/mocks/handlers.ts next to main.tsx with whatever endpoints your component
expects. The handlers file is yours — it’s not regenerated.
Tighten the budgets into hard failures
By default, every spec warns when a measurement exceeds its budget but does not fail. This is intentional for first runs — you don’t yet know what “fast enough” means for your component.
As generated:
if (mountMs > budget) {
console.warn(`[budget] ${NAME} mount ${mountMs.toFixed(2)}ms > ${budget}ms`);
}
Once you have a baseline (say, three green runs in CI), turn the warning into an assertion:
expect(mountMs, `${NAME} mount budget`).toBeLessThan(budget);
Same for the variant-matrix p95 warning. Now a perf regression breaks CI.
Answer a skip
Most of what you will want to change is a test.skip whose title states why the analyser
stopped. They are written to be actionable — the fix is nearly always one attribute in your
component, after which regenerating turns the skip into a test.
| Skip reason | What to change |
|---|---|
nothing in the source identifies the <Button> it is bound to | give it a data-testid, an aria-label, or literal text |
more than one <button> renders here and nothing distinguishes this one | as above — the tag alone cannot name it when several render |
no submit control in the markup | the form declares no <button type="submit">; a component tag needs type explicitly, since shadcn and base-ui default to type="button" |
its submit control is disabled at these props | supply props that enable it, or drive the UI that does |
<form> holds no value to change | nothing to do — the handler is delegation, and the form test covers the fields |
an early return the analyser could not decide left nothing to drive | pass the prop the guard reads, or fill the store fixture the hook uses |
no id fixture for /route — fill flawless/e2e/params.json | put a seeded id in params.json; the load pipes read the same file |
Files the tool will not overwrite
--force regenerates the derived output, but a few files become yours the moment they exist,
because their contents cannot be recovered if lost:
Protected under every flag, including --force: flawless/budgets.json,
flawless/mock/routes.ts, flawless/host/main.tsx (the wrapper only — the mount tables in
host/registry.ts beside it regenerate every run), flawless/provider.fixtures.ts and
flawless/e2e/global-setup.ts.
Merge-preserving under --force: flawless/e2e/params.json (stale route keys are pruned,
filled values never are) and flawless/load/routes.pipes (your edited pipes and rates
survive; only the header regenerates).
flawless/mocks/handlers.ts sits beside that group, not in the protected one: it is kept
while your edits are in it, and refreshed when they are not. Without --force a re-run
regenerates it only when the API spec has changed and the file still matches its
// @flawless-generated stamp — the stamp’s body carries a // @flawless-spec sha256:…
digest of the spec it was generated from. A hand-edited file (stamp mismatch), or one with
no stamp at all (hand-written or from before stamping), is kept and reported; --force
regenerates even a hand-edited file. The line is deliberate: handler bodies are rebuildable
from the spec, so freezing the file would let it silently rot when your API moves — unlike
a tightened budgets.json or a hand-hardened spec, which are work the tool cannot rebuild.
Colocated *.flawless.test.tsx specs are kept without --force — and guarded WITH it: each
opens with a // @flawless-generated sha256:… stamp hashing the rest of the file, and
--force rewrites a spec only while the stamp still matches. A hand-edited spec (stamp
mismatch) or a pre-stamp one is kept and listed in the run summary; delete such a file to
regenerate it.
Run only what an edit affects
A full suite is the wrong unit of work when three components changed, and
analysing a whole app costs about half a second (measured 2026-08-23: 99 files
in 463 ms) — so --changed keeps analysis
and generation complete and narrows only the run.
bunx flawless-react "src/**/*.tsx" --nextjs . --tasks perf-harness,unit-browser --changed
Unit specs are selected through vitest’s module graph, so the selection is
transitively correct without this tool reproducing its own naming rules. Profile
and browser specs are selected by component name. e2e is selected by whether a
page’s tree renders a changed component — the relationship a path filter
cannot see, since editing a leaf <StatusPill> changes what every page
rendering it produces without changing those pages’ files.
Against a 181-component app, editing one file that exports seven components takes about 13 seconds end to end, including a full regeneration.
Turn heap snapshots off
The profile suite takes a V8 heap snapshot either side of every scenario. On a real app each
is tens of megabytes, and a whole-app sweep leaves hundreds on disk — plus two heap walks
inside every measurement they bracket. --heaps off skips the capture entirely; --heaps all
persists every scenario rather than only the stress ones.
Add more interactions to click rerender
click rerender drives the target the analyser derived from your source — a test id, an aria-label, a role plus its accessible name, or literal copy — and opens the disclosure it lives behind first. When nothing names a target the scenario is a skip carrying the reason, not a click on whichever button happens to be first. Real components
have more nuanced interactions — typing into inputs, opening menus, dragging. Replace the body
of that test with whatever sequence exercises the component:
test(`${NAME}: form submit cycle`, async ({ page }) => {
await mountComponent(page, NAME, DEFAULTS);
await page.getByLabel("Email").fill("test@example.com");
await page.getByRole("button", { name: "Submit" }).click();
await page.waitForFunction(() => /* some completion signal */);
const events = await getScanEvents(page);
expect(events.filter(e => e.name === "Header")).toHaveLength(0); // header is prop-stable
});
getScanEvents() lets you assert on what react-scan flagged. The most valuable assertion is
negative: “component X should NOT have rerendered during this interaction”.
Add custom variants programmatically
The static-parse variants only cover unions-of-literals. For more interesting variants (large
data sets, edge-case shapes), append to the VARIANTS array in a spec by hand:
const VARIANTS = [
...EXTRACTED_VARIANTS,
{ items: Array.from({ length: 1000 }, (_, i) => ({ id: i, label: `item ${i}` })) },
{ items: [] }, // empty state
{ items: undefined as never }, // missing
] as Array<Record<string, unknown>>;
Edit-time concern: a spec inside the out dir is rewritten on every regeneration, --force
or not — the tool is the source of initial truth and keeps improving what it emits. A spec
you want to evolve belongs in --colocate mode: written beside your source, kept without
--force, and — once you edit it — kept under --force too, because the provenance stamp on
its first line no longer matches the body.
Run a specific spec or test
bun run test tests/Dashboard.profile.spec.ts
bun run test -g "click rerender"
Standard Playwright args. The -g flag filters by test title regex.
Capture a longer profile run
For deeper measurement, bump iterations in the forced-rerender test and pipe the JSON output to a file:
const forcedIterations = 1000; // up from the default
// at the end of the test:
const blob = JSON.stringify({ stats, samples: updates });
console.log(`PROFILE_DATA ${blob}`);
Then in CI:
bun run test 2>&1 | grep '^PROFILE_DATA ' | sed 's/^PROFILE_DATA //' > profile.json
profile.json is now consumable by whatever dashboard you want to point at it.
Headed debugging
bun run test:headed
Opens visible Chromium. Combine with Playwright’s await page.pause() inside a test to step
through interactively — useful when click rerender doesn’t behave as you expect.
react-scan itself can highlight rerenders visually in headed mode if you flip log: false
to log: true in host/main.tsx. The browser console will then show the rerender heatmap in
real time.
Run against a hot-reloaded host
Right now the host bundle is built once when bun host/server.ts starts. For inner-loop
iteration on host/main.tsx itself:
// host/server.ts — replace one-shot build with a watcher
import { watch } from "node:fs";
let bundleText = await build();
watch("./host/main.tsx", async () => {
bundleText = await build();
console.log("rebuilt");
});
Not generated by default because Playwright already restarts the webServer between runs in CI; this is only useful for local dev.
When you’ve outgrown the generated suite
If you’ve heavily customised the suite — multiple providers, MSW, custom drivers, custom reporting — you’re past the value of regeneration. At that point:
- Stop running
flawless-reactagainst thisoutDir. - Drop the suite into your monorepo’s regular test directory if it isn’t already.
- Treat it as a normal Playwright project. The fact that it started as scaffold is incidental.
The tool’s job is to skip the boring first hour. After that, your suite earns its keep on its own.