Skip to content

Restore frontend linting with ESLint 10 - #9

Open
turegjorup wants to merge 9 commits into
release/3.0.0from
feature/eslint-frontend-linting
Open

Restore frontend linting with ESLint 10#9
turegjorup wants to merge 9 commits into
release/3.0.0from
feature/eslint-frontend-linting

Conversation

@turegjorup

Copy link
Copy Markdown

Restores frontend linting, dropped in the 3.0 monorepo consolidation, as a CI gate.

Changes

  • ESLint 10 flat config (eslint.config.mjs) with @eslint-react, eslint-config-prettier
  • Pins @types/react to 18 via npm overrides so type resolution is deterministic
  • Bumps TypeScript to 5.9
  • Clears the errors this surfaced: unused vars, missing React keys, unsafe optional chaining,
    prototype access, ~21 stale 2.x eslint-disable directives
  • Renames getXxxColumns helpers to useXxxColumns (they call hooks)
  • Adds the lint task and CI workflow, documented in the README

Why

eslint-plugin-react peer-caps at ESLint 9, so the modern successor @eslint-react is used
instead; its recommended config bundles the hooks rules.

Two classes are left as warnings rather than blocking this PR: exhaustive-deps (181) and the
leaked timer/fetch/listener rules. Conditional-hook errors are suppressed with @TODO markers.
Follow-ups in a comment below.

Linting was dropped in the 3.0 monorepo consolidation; both 2.x repos
carried ESLint. This restores the gate with a modern flat-config stack:
ESLint 10 plus @eslint-react, which supersedes eslint-plugin-react and
bundles its own hooks rules.

exhaustive-deps is error-level in assets/client and assets/shared (the
screen client is the production-bug surface) and warn elsewhere pending
burn-down.

typescript is bumped to ^5.9 (required by @eslint-react's TS API usage)
and @types/react is pinned to ^18 with an overrides entry, so the React
types resolve deterministically against the react@18 runtime.
The directives reference airbnb-era rules (react/prop-types, jsx-a11y/*,
import/*, no-restricted-syntax, ...) that the flat config does not carry,
so ESLint reports them as unused directives or missing rule definitions.
Comment removals only.
Drops dead imports (React under the automatic JSX runtime, JSX imported
only for a JSDoc reference, unused Playwright fixtures), dead destructured
parameters, and dead initialisers, and switches two unused catch bindings
to the optional catch binding.

Also sets no-unused-vars' ignoreRestSiblings back to true: ESLint 10
flipped the default, and destructuring a prop out before spreading the
rest is the omit-a-prop pattern used throughout the admin HOCs.
Adds keys to mapped lists in the poster selector, the dropzone rejection
list and the screen-layout template builder. In the react-table wrapper
the key already came from getHeaderGroupProps()/getRowProps()/
getCellProps() via prop spreading; it is now destructured out and passed
explicitly, which is also what React 18.3 wants.
…nt errors

Drops a redundant optional chain in the news-feed template (the branch is
already guarded by an instanceof check), swaps hasOwnProperty for
Object.hasOwn in enhanced-api, and adds justified suppressions where the
rules do not fit the library APIs: createRef builds one ref per item for
react-transition-group's nodeRef, and react-table's Cell is a column
renderer, not a nested component.
The column helpers call useTranslation/useContext, so they are custom
hooks; the get-prefix hid that from rules-of-hooks. They are already
called unconditionally at the top level of the column HOCs and list
components, so this is a rename only.
media-modal and the RSS template call useEffect after an early return, so
hook order changes between renders. Both are real defects that need a
restructure plus regression tests, which is out of scope for a tooling
change — suppressed with a @todo so the gate can go green.
region and touch-region now depend on regionId, which is derived from the
region prop and fixed for a mounted region, so the listener registration
and the regionReady event stay correct if it ever changes.

The remaining client findings are suppressed with a rationale: the App
boot sequence must run exactly once, and the two region effects that read
currentSlide would change when queued slides swap in — that needs its own
change with client tests.

Slide templates (assets/shared/templates, custom-templates-example) build
a BaseSlideExecution on every render and start it from a mount-only
effect; satisfying the rule there means reworking the slide lifecycle
across every template, so they stay at warn for now.
@turegjorup

Copy link
Copy Markdown
Author

Follow-up notes — fallout from this PR

File these as issues, then replace the @TODO markers in the code with the issue numbers.

Blocking-ish (real defects, suppressed to get the gate green)

  1. Conditional useEffect — hooks after an early return.
    assets/admin/components/media-modal/media-modal.jsx (after if (!show) return) and
    assets/shared/templates/rss.jsx ×2 (after if (!slide?.feed) return). Hook order changes
    between renders, which is a crash-under-reorder path. Needs a restructure plus regression
    tests. Suppressed with // eslint-disable-next-line @eslint-react/rules-of-hooks and a
    @TODO.

  2. region.jsx stale closures on currentSlide (2 effects, lines ~152 and ~163).
    The 523 secondary-defect family. Adding the dependency changes when queued slides swap
    in — observable client behaviour, so it needs its own PR with client tests. Suppressed with
    a @TODO.

  3. Leaked timers / fetches / listeners in effects — 9 web-api-no-leaked-timeout,
    8 web-api-no-leaked-fetch, 2 web-api-no-leaked-event-listener. Currently warnings.
    Matters for a 24/7 kiosk client on Pi hardware; each needs a considered fix.

Burn-downs (warnings today, promote to error per directory as they clear)

  1. exhaustive-deps in assets/admin — 143 warnings.

  2. exhaustive-deps in the slide templates (assets/shared/templates,
    assets/shared/custom-templates-example) — every template builds a BaseSlideExecution on
    every render and starts it from a mount-only effect, so satisfying the rule means reworking
    the slide lifecycle across all templates. Scoped to warn in eslint.config.mjs with a
    comment; the plan had assumed these were individually fixable.

  3. @eslint-react/purity (11) and dom-no-dangerously-set-innerhtml (2),
    dom-no-unsafe-iframe-sandbox (2).

From the plan

  1. eslint-plugin-jsx-a11y warn-first phase. The 2.x config had it and the admin UI is
    plausibly in scope for the EU Web Accessibility Directive.

Items 7, 9 and 10 concern the type-check ratchet and are on that PR instead.

@turegjorup

Copy link
Copy Markdown
Author

CI gate strengthening, ranked

Evaluation of static analysis, linting, code-quality tools and testing frameworks against the
twelve bugs in the recent bug sweep. The ranking is driven by the root-cause decomposition below,
not by tool feature lists — several heavily marketed tools address classes that barely appear in
this bug list.

Root-cause classes in the twelve bugs

Class Bugs
Shape / typo errors 523 root, 522 (never-attached forwardRef), 512 (file)
React state & lifecycle integration 513, 524, 523-secondary
Serialization-contract mismatch 514
Client data-flow logic 515, 517, 507-symptom
Build-time asset resolution 512
Infrastructure config drift 392, 507-root

Cross-cutting: four of the twelve (514, 507, 515, 522) are silent failures — swallowed
rejections, 503→null, dropped data, no-op transitions. That is why they shipped at all.

The ranking

Rank Gate Catches (of these 12) Impl. cost Maint. Verdict
1 Vitest regression tests from the sweep's own per-issue plans 10/12 have deterministic RED tests already specified ~0 marginal low Highest impact by construction — but only locks known bugs
2 Vite build gate: unresolved asset url() → hard failure 512 + entire class ~0.5 d ~0 Best ratio of anything new
3 checkJs ratchet (built, verified) 523 root (TS2551, red/green proven); 522's prop mismatch; a type error in travel.jsx:126 done per-PR marginal The stacked PR; 522 being independently flagged strengthens it
4 Config-alignment tests (PHPUnit) 392; drift class incl. 507's rate-limit numbers hours–1 d ~0 Cheap, and the only gate class for infra drift short of full-stack tests
5 ESLint 10 (built, verified) 523-secondary stale closures at the sweep's exact lines; latent conditional hooks, 17 leaked timers/fetches done low This PR
6 Spec-derived, group-accurate test fixtures 514 + its whole class 2–3 d generator low The sleeper pick — see below
7 Playwright E2E: 3 admin flows 513, 524, 514 end-to-end 2–4 d medium (flake) Highest-cost item still worth doing
8 Silent-failure surfacing converts 514/507/515-type mysteries into reports ~1 d ~0 Not a gate, but multiplies the value of every future bug report
9 Coverage ratchet, hot path only 0 directly 0.5–1 d threshold upkeep Scope it narrowly or not at all
10 stylelint restore none of these 12 0.5 d low Optional hygiene; honestly low impact here

Notes on the ones that need more than a table row

Rank 2 — Vite asset-resolution gate. Vite left url(fontsubik-regular.woff) verbatim in the
bundle with only a warning; a bad search/replace therefore survived a year with a green build. The
fix is to elevate that warning to an error in vite.config.js plus a post-build assertion that
every url() in emitted CSS resolves to an emitted asset.
Implementation caveat: this repo runs rolldown-vite with experimental.enableNativePlugin: true, so the stock rollup onwarn hook may not be the right lever — budget an hour to find where
rolldown surfaces the warning before assuming the one-liner works. The post-build assertion is
plumbing-independent and worth having either way.

Rank 6 — Spec-derived fixtures. 514's class is invisible to lint, to checkJs, and to
hand-written mocks
, because a hand mock encodes the same wrong assumption the code does — the
sweep's own test plan hand-builds the response shape and would pass against the bug. Generating
fixtures from public/api-spec-v2.yaml component schemas per serialization group means a test
rendering SlidesButton with a Playlist.jsonld-playlist-screen-region.read-shaped object fails
the moment the code reads .id. The spec is committed, oasdiff-gated and regenerated by
task generate:api-spec, so maintenance is near zero. Only the full TS migration of consumers
matches this, at far higher cost. Build this before rank 7, so the E2E specs rest on honest
fixtures.

Rank 7 — Playwright E2E. The infrastructure already exists (28 specs, CI workflow,
parallelization unblocked via ipc: host in docker-compose.yml). The save-clobbers-form-state
class (513/524) is an integration of RTK invalidation and component state that unit tests
structurally miss.

Rank 9 — Coverage. Currently not configured at all: vitest.config.js has no coverage block
and there is no @vitest/coverage-v8 dependency. If added, scope per-directory thresholds to the
24/7 hot path only — assets/client/service and assets/client/data-sync, i.e.
schedule-service, content-service and pull-strategy, which produced 523, 515 and 517.
Repo-wide percentage gates are pathology-prone.

Evaluated and rejected

  • Mutation testing (Stryker / Infection) — CPU-heavy in CI and premature while the unit
    coverage baseline is still being built (12 test files today). Revisit after ranks 1 and 9 mature.
  • SonarQube / CodeClimate — near-total overlap with PHPStan level 6 + Rector + the new ESLint,
    plus self-hosted infrastructure burden.
  • Visual regression screenshots — the only gate for 511-type issues, but 511 is a product
    default decision (opt-in letterboxing), not a regression, and screenshot-diffing animated kiosk
    templates (522, 391) is a flake generator. The sweep's deterministic jsdom class-assertions
    are strictly cheaper for the same defects.
  • knip / dependency-cruiser — zero relevance to these classes.

Honest gaps

  • 511 has no gate. It needs a product decision on the object-fit default first, then a
    one-line component test.
  • 391 is guarded only by its own component test — no static tool sees per-slide keyframe
    scoping.
  • 507's root cause (nginx rate limiting) is only fully gated by a stack-level test driving a
    6-region pull through real nginx. Ranked the cheap config assertion (rank 4) instead: a
    timing-dependent 503 test has exactly the flake profile that erodes trust in CI. The ApiHelper
    logging from rank 8 is the better long-term detector there.

Suggested sequence

  1. Ranks 2 and 4 immediately — combined under a day, and each closes a whole class.
  2. Rank 1 continues with the fix PRs (it is the TDD step already in the sweep's plans).
  3. Rank 6 before rank 7, so the admin E2E specs are built on spec-derived fixtures rather than
    hand mocks that would encode 514's assumption.

@tuj tuj left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review

Verdict: approve with changes. Solid PR — clean commit separation, honest scoping, real defects found. The overrides block is the best-built part: single @types/react 18.3.31 across the tree despite @tiptap/react peering ^17||^18||^19, and react-intl.typescript: "$typescript" handles the optional TS peer properly.

Blocking:

lint:js has no --max-warnings. 181 exhaustive-deps + 19 leak warnings float free, so the burn-down plan has no enforcement. One flag.
.ts is in the glob with no TS parser installed. Green today only because enhanced-api.ts happens to contain no TS syntax — the first annotation added there breaks lint with a parse error, and the checkJs PR invites exactly that. Install typescript-eslint, or drop ts,tsx from script and config.
Commit 45ea7a7 changes screen-client behaviour, untested, in the code that produced 523. Needs a second reviewer; you're sole participant.

Should fix before merge:
4. set-state-in-effect: "off" blinds you to the 513/524 class — your second-largest bug family. Make it "warn".
5. Add linterOptions: { reportUnusedDisableDirectives: "error" } — starts at zero, protects commit 2's cleanup.
6. Config block 2 omits ts,tsx, so enhanced-api.ts sits at warn while its neighbours are error.
7. dom-no-dangerously-set-innerhtml → error now; 2 sites, and react/no-danger was just removed from one.
8. Taskfile: eslint --fix before Prettier, not after.
9. Workflow: npm ci over npm install; add permissions: contents: read and a concurrency group. No engines field while ESLint 10 needs Node ^20.19||>=22.

Notes: @eslint-react peers are typescript: "*", so the commit message's "^5.9 required" isn't a declared constraint. Type-aware machinery (project-service, type-utils, typescript-estree) already ships transitively — enabling recommended-typescript is one package plus config, cheaper than the PR implies. e92edd2 changes reconciliation identity; flag as behavioural. Glob is assets/ only, so config files are ungated — relevant to the 392/507 drift class. Unrelated: @vitejs/plugin-react-oxc is in prod deps, and react-bootstrap 1.6.8 (Bootstrap 4) runs against bootstrap 5.3.7.

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.

2 participants