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 reasonWhat to change
nothing in the source identifies the <Button> it is bound togive it a data-testid, an aria-label, or literal text
more than one <button> renders here and nothing distinguishes this oneas above — the tag alone cannot name it when several render
no submit control in the markupthe 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 propssupply props that enable it, or drive the UI that does
<form> holds no value to changenothing to do — the handler is delegation, and the form test covers the fields
an early return the analyser could not decide left nothing to drivepass the prop the guard reads, or fill the store fixture the hook uses
no id fixture for /route — fill flawless/e2e/params.jsonput 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-react against this outDir.
  • 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.