Skip to content

Clear 127 lint findings and gate lint in CI - #22

Merged
fylorn merged 2 commits into
mainfrom
dev
Sep 14, 2026
Merged

fylorn merged 2 commits into
mainfrom
dev

Conversation

@fylorn

@fylorn fylorn commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

What

pnpm lint goes 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@7 was a dependency from day one and lint was 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.tsx built its provider list with live?.providers ?? [], which allocates a fresh array on every render while live is null. Both useMemos below it were keyed on that array, so neither had ever memoised anything.
  • A rethrow that dropped its 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 new useNow now, so they actually 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. One lazy useState now. 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 are useCallback-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-deps was 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.md rather than repeated eighteen times. Loads here are hand-rolled — a useCallback that fetches and sets state, plus an effect that calls it — and set-state-in-effect fires 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]) where load is async and its first statement is the await. Every setState inside runs in the continuation — never synchronously with the effect, never a cascading render. The rule's cross-function analysis does not model await.

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

  • A leading underscore already meant "deliberately unused" in this codebase. no-unused-vars knows that now instead of reporting the convention as five defects.
  • 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, and the next shadcn add would put them back.

🤖 Generated with Claude Code

fylorn and others added 2 commits September 14, 2026 17:04
`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>
@fylorn
fylorn merged commit dd4d6b0 into main Sep 14, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant