Conversation
`pnpm lint` reported 127 problems (90 errors) and had reported them for a
long time: `eslint-plugin-react-hooks@7` has been a dependency since the
first commit, and `lint` is not one of the four steps the Frontend Build
job runs. A check with no gate only goes one direction.
Every change here is verified against `tsc -b` (the project build, not a
bare `tsc --noEmit` — that one silently misses project references and let
two broken extractions through), 96 tests, `check:i18n`, and `pnpm build`.
What actually changed, by kind:
**Real defects.** A rethrow that dropped its `cause`. A dead initialiser.
A try/catch that only rethrew. An empty catch with nothing saying why.
`Date.now()` read during render in two countdown labels — those now take
a ticking clock from `useNow`, which also makes them count down instead
of freezing until something else re-renders.
**State that was pretending to be a ref.** `use-wizard-state` held its
session id, resume flag and template slug in refs assigned during render
and read back later. That is the one thing the compiler cannot reason
about: it may discard a render pass, and a ref written only in render
then holds a value that was never committed. They are one lazy
`useState` now. Same fix for two ref mirrors that were assigned during
render rather than in an effect.
**Effects doing state alignment.** Resetting a form when a dialog opens,
clearing a list when its id changes, dropping to page 1 when a search
term changes — all of it ran after paint. The stale pair was on screen
for a frame, and in the pagination case long enough to fire a request
for page 4 of a two-page result and race its response. These use a new
`useResetOnChange`, which is React's documented adjust-during-render
pattern with the comparison written once instead of fifteen times.
**Reads that should be initialisers.** The dismissed-state of the
getting-started card, the mobile breakpoint, the OAuth callback banner.
Each rendered its wrong value once and corrected itself after mount; the
mobile one meant every phone got a frame of desktop layout.
**Nine suppressions, and they are not the same as giving up.** The rule
reports `useEffect(() => { load(); }, [load])` where `load` is async and
its first statement is the `await`. Every setState in such a loader runs
in the continuation — never synchronously with the effect, never a
cascading render. The rule's cross-function analysis does not model
`await`. Each of the nine says that at the site.
**Two config decisions, both written down.** A leading underscore already
meant "deliberately unused" in this codebase, so `no-unused-vars` now
knows that instead of reporting the convention as five defects.
`src/components/ui/` is shadcn output whose house style ships a component
and its variants together; splitting those files does not survive the
next `shadcn add`.
**Not done: 18 `set-state-in-effect` and 34 warnings.** The 18 are one
shape — an effect starts a load whose first statement flips a spinner —
and the fix is a judgement call worth making deliberately rather than in
passing. `lint` is deliberately still not wired into CI; wiring it while
findings remain would only turn the build red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pnpm lint` now exits 0. It reported 127 problems (90 errors) before this series, and had reported them since the first commit — `eslint-plugin-react-hooks@7` was a dependency from day one and `lint` was not one of the steps the Frontend Build job ran. A check with no gate only moves one direction, so the last step here is adding it. This commit finishes what the previous one started. **Dependency arrays, 23 of them.** Eleven were a missing `t`, which is stable and was simply absent. The rest were a missing loader — and those could not just be added, because every one of those loaders was a plain arrow function, so listing it would hand the effect a new identity on each render and spin. Seventeen loaders are wrapped in `useCallback` now, with the dependencies ESLint computes for them, which is also what makes the effects' own arrays honest rather than a `[]` with a suppression on top. That removed nine suppressions, and with them nine React Compiler warnings — it refuses to optimise a component that disables a rule, so each `// eslint-disable exhaustive-deps` was costing a second finding elsewhere. **One real bug found on the way.** `dashboard.tsx` built its provider list with `live?.providers ?? []`, which allocates a fresh array every render while `live` is null. Both `useMemo`s below it were keyed on that array, so neither had ever memoised anything. **Eighteen sites keep a suppression, and the reason is written down once** in a new "Data fetching" section of `web/README.md` rather than eighteen times. Loads here are hand-rolled — a `useCallback` that fetches and sets state, plus an effect that calls it — and the rule fires on the shape. Hoisting the spinner flag out to render time silences it but splits "start a load" across two places and leaves every other caller holding half of it. Both this and the nine `await` cases disappear with a data-fetching layer, which is a deliberate piece of work and not something to fold into a lint pass. The note says so, and says not to add suppressions for any other reason: every other finding this rule reports is real, and the tree is now clean of them. **Two rules are off for `src/components/ui/`,** which is shadcn output. Its sidebar writes `document.cookie` inside a `useCallback`, and its files ship a component next to its variants. Both are upstream's implementation; the next `shadcn add` would put them back. Verified with the CI steps: `pnpm install --frozen-lockfile`, `check:i18n` (1386 keys), `lint` (0), 96 tests, `pnpm build`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
pnpm lintgoes from 127 problems (90 errors) to 0, and is now one of the steps the Frontend Build job runs.It had reported those 127 since the first commit:
eslint-plugin-react-hooks@7was a dependency from day one andlintwas never gated. A check with no gate only moves one direction — which is the actual finding here, and why the last commit adds the gate rather than just the fixes.Every change is verified against the CI steps:
pnpm install --frozen-lockfile,check:i18n(1386 keys),lint, 96 tests,pnpm build.Real defects fixed
dashboard.tsxbuilt its provider list withlive?.providers ?? [], which allocates a fresh array on every render whileliveis null. BothuseMemos below it were keyed on that array, so neither had ever memoised anything.cause. A dead initialiser. A try/catch that only rethrew. An empty catch with nothing saying why. Write-only state that cost a render per load.Date.now()read during render in two countdown labels. They take a ticking clock from a newuseNownow, so they actually count down instead of freezing until something else re-renders.State that was pretending to be a ref
use-wizard-stateheld its session id, resume flag and template slug in refs assigned during render and read back later. That is the one thing the compiler cannot reason about: it may discard a render pass, and a ref written only in render then holds a value that was never committed. One lazyuseStatenow. Same fix for two ref mirrors assigned during render rather than in an effect.Effects that were doing state alignment
Resetting a form when a dialog opens, clearing a list when its id changes, dropping to page 1 when a search term changes — all of it ran after paint. The stale pair was on screen for a frame, and in the pagination case long enough to fire a request for page 4 of a two-page result and race its response. These use a new
useResetOnChange, which is React's documented adjust-during-render pattern with the comparison written once instead of fifteen times.Three reads that should have been initialisers: the dismissed-state of the getting-started card, the mobile breakpoint, the OAuth callback banner. Each rendered its wrong value once and corrected after mount — the mobile one meant every phone got a frame of desktop layout.
Dependency arrays (23)
Eleven were a missing
t. The rest were a missing loader, and those could not simply be added: every one of those loaders was a plain arrow function, so listing it hands the effect a new identity each render and spins. Seventeen loaders areuseCallback-wrapped now with the dependencies ESLint computes, which is what makes the effects' own arrays honest rather than a[]with a suppression on top.That removed nine suppressions and, with them, nine React Compiler warnings — it refuses to optimise a component that disables a rule, so each
eslint-disable exhaustive-depswas costing a second finding somewhere else.What keeps a suppression, and why it is written down once
Eighteen sites, with the reason in a new "Data fetching" section of
web/README.mdrather than repeated eighteen times. Loads here are hand-rolled — auseCallbackthat fetches and sets state, plus an effect that calls it — andset-state-in-effectfires on the shape. Hoisting the spinner flag out to render time silences the rule but splits "start a load" across two places and leaves every other caller of the loader holding half of it.Nine further sites are a different claim:
useEffect(() => { load(); }, [load])whereloadis async and its first statement is theawait. Every setState inside runs in the continuation — never synchronously with the effect, never a cascading render. The rule's cross-function analysis does not modelawait.Both categories disappear with a data-fetching layer (TanStack Query or equivalent), which owns the loading flag and the cache and removes the effect. That is deliberate work, not something to fold into a lint pass. The note says so, and says not to add suppressions of this rule for any other reason — every other finding it reports is real, and the tree is now clean of them.
Config decisions, both commented
no-unused-varsknows that now instead of reporting the convention as five defects.src/components/ui/, which is shadcn output: its sidebar writesdocument.cookieinside auseCallback, and its files ship a component next to its variants. Both are upstream's implementation, and the nextshadcn addwould put them back.🤖 Generated with Claude Code