@lariocpt/flawless-react 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +611 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- package/src/version.ts +12 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: flawless-nextjs
|
|
3
|
+
description: Work with flawless-react's `--nextjs` mode — the app-router resolver, the derived `flawless/provider.tsx` and why a provider layer gets dropped, the signed-out auth stub and the layers it makes dangerous, module stubs and their precedence, the React 17/18/19 pins (Testing Library `^12.1.5`, `repairRtlPin`), the package.json merge with `pnpm.ignoredBuiltDependencies`, `.env` loading for the suite and the host, and where gitignore entries land. Use when a Next app's generated suite is red at import or mount, when a provider is missing from the wrap, when `Cannot read properties of null (reading 'useState')` appears, or when `pnpm run dev` dies after an install. For fixtures from a spec see flawless-api-fixtures; for route e2e see flawless-e2e.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# flawless Next.js mode
|
|
7
|
+
|
|
8
|
+
`--nextjs [path]` is the mode that makes the output a **suite inside your app**
|
|
9
|
+
rather than a folder of files: it finds the app root, resolves an entry glob, writes
|
|
10
|
+
under `<root>/flawless/`, merges devDeps and `flawless:*` scripts into your
|
|
11
|
+
`package.json`, and appends gitignore entries.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
cd apps/web
|
|
15
|
+
bunx @lariocpt/flawless-react "src/app/dashboard/page.tsx" --nextjs . --tasks unit-browser,e2e
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The path is optional — with no value the current directory is the app root, and in
|
|
19
|
+
`--nextjs` mode the entry glob itself is auto-detected from the router when you give
|
|
20
|
+
none. Monorepos work because the root is resolved, not assumed: `apps/web` is the
|
|
21
|
+
root, the suite lives at `apps/web/flawless/`, and the scripts land in
|
|
22
|
+
`apps/web/package.json`.
|
|
23
|
+
|
|
24
|
+
## The provider wrapper, and its honest drops
|
|
25
|
+
|
|
26
|
+
App-router `layout.tsx` is usually an **async server component** importing
|
|
27
|
+
`next/font` or `next/headers` — nothing in that file can run under jsdom. So the tool
|
|
28
|
+
derives `flawless/provider.tsx` from the JSX chain that actually encloses
|
|
29
|
+
`{children}`, keeping only `"use client"` components and literal attributes.
|
|
30
|
+
|
|
31
|
+
**Every drop is named where you will read it**: a dropped attribute becomes a
|
|
32
|
+
`TODO(flawless)` comment beside the wrapper; a dropped provider layer becomes a block
|
|
33
|
+
at the top of the file with the reason and the fix. Do not read `provider.tsx` as
|
|
34
|
+
"the app's providers" — read it as "the providers that could be mounted", and read
|
|
35
|
+
its header.
|
|
36
|
+
|
|
37
|
+
**A layer whose module directly imports an auth library is skipped rather than
|
|
38
|
+
mounted.** The list is `@clerk/nextjs`, `@clerk/clerk-react`, `next-auth`,
|
|
39
|
+
`@supabase/auth-helpers-nextjs`, `@supabase/auth-helpers-react`, `@supabase/ssr`,
|
|
40
|
+
matched by package or subpath (`next-auth/react`, `@clerk/nextjs/server`). The reason
|
|
41
|
+
is the stub the unit and browser planes install for those same libraries: a **signed
|
|
42
|
+
snapshot** — `isLoaded: true`, `isSignedIn: false`, `userId: null`, `user: null`,
|
|
43
|
+
`status: "unauthenticated"`, behind a Proxy whose method-shaped keys (`sign*`, `set*`,
|
|
44
|
+
`get*`, `reload*`, `redirect*`, `create*`) resolve to another signed snapshot and
|
|
45
|
+
`*List` keys to `[]`, everything else `undefined`. Mount a provider that gates its
|
|
46
|
+
children on a session against that and it renders **nothing**, and every spec inherits
|
|
47
|
+
the blank screen — while the stub already satisfies the context reads that
|
|
48
|
+
auth-consuming components make on their own.
|
|
49
|
+
|
|
50
|
+
So the failure to expect is one **below** the dropped layer, and it is the drop, not
|
|
51
|
+
the component: `Could not find Convex client`, `could not find … in the context`. The
|
|
52
|
+
fix is not editing `provider.tsx`, because `--force` regenerates it. Two real options:
|
|
53
|
+
|
|
54
|
+
- put the values you supply (a seeded auth state, a theme, a router) in
|
|
55
|
+
**`provider.fixtures.ts`**, which is never regenerated under any flag;
|
|
56
|
+
- or pass **`--provider <file>`**, which also buys you a context the derived wrapper
|
|
57
|
+
could not produce.
|
|
58
|
+
|
|
59
|
+
One subtlety in the resolver worth knowing when a provider you expected is missing: a
|
|
60
|
+
namespace alias (`const ToastProvider = ToastPrimitives.Provider`) counts **only if
|
|
61
|
+
that file exports the name**, and the first exported candidate wins. And a file that
|
|
62
|
+
renders `<Component />` with no `{children}` inside it is never a usable provider —
|
|
63
|
+
it wraps nothing.
|
|
64
|
+
|
|
65
|
+
## Module stubs, and what they must not touch
|
|
66
|
+
|
|
67
|
+
Client specs mock an **allowlist only**: `next/navigation`, `next/headers`,
|
|
68
|
+
`server-only`, `next/link`, `next/font/*`, and any `"use server"` module. Mocking a
|
|
69
|
+
`cn` helper would strip every className off the markup the assertions read; mocking a
|
|
70
|
+
UI component would replace the element an interaction test clicks.
|
|
71
|
+
|
|
72
|
+
Rules the shim table follows:
|
|
73
|
+
|
|
74
|
+
- **A named stub outranks a pattern stub** — an explicit entry for a module wins over
|
|
75
|
+
a family match, so adding one never silently shadows a narrower decision elsewhere.
|
|
76
|
+
- `next/script` gets an inert in-place stand-in: it renders **nothing** without
|
|
77
|
+
Next's router context (it injects its tag through a side effect), which otherwise
|
|
78
|
+
looks like a broken component. `next/head` is deliberately **not** stubbed.
|
|
79
|
+
- `LIBRARY_SHIMS` covers the third-party packages whose context the mount needs
|
|
80
|
+
(react-query, react-router, react-hook-form and react-intl are in it). The
|
|
81
|
+
requirement is detected from what the component **mounts**: the hooks it calls
|
|
82
|
+
— through project wrappers, across files — and the library's **components** it
|
|
83
|
+
renders, because `<FormattedMessage>` calls no hook and throws for the missing
|
|
84
|
+
provider all the same. A component handed through a prop counts as rendered
|
|
85
|
+
(`as={Link}`, `component={Link}`, `slots={{ root: Link }}`, `render`), since no
|
|
86
|
+
JSX tag ever names it; an HTML tag string (`as="a"`) and the app's own
|
|
87
|
+
components do not. One library can also be installed under two module names,
|
|
88
|
+
and both count.
|
|
89
|
+
A shim fixes the mount, not the component: an API misuse the analyser cannot
|
|
90
|
+
see stays a red spec, and an honest one.
|
|
91
|
+
- **One library can need two wraps, and its older install can be unable to have
|
|
92
|
+
the newer one.** react-router is that case: `MemoryRouter` serves
|
|
93
|
+
`useNavigate` / `useLocation` / `<Outlet>`, while the data hooks
|
|
94
|
+
(`useLoaderData`, `useFetcher`, `useMatches` …) invariant out under it and get
|
|
95
|
+
`createMemoryRouter` + `RouterProvider` instead. Tags split the same way, and
|
|
96
|
+
each was probed alone under both wraps rather than read off the docs: `<Form>`
|
|
97
|
+
throws `useSubmit` and `<ScrollRestoration>` throws `useMatches` under
|
|
98
|
+
`MemoryRouter`, while `<Link>` and `<Outlet>` mount there and stay. A component
|
|
99
|
+
needing both gets the data wrap alone (`supersedes`: the data router serves the
|
|
100
|
+
navigation surface too, so nesting only shadows) — whether it reached them
|
|
101
|
+
directly, through its own hooks, or through a tag. It is offered only when the
|
|
102
|
+
react-router that actually resolves is **≥ 6.4**, the release that added
|
|
103
|
+
`createMemoryRouter` — an import the installed library never exported takes down
|
|
104
|
+
every spec that reads the file, so on 5.x those hooks keep failing with the
|
|
105
|
+
message the library writes. And because `RouterProvider` renders a real error
|
|
106
|
+
*page* when the mount throws, the wrap's `errorElement` **rethrows**: a crashing
|
|
107
|
+
component fails its own spec with its own error.
|
|
108
|
+
- **A Feature-Sliced barrel declares nothing, so both walks follow the
|
|
109
|
+
hand-off.** `entities/rates/index.ts` is `export * from './model'` and that
|
|
110
|
+
model index is `export { useRateQuery } from './queries'`, so neither the hook a
|
|
111
|
+
component imports nor the library it calls three files away is named by any
|
|
112
|
+
import the component makes. A JSX child tag is the same problem: `<CreateApiKeyModal>`
|
|
113
|
+
imported from `'../index'` left the child edge at the barrel, so what the modal
|
|
114
|
+
needs was inherited by nobody and the parent's spec mounted bare. The named hop
|
|
115
|
+
is exact, so it goes first; `export *` only says "ask the target", so it is
|
|
116
|
+
consulted when no named hop carries the name; a hop whose module does not
|
|
117
|
+
resolve ends that hop rather than the search; and a cycle costs one visit.
|
|
118
|
+
`export * as ns from`, `export type { T } from` and a file's own
|
|
119
|
+
`export { localName }` are NOT hand-offs of the target's name, while a name
|
|
120
|
+
imported and handed back out with a module-less `export { … }` is.
|
|
121
|
+
- `server-only` is handled by an emitted `server-only-stub.ts` at the suite root,
|
|
122
|
+
because the real package throws on import by design.
|
|
123
|
+
|
|
124
|
+
## React 17 / 18 / 19
|
|
125
|
+
|
|
126
|
+
The app's React major (`detectAppReact`, read from the root `package.json`) is the
|
|
127
|
+
single switch for the whole suite.
|
|
128
|
+
|
|
129
|
+
- **≥18** mounts through `createRoot`; **<18** falls back to legacy `ReactDOM.render`,
|
|
130
|
+
document roots render with `renderToStaticMarkup`, and promise-valued props are not
|
|
131
|
+
passed at all.
|
|
132
|
+
- The suite's `react` / `react-dom` are **pinned to the version the app has installed, exactly** —
|
|
133
|
+
`detectAppReact` reads `node_modules/react` before the declared range, because a caret is a
|
|
134
|
+
floor and `^19.2.0` installs whatever is newest. Where the sources resolve a different copy than
|
|
135
|
+
that walk finds, `appReactPin` reads the pin off the copy the emitted `react` aliases anchor on:
|
|
136
|
+
the pin and the alias must name one physical copy or the plane renders with two Reacts.
|
|
137
|
+
Two React copies is the classic red-suite
|
|
138
|
+
cause, with signature `Cannot read properties of null (reading 'useState')`; a pair that
|
|
139
|
+
merely drifts is just as fatal — one patch off dies at load on `Incompatible React
|
|
140
|
+
versions`, one major off on `Cannot read properties of undefined (reading
|
|
141
|
+
'ReactCurrentOwner')`. The next run rewrites a stale or loose pin it finds in an existing
|
|
142
|
+
suite manifest (`repairReactPin`).
|
|
143
|
+
- Testing Library follows the app: React ≤17 gets `@testing-library/react@^12.1.5`
|
|
144
|
+
with `@testing-library/dom@^8.20.1`, because 13/14 peer-require React ^18 and 14
|
|
145
|
+
hard-requires the `react-dom/client` entry that does not exist in 17. A stale 13+
|
|
146
|
+
range left behind by an older run is repaired by the next one (`repairRtlPin`) —
|
|
147
|
+
so an upgraded-app suite that suddenly installs RTL 12 did the right thing.
|
|
148
|
+
|
|
149
|
+
## What the `package.json` merge does
|
|
150
|
+
|
|
151
|
+
`devDependencies` for the harness (`@playwright/test`, `msw`, `react-scan`, Vitest and
|
|
152
|
+
friends) and the plane scripts — `flawless:unit`, `flawless:browser`, `flawless:perf`,
|
|
153
|
+
`flawless:e2e`, `flawless:load`. JSON is merged, not rewritten: your file's existing
|
|
154
|
+
indentation and key order survive, because generation should produce a clean
|
|
155
|
+
`git diff`, not a reformat.
|
|
156
|
+
|
|
157
|
+
**pnpm ≥10 needs one more thing, and the merge records it:** the deps the tool
|
|
158
|
+
introduces go into `pnpm.ignoredBuiltDependencies`. Without that record pnpm errors on
|
|
159
|
+
an unapproved build-script decision, and `ERR_PNPM_IGNORED_BUILDS` kills the app's own
|
|
160
|
+
`pnpm run dev` before Next boots — a test-tooling install that breaks the dev server
|
|
161
|
+
is the worst possible first impression.
|
|
162
|
+
|
|
163
|
+
## Environment loading
|
|
164
|
+
|
|
165
|
+
Two different orders, because the two consumers are different runtimes.
|
|
166
|
+
|
|
167
|
+
- **The Vitest config** reads `.env.test.local`, `.env.test`, `.env.local`, `.env`,
|
|
168
|
+
`.env.example` — first value wins, real environment variables beat all of them.
|
|
169
|
+
`.env.example` is last on purpose: template repos ship only the example, and its
|
|
170
|
+
placeholders exist to satisfy module-scope construction (`new URL(process.env.DATABASE_URL)`
|
|
171
|
+
at import time), not to be correct.
|
|
172
|
+
- **The perf/browser host bundle** reads Next's own development precedence: `.env`,
|
|
173
|
+
`.env.local`, `.env.development`, `.env.development.local`, later wins — the same
|
|
174
|
+
values the app would see in `next dev`.
|
|
175
|
+
|
|
176
|
+
## gitignore
|
|
177
|
+
|
|
178
|
+
Entries are appended in **every** mode, not only under `--nextjs`: the suite's own
|
|
179
|
+
scratch directories (`test-results/`, `heaps/`) and the credential file the emitted
|
|
180
|
+
global-setup writes (`e2e/.auth.json`). Outside Next mode the destination is a small
|
|
181
|
+
search — git root, then the nearest `package.json`, then the out dir's parent — which
|
|
182
|
+
matters because until that ordering was fixed a plain run ignored nothing it
|
|
183
|
+
generated.
|
|
184
|
+
|
|
185
|
+
## Spec-derived API fixtures are a separate concern
|
|
186
|
+
|
|
187
|
+
`--nextjs` probes `http://localhost:3000/api/docs` for a live OpenAPI document with a
|
|
188
|
+
short timeout. Whether that probe helps, hurts or should be turned off
|
|
189
|
+
(`--swagger-url=off`) is in flawless-api-fixtures.
|
|
190
|
+
|
|
191
|
+
## Never
|
|
192
|
+
|
|
193
|
+
- Never hand-edit `flawless/provider.tsx` to add a provider: `--force` erases it.
|
|
194
|
+
Use `--provider` or `provider.fixtures.ts`.
|
|
195
|
+
- Never mock a project module (a `cn`, a UI primitive) to make a spec green — the
|
|
196
|
+
assertions stop referring to the real render.
|
|
197
|
+
- Never treat a provider drop's `TODO(flawless)` as a passing state when the specs
|
|
198
|
+
below it fail on a missing context: that is the same drop, still open.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: flawless-perf-harness
|
|
3
|
+
description: Run and interpret flawless-react's runtime performance plane — the `host/` bundle, `profile/` Playwright suite, mount/update times from React's Profiler, budgets.json (warn-only, and NOT baselines), `--heaps` V8 snapshots, FLAWLESS_WORKERS, the shared 127.0.0.1:5173 host with the browser specs, and what a number is worth. Use before running `flawless:perf`, when a profile spec is red or a budget warns, when snapshots are missing, or before quoting a mount time. For the static `perf/*` findings that need no browser see flawless-perf-static; for load-test p95s and their baselines see flawless-load.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# flawless perf harness
|
|
7
|
+
|
|
8
|
+
`perf-harness` builds a real Chromium surface for components: a Bun-served host
|
|
9
|
+
bundle, one Playwright spec per host-mountable component, and a budget file. It is
|
|
10
|
+
also the host the **browser specs** run on, so most "the perf plane is broken"
|
|
11
|
+
reports are really "the host is broken", and both planes share the symptom.
|
|
12
|
+
|
|
13
|
+
## What it writes
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bunx @lariocpt/flawless-react "src/app/**/page.tsx" --nextjs . --tasks unit-browser,perf-harness
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The `perf-harness` task is what builds this plane, and `unit-browser` alongside it
|
|
20
|
+
is what makes the **browser specs** appear — they run on the same host, so without
|
|
21
|
+
this task there is no browser plane either.
|
|
22
|
+
|
|
23
|
+
| file | regenerated? |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `host/index.html`, `host/server.ts`, `host/_polyfill.ts` | yes |
|
|
26
|
+
| `host/registry.ts` — the component tables the host mounts from | yes, every run |
|
|
27
|
+
| `host/main.tsx` — boot: providers, MSW worker, the `ALL` spread | **write-once** |
|
|
28
|
+
| `profile/_helpers.ts`, `profile/_heap.ts` | yes |
|
|
29
|
+
| `profile/<Component>.profile.spec.ts` | yes while the component stays host-mountable; stamped, so your edits mark it as yours |
|
|
30
|
+
| `budgets.json` | **write-once** |
|
|
31
|
+
| `playwright.config.ts` (the perf plane) | yes |
|
|
32
|
+
|
|
33
|
+
Two consequences people trip on:
|
|
34
|
+
|
|
35
|
+
- A component hand-added to `host/registry.ts` is **lost on the next run**. Register
|
|
36
|
+
it in the `ALL` spread in `host/main.tsx`, which nothing rewrites.
|
|
37
|
+
- `budgets.json` keeps whatever you wrote. That protects your tightened numbers — and
|
|
38
|
+
means a **deleted key silently reverts to the default** at the next measurement,
|
|
39
|
+
because the file is never rewritten to remind you.
|
|
40
|
+
|
|
41
|
+
## What is measured
|
|
42
|
+
|
|
43
|
+
- **Mount time** and **update times** per component, via React's built-in
|
|
44
|
+
`<Profiler>`.
|
|
45
|
+
- **Unnecessary rerenders** flagged by `react-scan`.
|
|
46
|
+
- A wall-clock distribution (min / avg / p50 / p95 / max) over a forced-rerender
|
|
47
|
+
loop.
|
|
48
|
+
|
|
49
|
+
Scenarios per component: `mounts at defaults`, `variant matrix` (when the analyser
|
|
50
|
+
found variants), `stress-click-rerender x 50` (when a handler prop exists — skipped
|
|
51
|
+
with a stated reason when it does not), and `forced rerender x N`.
|
|
52
|
+
|
|
53
|
+
**Only two things fail a profile spec**: the mount witness (`renderCount ≥ 1`) and
|
|
54
|
+
the forced-rerender smoke check (at least one update timed). **Every budget is a
|
|
55
|
+
`console.warn`.** A green perf run therefore means "it mounts and it re-renders",
|
|
56
|
+
not "it is fast" — read the `[budget]` lines, not the exit code.
|
|
57
|
+
|
|
58
|
+
## Budgets, not baselines
|
|
59
|
+
|
|
60
|
+
`budgets.json` is per component: `{ "HomePage": { "mountMs": 50, "updateMs": 16 } }`.
|
|
61
|
+
Defaults are 50 ms mount, 16 ms update (one frame). Breaching one warns:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
[budget] HomePage mount 61.42ms > 50ms
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
To make a budget a gate, change that `console.warn` into an
|
|
68
|
+
`expect(...).toBeLessThan(...)` in the spec — the suite README says so, and it is
|
|
69
|
+
your call because a threshold you did not measure is theatre. The intended loop is:
|
|
70
|
+
run once, tighten each number to what you actually observed, keep the diff in the
|
|
71
|
+
commit.
|
|
72
|
+
|
|
73
|
+
**There is no baseline file in this plane.** `last-<name>.json` /
|
|
74
|
+
`baseline-<name>.json` are load pipes (flawless-load). Comparing a mount time here
|
|
75
|
+
means comparing two runs' output by hand, which is why the recorded numbers should
|
|
76
|
+
come from a one-worker run (below).
|
|
77
|
+
|
|
78
|
+
## Running it
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
bun run flawless:perf # from the app root
|
|
82
|
+
node node_modules/@playwright/test/cli.js test --config playwright.config.ts # from the suite dir
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The config has **exactly one project**, `chromium`, with `testDir: "./profile"`; run
|
|
86
|
+
the plane from the suite dir so the suite's own copy of Playwright resolves — from
|
|
87
|
+
the app root, a second copy is how you get `No tests found` or `test.describe()
|
|
88
|
+
called here`.
|
|
89
|
+
|
|
90
|
+
It serves its own host: `webServer.command` is `bun host/server.ts` on
|
|
91
|
+
`http://127.0.0.1:5173`, with `reuseExistingServer: !process.env.CI`. The **browser
|
|
92
|
+
specs use the same host on the same port** (`browser/playwright.config.ts` runs
|
|
93
|
+
`bun ../host/server.ts`), so two planes at once is one server serving two runners
|
|
94
|
+
and noisy numbers at best. To clear a stuck host: `lsof -ti :5173 | xargs -r kill`.
|
|
95
|
+
|
|
96
|
+
Measurement hygiene: `FLAWLESS_WORKERS=1` forces a single worker; CI defaults to 1,
|
|
97
|
+
otherwise Playwright decides. Budgets only warn, so parallel measurement can't fail
|
|
98
|
+
a run — but it does make the numbers worse. Record a number with one worker.
|
|
99
|
+
|
|
100
|
+
Output lands in `test-results/` (already gitignored, and Playwright clears it at the
|
|
101
|
+
start of each run): `test-results/results.json` is the machine-readable one —
|
|
102
|
+
`scripts/report.mjs`, emitted with `--report`, aggregates it — while
|
|
103
|
+
`playwright-report/` is the HTML one. Traces are retained on failure only.
|
|
104
|
+
|
|
105
|
+
## `--heaps`: V8 snapshots as diagnostics
|
|
106
|
+
|
|
107
|
+
`--heaps off | stress | all` (default `stress`) is read at scaffold time and baked
|
|
108
|
+
into `profile/_heap.ts`:
|
|
109
|
+
|
|
110
|
+
- `off` — no CDP capture at all. A snapshot of a real app is tens of MB and one is
|
|
111
|
+
taken **either side of every scenario**: an observer that costs more than the thing
|
|
112
|
+
it observes, inside the measurement it brackets.
|
|
113
|
+
- `stress` (default) — keep snapshots for `stress-*` scenarios and for failures.
|
|
114
|
+
- `all` — keep every post-scenario snapshot.
|
|
115
|
+
|
|
116
|
+
Files land at `heaps/<spec>__<test>__<label>.heapsnapshot` with predictable names, so
|
|
117
|
+
you can take them from the filesystem instead of digging through
|
|
118
|
+
`playwright-report/data/<sha1>`, and they are attached to the HTML report too. Format
|
|
119
|
+
is the standard V8 `.heapsnapshot`: Chrome DevTools → Memory.
|
|
120
|
+
|
|
121
|
+
Snapshots are **diagnostics, not the measurement**: an open CDP session can fail for
|
|
122
|
+
reasons unrelated to the component (a page that already navigated, a Playwright and
|
|
123
|
+
Chromium that disagree about a target guid), so `_heap.ts` warns and returns nothing
|
|
124
|
+
rather than failing the spec it was added to observe. If snapshots are missing but
|
|
125
|
+
the run is green, look for the warning.
|
|
126
|
+
|
|
127
|
+
## Debugging the host
|
|
128
|
+
|
|
129
|
+
- **`ReferenceError: <name> is not defined` with no output from the host build** — a
|
|
130
|
+
resolve-plugin fall-through in the host server dropped a bare specifier's module
|
|
131
|
+
onto a variant that lacks that export (`msw`'s named imports were the casualty).
|
|
132
|
+
Run `bun host/server.ts` from the suite dir to see the build; check the plugin's
|
|
133
|
+
`args.path` gates, not the spec.
|
|
134
|
+
- **A component is not in the host at all** — REPORT.md's components table says why
|
|
135
|
+
it is not mountable. Server components cannot mount in this host, and a compound
|
|
136
|
+
part may only be reachable through its parent. Do not write a spec for what the
|
|
137
|
+
host refuses to mount; fix the mountability.
|
|
138
|
+
- **A spec times out in `waitForFunction` waiting for `window.__profile.mount`** — the
|
|
139
|
+
host never booted (see the resolve trap) or `host/main.tsx` was edited into a state
|
|
140
|
+
where its boot script throws. Its edits are yours forever, so `git diff` it first.
|
|
141
|
+
- **`[flawless] react-scan disabled`** on React 17 — react-scan cannot instrument it;
|
|
142
|
+
render counts still come from the Profiler, so the times are real and the
|
|
143
|
+
unnecessary-rerender column is not.
|
|
144
|
+
|
|
145
|
+
## Under `--expo`
|
|
146
|
+
|
|
147
|
+
`perf-harness` **refuses** under `--expo`, by a measured decision (~0 host-mountable
|
|
148
|
+
components on an all-route-screens app), and the refusal names the recorded evidence.
|
|
149
|
+
It is a failed `TaskResult`, so it appears in `results[]`, the `--ci` blob and
|
|
150
|
+
REPORT.md. Do not work around it by dropping `--expo`: a DOM host pointed at React
|
|
151
|
+
Native produces specs that run, pass, and touch nothing. See flawless-expo.
|
|
152
|
+
|
|
153
|
+
## Never
|
|
154
|
+
|
|
155
|
+
- Never call a green perf run "within budget": budgets warn.
|
|
156
|
+
- Never compare a one-worker number against a parallel one.
|
|
157
|
+
- Never read load-test p95s out of this plane, or claim a perf baseline this tool
|
|
158
|
+
does not keep.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: flawless-perf-static
|
|
3
|
+
description: Read and act on the findings from flawless-react's own static performance analyser — the nine `perf/*` rules (set-state-in-render, unstable-child-prop, hook dependency drift, missing-memo-leaf, unresolved-prop), how the analyser's conservatism shapes a clean report, the sidecar and per-component report files, and which findings are worth fixing versus suppressing. Use when triaging `reports/<Component>.perf-static.json`, when a `perf/unresolved-prop` finding means the generated spec passes `undefined`, or when deciding whether a memoisation finding is real. For runtime numbers see flawless-perf-harness; for a11y and React-rule findings see flawless-a11y and flawless-react-rules.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# flawless perf-static
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
bunx @lariocpt/flawless-react "src/**/*.tsx" --tasks perf-static --no-inline-summary
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`perf-static` reports; it scaffolds nothing to run. Findings print as
|
|
13
|
+
`file:line:col rule message`, land in `flawless/reports/<Component>.perf-static.json`
|
|
14
|
+
(one file per component that owns findings), and are summarised in REPORT.md and the
|
|
15
|
+
`--ci` blob. A `<Component>.flawless.json` sidecar lands beside each analysed source
|
|
16
|
+
unless `--no-inline-summary`; its paths are relative to the sidecar, so resolve them
|
|
17
|
+
against the sidecar's own directory.
|
|
18
|
+
|
|
19
|
+
Each report file carries two numbers beside its findings: `reactCompiler` (the
|
|
20
|
+
state the memo rules were judged against, with the config that proved it) and
|
|
21
|
+
`specQuality` (that component's props no fixture could synthesise). Both are why a
|
|
22
|
+
zero in the file means what it means — read them before concluding anything from an
|
|
23
|
+
empty `findings`.
|
|
24
|
+
|
|
25
|
+
Per-component report files only appear for components with findings, so **stale
|
|
26
|
+
per-component files from earlier runs linger in `reports/`.** Count from the task
|
|
27
|
+
summary or REPORT.md, never from a glob over the directory.
|
|
28
|
+
|
|
29
|
+
## The nine rules
|
|
30
|
+
|
|
31
|
+
| rule | severity | what it means |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| `perf/set-state-in-render` | **error** | unguarded `setState` during render — always real, fix first |
|
|
34
|
+
| `perf/hook-missing-dependency` | warn | an effect/callback reads something not in its deps — stale closure |
|
|
35
|
+
| `perf/hook-extraneous-dependency` | warn | a dep that is never read — the effect fires for nothing |
|
|
36
|
+
| `perf/hook-missing-deps-array` | warn | a hook that takes a dep list is called without one |
|
|
37
|
+
| `perf/unstable-child-prop` | warn | inline object/array/arrow handed to a component child |
|
|
38
|
+
| `perf/no-memoisation-with-children` | warn | a component taking `children` is memoised where it cannot help |
|
|
39
|
+
| `perf/missing-memo-leaf` | warn | a leaf that re-renders on every parent render, not memoised |
|
|
40
|
+
| `perf/unresolved-prop` | *spec quality* | the analyser could not synthesise a default for a prop — see below |
|
|
41
|
+
| `perf/effect-every-render` | warn | an effect that runs on every render |
|
|
42
|
+
|
|
43
|
+
Triage in that order of trust.
|
|
44
|
+
|
|
45
|
+
- **`set-state-in-render`** is the only error the analyser emits and the only one
|
|
46
|
+
that is never a style preference.
|
|
47
|
+
- **Hook deps.** `hook-extraneous-dependency` usually means the dep is never read:
|
|
48
|
+
remove it, or make it real — a scroll-on-change effect that never touches
|
|
49
|
+
`messages.length` is fixed by reading it (`if (messages.length === 0) return;`),
|
|
50
|
+
not by suppressing. `hook-missing-dependency`: wrap the function in `useCallback`
|
|
51
|
+
and add it.
|
|
52
|
+
- **`unstable-child-prop`** is a real win when the receiving child is memoised or
|
|
53
|
+
heavy (a memo'd list, a third-party `<Sonner>`). Hoist static objects and arrays
|
|
54
|
+
to module scope. An inline arrow handed to a plain DOM element costs nothing —
|
|
55
|
+
leave it.
|
|
56
|
+
- **The advisory tail** (`no-memoisation-with-children`, `missing-memo-leaf`,
|
|
57
|
+
`effect-every-render`) is a scale, not a bug list. Fix where the runtime harness
|
|
58
|
+
agrees (flawless-perf-harness); do not chase it to zero.
|
|
59
|
+
- **`perf/unresolved-prop` is not a performance finding, and is not counted as
|
|
60
|
+
one.** It is the only one of the nine reported apart: bucketed per component under
|
|
61
|
+
REPORT.md's `## Spec quality` heading with the prop names, and never in the task's
|
|
62
|
+
finding number. It says the analyser could not invent a value, so the generated
|
|
63
|
+
spec passes `undefined` for that prop — a limit of this tool's fixtures, not a
|
|
64
|
+
rerender risk in your app. Fix it with `--props '{"x":1}'` or by editing that
|
|
65
|
+
spec's `DEFAULTS`, and expect that component's specs to stay weak until you do.
|
|
66
|
+
Counted per prop it was **713 of the dogfood run's 926 findings**, one button alone
|
|
67
|
+
contributing 148, with the 127 findings that needed someone buried under it.
|
|
68
|
+
|
|
69
|
+
## A clean report is not proof
|
|
70
|
+
|
|
71
|
+
The analyser is deliberately conservative where it would otherwise be wrong: it
|
|
72
|
+
**skips hook-dependency findings when the dependency list contains a member access
|
|
73
|
+
or a spread**, because it cannot resolve those without a type checker. So a file
|
|
74
|
+
using `useEffect(fn, [state.items])` produces nothing at all here — absence of
|
|
75
|
+
findings means "not checked", not "correct".
|
|
76
|
+
|
|
77
|
+
The same conservatism that protects the report shapes the specs: everything
|
|
78
|
+
`perf-static` knows comes from the same static read the unit specs are built from
|
|
79
|
+
(`analyseTree()`), no type checker, no execution.
|
|
80
|
+
|
|
81
|
+
## React Compiler changes what these findings mean
|
|
82
|
+
|
|
83
|
+
On a project the compiler runs on, memoisation is the compiler's job: measured on
|
|
84
|
+
one native app, **136 of 168 perf-static findings were the three memoisation
|
|
85
|
+
rules** (`docs/archive/2026-08-expo-probes.md`). Telling someone to hand-write a
|
|
86
|
+
`memo()` wrapper the build already applied is not advice worth printing, so
|
|
87
|
+
`perf-static` reads the state off the project's own configs — in **every** mode,
|
|
88
|
+
not only `--expo` — and suppresses `perf/unstable-child-prop`,
|
|
89
|
+
`perf/missing-memo-leaf` and `perf/no-memoisation-with-children` when it is on.
|
|
90
|
+
|
|
91
|
+
| state | read from | what the task does |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| **on** | `app.json` `experiments.reactCompiler`; `next.config.*` `reactCompiler`; a babel or recognised bundler config naming `babel-plugin-react-compiler` | the three memo rules are suppressed, the other six run, and REPORT.md names the config that proved it |
|
|
94
|
+
| **off** | a config was read, and it does not turn the compiler on | all nine rules run, and nothing is said about the compiler |
|
|
95
|
+
| **undetermined** | nothing readable | all nine run, and the summary says the memo findings may be pre-compiled noise |
|
|
96
|
+
|
|
97
|
+
Three things this deliberately does not do:
|
|
98
|
+
|
|
99
|
+
- **It does not infer the flag from a dependency.** Next ships
|
|
100
|
+
`babel-plugin-react-compiler` in its own dependency tree, so a lockfile grep
|
|
101
|
+
reports "compiler on" for apps that never enabled it — and would have
|
|
102
|
+
suppressed 127 valid findings on the run that found this bug. Config only.
|
|
103
|
+
- **It does not model per-file bail-outs.** A component that violates the Rules of
|
|
104
|
+
React is not compiled, so in principle its memo findings survive. The
|
|
105
|
+
`react-rules` task owns that detection; these flags are global, and perf-static
|
|
106
|
+
treats compiler state as global rather than guessing per file.
|
|
107
|
+
- **It does not suppress quietly.** The suppression, its per-rule counts and the
|
|
108
|
+
evidence string are in the task summary, in REPORT.md, in the `--ci` blob and in
|
|
109
|
+
every `<Component>.perf-static.json`. A suppressed zero has to read differently
|
|
110
|
+
from a zero that means nothing was found — same reason the native a11y zero
|
|
111
|
+
carries its denominator.
|
|
112
|
+
|
|
113
|
+
So on a compiler project the findings you still get are the ones the compiler has
|
|
114
|
+
no opinion about: `set-state-in-render`, the three hook-dependency rules,
|
|
115
|
+
`effect-every-render` and `unresolved-prop`. On a compiler-off project
|
|
116
|
+
`unstable-child-prop` is a real win when the receiving child is memoised or heavy,
|
|
117
|
+
and memoising it by hand is still the fix.
|
|
118
|
+
|
|
119
|
+
## Under `--expo`
|
|
120
|
+
|
|
121
|
+
`perf-static` runs on React Native the same way it runs on DOM — same rules, same
|
|
122
|
+
report files. What changes is the compiler question above, and that "heavy child"
|
|
123
|
+
usually means a list row rather than a widget. See flawless-expo.
|
|
124
|
+
|
|
125
|
+
## Never
|
|
126
|
+
|
|
127
|
+
- Never suppress a `perf/*` finding without a reason clause, and never to make a
|
|
128
|
+
batch green.
|
|
129
|
+
- Never quote a zero for a file whose deps the analyser could not resolve.
|
|
130
|
+
- Never diff `reports/` to count findings: per-component files from earlier runs
|
|
131
|
+
survive. Use the task summary.
|