Skip to content

Leave ESLint 8: flat config on ESLint 9, with oxlint as the first pass - #522

Open
Pixnop wants to merge 2 commits into
devfrom
chore/513-eslint9-oxlint
Open

Pixnop wants to merge 2 commits into
devfrom
chore/513-eslint9-oxlint

Conversation

@Pixnop

@Pixnop Pixnop commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

ESLint 8 has been end of life since 2024-10-05, so the move off it was due whatever we decided about oxlint. Issue #513 measured both halves; this delivers them as two commits that can be reviewed, and merged, apart.

What changes

Commit 1, "Move ESLint to 9 with a flat config". eslint.config.mjs replaces .eslintrc.cjs and .eslintignore. It reproduces the old config rule for rule, including what the two @electron-toolkit shareable configs used to supply: they ship the legacy format only, so their env/parser block, their @typescript-eslint rule set and their *.js carve-out for explicit-function-return-type are spelled out here instead. The src/domain import guard keeps its paths and patterns unchanged, and the thirteen-file downgrade of react-hooks/exhaustive-deps to a warning survives with its comment. An override for scripts/headless/**/*.mjs comes across from #511 with the same wording; the glob is harmless while that folder is absent. lint and lint:ci lose --ext, which a flat config does not take, and gain --cache under node_modules/.cache/eslint.

Three deliberate differences, all in the commit message:

  • @typescript-eslint/no-require-imports is switched off alongside the no-var-requires it replaced in typescript-eslint 8, which is what keeps the CommonJS helpers in scripts/ passing.
  • eslint-plugin-prettier is gone. prettier/prettier was a warning nobody could fail on, and the same CI job already runs npm run format:check over the whole tree. eslint-config-prettier stays, so ESLint still keeps out of Prettier's way.
  • coverage/ joins the ignore list. eslint . walked into the generated HTML report before, found nothing in it, and only spent time doing so.

Commit 2, "Run oxlint before ESLint". oxlint 1.83.0 pinned as a devDependency, .oxlintrc.json written by hand (@oxlint/migrate ports two rules and drops every extends and both overrides blocks), and eslint-plugin-oxlint wired into the flat config so ESLint stops checking what oxlint now covers. lint:ci is oxlint && eslint, lint is both with their fix flags. tests/config/lint-guards.test.ts seeds a violation of each architectural guard and asserts the pair of linters reports it and that one of the two exits non-zero.

Handing rules to oxlint is where this commit can lose things quietly, so three points are set deliberately rather than by default:

  • Severity is set for the whole correctness category, not only for the rules named one by one. oxlint defaults that category to a warning and exits non-zero on errors only, so at the default the 107 rules eslint:recommended and typescript-eslint used to fail CI on would still be printed but would gate nothing, since eslint-plugin-oxlint hands every one of them over. "categories": { "correctness": "error" } gives them back the severity ESLint gave them.
  • no-unsafe-optional-chaining and explicit-function-return-type on .jsx, .cjs and .mjs stay with ESLint, because oxlint cannot express them the way the old config did. Details under Parity.
  • The blocks eslint-plugin-oxlint returns are filtered by name, not by whether they carry an ignores key. Two of the six carry one, and the second turns no-unused-vars and rules-of-hooks off for everything that is not .vue, .svelte or .astro; dropping it along with the ignore-patterns block left those two enabled on the ESLint side while oxlint already denied them.

Parity

npm run lint:ci reports 0 errors and the same 14 react-hooks/exhaustive-deps warnings, at the same file and line as on dev, at every step: before, after commit 1, and after commit 2. The sorted lists were diffed each time and come out identical, and each warning is reported once, not once per linter.

Three rules stay with ESLint, on purpose:

  • react-hooks/exhaustive-deps. oxlint flags the same 13 files but produces 15 findings, at different lines and columns (StickyMenu.tsx splits into three where ESLint sees two). "The 14 known warnings" is the number we read our lint output against, so the rule is "off" in .oxlintrc.json and re-enabled after eslint-plugin-oxlint in the flat config.
  • no-unsafe-optional-chaining. eslint:recommended had it at error and it reports nothing on this tree, because ESLint stops at the as cast the four oxlint findings hide behind. Keeping it on the ESLint side preserves the old severity without pulling those four in. Its key in .oxlintrc.json is spelled without the eslint/ prefix on purpose: eslint-plugin-oxlint reads a bare rule name as an opt-out and leaves the rule alone, but does not recognise the prefixed form and would switch the rule off on both sides.
  • @typescript-eslint/explicit-function-return-type for .jsx, .cjs and .mjs. oxlint's implementation only fires on TypeScript files, while the old config exempted plain *.js and nothing else. Handing the rule over wholesale would have dropped those three extensions by accident rather than by decision, so ESLint keeps them (minus the scripts/headless/**/*.mjs carve-out, which is spelled out on both sides).

no-unused-vars does move to oxlint, but had to be raised to an error there. @typescript-eslint/recommended had it as an error and oxlint defaults it to a warning, so without that line an unused variable would have quietly stopped failing CI.

One consequence of splitting rules across two linters: unused-disable-directive reporting is off on the ESLint side, which ESLint 9 turns on by default. Neither linter can judge a directive it does not own. ESLint sees five no-explicit-any directives it no longer runs a rule for, and oxlint sees the five exhaustive-deps directives in ListMods.tsx for the same reason.

Verification

Every rule this change moves, restores or re-severities was checked from a seeded violation, one rule per file, on the final tree. In each case exactly one of the two linters reports it and exits non-zero, so npm run lint:ci fails; deleting the file returns the command to 0 errors and the 14 known warnings.

Rule Seeded violation Reported by lint:ci
react/jsx-key .map returning <li> with no key oxlint fails
use-isnan x === NaN oxlint fails
no-debugger a debugger statement oxlint fails
no-unused-vars an unused const oxlint fails
react-hooks/rules-of-hooks useState inside an if oxlint fails
no-restricted-imports a src/domain file importing electron oxlint fails
no-unsafe-optional-chaining (o?.a).b ESLint fails
@typescript-eslint/explicit-function-return-type an untyped export function, once in .mjs, .cjs and .jsx each ESLint fails
react-hooks/exhaustive-deps the 14 already in the tree ESLint warns, unchanged

The first three stand for the correctness category as a whole: they were advisory warnings at oxlint's default severity and exit 0, and they fail the command now.

tests/config/lint-guards.test.ts is the standing check on the two architectural guards. It deliberately does not care which linter reports one, only that the pair does and that one of the two exits non-zero, so a rule moving between them again stays covered. The exit code matters as much as the rule name: a guard demoted to a warning still prints its name while lint:ci stays green, which is the case the earlier version of the test missed. Both halves were verified by breaking them: dropping a guard on one side keeps the test passing (correct, the other side still fails the command), dropping it on both fails, and demoting it to a warning on the only side that has it now fails too. It runs in 1 s with nothing skipped on Windows. Its per-test timeout is 30 s, which is headroom rather than runtime: the work is short-lived processes, and on the Windows runner they start while the rest of the suite is still going, which the default 5 s did not survive. Same reason vitest.config.ts already gives the renderer-dom project 15 s.

Timings

Median of three, Node 22, same machine and tree.

Cold Warm (ESLint cache)
lint:ci on dev (ESLint 8.57) 9.09 s n/a
lint:ci after commit 1 (ESLint 9, flat) 5.14 s 0.53 s
lint:ci after commit 2 (oxlint then ESLint) 4.35 s 0.66 s
oxlint's own pass 0.17 s

The big drop is the flat config plus dropping eslint-plugin-prettier, not oxlint. oxlint adds 0.17 s and takes about 0.6 s of work off ESLint; ESLint still has to parse every renderer file for exhaustive-deps, so it cannot get out of the way entirely while that rule stays here. The honest summary is that commit 2 buys a fast fail on the rules that actually gate a merge, and the groundwork for ESLint to shrink further, rather than a large number today.

What is deliberately off

Five rules oxlint turns on by itself report 41 findings our ESLint config never looked for: react/set-state-in-effect (23), react/refs (14), and one each of react/no-did-update-set-state, react/immutability and react/globals. All five are "off" in .oxlintrc.json with a comment, so this PR stays a migration rather than doubling as a code sweep.

They are listed with counts, examples and a first judgement in #521, along with the four no-unsafe-optional-chaining hits oxlint sees and ESLint does not (the optional chain is cast before it is reached through, and ESLint stops at the TSAsExpression; oxlint looks through it). Those four are genuine, and the rule itself stays enforced here on the ESLint side at the severity it had, so nothing is lost while they wait for triage.

oxlint's parser also emits two false TS(1038) diagnostics on src/global.d.ts, on declare inside an already-ambient block. tsc accepts the file. They carry no rule name, so there is nothing to switch off and that one file is skipped, with the reason written next to it.

Known limits

npm run lint no longer fixes formatting. @electron-toolkit/eslint-config-prettier extended plugin:prettier/recommended, so the old lint --fix reformatted files on the way through and lint:ci reported formatting as warnings. Only eslint-config-prettier stays. CI is unaffected, since .github/workflows/ci.yml runs npm run format:check in the same job right after lint:ci, but a contributor who runs npm run lint alone now has to run npm run format as well.

Two rules were lost to the ESLint 8 to 9 jump rather than to oxlint. no-inner-declarations was an error under eslint:recommended in ESLint 8, is not in ESLint 9's recommended set and is not in oxlint's config either, so a function declared inside an if block is now reported by nothing (checked on a probe file: both linters silent, exit 0). @typescript-eslint/ban-types was removed in typescript-eslint 8 and split into narrower rules that recommended does not all carry. Neither is a regression this PR introduces, and neither is switched back on here; they belong with the rest of the triage in #521.

oxlint && eslint hides ESLint's findings whenever oxlint errors. With &&, one oxlint error stops the run and ESLint never executes, so a contributor fixes one tool's list, reruns, and only then discovers the other's. The old single command printed everything at once. Running ESLint first, or a semicolon plus an exit-code roll-up, would keep it to one pass per fix; it is left as is here because the shape matters most on a first failing run and the fast linter failing first is what buys the quick feedback.

If you would rather not take oxlint

Drop the second commit. The first stands alone: ESLint 9, flat config, same 14 warnings, same guards, lint:ci at 5.1 s cold and 0.53 s warm. That was checked by building the first commit's tree on its own and running lint:ci against it, not just assumed. Nothing in commit 1 mentions oxlint.

Testing

Full gate green on the final tree:

  • npm run typecheck (node, web, tests): clean
  • npm run lint:ci: 0 errors, 14 warnings
  • npm run format:check: clean
  • npm run test:coverage: 243 files, 4462 passed, 2 skipped; 94.57% statements, 91.01% branches, 95.22% functions, 96.26% lines, all above the configured floors
  • npm run build: clean

The same gate was run and green on commit 1 before commit 2 was written (242 files, 4461 passed).

.github/workflows/ci.yml needs no change: the lint job already runs npm run lint:ci and caches nothing of its own.

Closes #513

ESLint 8 reached end of life on 2024-10-05 and the repository still ran it
through a legacy .eslintrc.cjs, so the move was due whatever we decide about
oxlint. eslint.config.mjs reproduces the old config rule for rule: the
env/parser block and @typescript-eslint rule set that the two
@electron-toolkit shareable configs supplied (they ship the legacy format
only, so what they set is now spelled out), the no-restricted-imports guard
that keeps src/domain free of Electron, Node and React, the *.js carve-out for
explicit-function-return-type, and the per-file downgrade of
react-hooks/exhaustive-deps to a warning.

Three deliberate differences:

- @typescript-eslint/no-require-imports is switched off alongside the
  no-var-requires it replaced in typescript-eslint 8, which is what keeps the
  CommonJS helpers under scripts/ from failing.
- eslint-plugin-prettier is gone. prettier/prettier was a warning nobody could
  fail on, and `npm run format:check` already runs Prettier over the whole tree
  in the same CI job. eslint-config-prettier stays, so ESLint still keeps out
  of Prettier's way.
- coverage/ joins the ignore list. `eslint .` walked into the generated HTML
  report before, found nothing, and only spent time doing so.

An override for scripts/headless/**/*.mjs carries over from the open headless
harness branch; the glob is harmless while that folder is absent.

lint and lint:ci lose --ext, which a flat config does not take, and gain
--cache with its location under node_modules/.cache/eslint (already ignored by
git).

Parity: lint:ci reports the same 14 react-hooks/exhaustive-deps warnings at the
same file:line as before, and 0 errors. Each guard was proven to still fire by
seeding a violation and removing it: a src/domain file importing electron and
node:path, a hook called conditionally, an explicit any, a missing return type.

Wall time for lint:ci: 9.1 s before, 5.1 s cold, 0.53 s warm.
@Pixnop
Pixnop requested a review from Zaldaryon September 20, 2026 13:39
@Pixnop
Pixnop force-pushed the chore/513-eslint9-oxlint branch from 4cccce8 to 9594ec4 Compare September 20, 2026 13:46
oxlint 1.83.0 reads the whole tree in 0.17 s and already implements most of what
we lint for. It runs first, ESLint runs after it, and eslint-plugin-oxlint
switches off in ESLint every rule .oxlintrc.json now enforces so nothing is
reported twice. lint:ci fails if either linter fails.

.oxlintrc.json is written by hand: @oxlint/migrate ports two rules and drops
every extends and both overrides blocks. It enables only what ESLint already
enforced, at the severity ESLint gave it, including the src/domain
no-restricted-imports guard with its original paths and patterns, and the two
per-folder explicit-function-return-type carve-outs.

Severity is set for the correctness category as a whole, not only for the rules
named one by one. oxlint defaults that category to a warning and exits non-zero
on errors only, so at the default the 107 rules eslint:recommended and
typescript-eslint used to fail CI on would have been reported without gating
anything, since eslint-plugin-oxlint hands every one of them over to oxlint.
"categories": { "correctness": "error" } gives them back the severity they had.

Two rules do not move:

- react-hooks/exhaustive-deps stays with ESLint. oxlint flags the same 13 files
  but produces 15 findings at different lines and columns, and "the 14 known
  warnings" is the number this repository reads its lint output against.
- no-unsafe-optional-chaining stays with ESLint as well. eslint:recommended had
  it at error and it reports nothing on this tree, because ESLint stops at the
  `as` cast that the four oxlint findings hide behind. Its key in .oxlintrc.json
  is deliberately spelled without the eslint/ prefix: eslint-plugin-oxlint reads
  a bare rule name as an opt-out and leaves the rule to ESLint, but does not
  recognise the prefixed form and would switch it off on both sides.

no-unused-vars does move to oxlint, and has to be raised to an error there,
because @typescript-eslint/recommended had it as an error and oxlint defaults it
to a warning.

ESLint also keeps explicit-function-return-type for .jsx, .cjs and .mjs. oxlint's
implementation only fires on TypeScript files, while the old config exempted
plain *.js and nothing else, so handing the rule over wholesale would have
dropped those three extensions by accident rather than by decision.

The blocks eslint-plugin-oxlint returns are filtered by name rather than by
whether they carry an ignores key. Two of the six carry one, and the second turns
no-unused-vars and rules-of-hooks off for everything that is not .vue, .svelte or
.astro: dropping it along with the ignore-patterns block left those two enabled
on the ESLint side while oxlint already denies them.

Unused-disable-directive reporting is off on the ESLint side, which ESLint 9
turns on by default. With the rules split across two linters neither one can
judge a directive it does not own: ESLint sees five no-explicit-any directives
it no longer runs a rule for, and oxlint sees five exhaustive-deps directives in
ListMods.tsx for the same reason.

Every rule oxlint turns on by itself that reports beyond the 14 is switched off:
react/set-state-in-effect (23), react/refs (14), react/no-did-update-set-state,
react/immutability, react/globals. They are triaged in their own issue rather
than fixed blind here. oxlint's parser also emits two false TS(1038) diagnostics
on src/global.d.ts, which tsc accepts; those are not rule violations, so that one
file is skipped.

tests/config/lint-guards.test.ts seeds a violation of each guard from a fixture
under tests/fixtures/lint and asserts that the pair of linters reports it and
that one of the two exits non-zero. Which one does is left open, so a rule moving
between them again stays covered. The exit code matters as much as the rule name:
a guard demoted to a warning still prints its name while lint:ci stays at exit 0.
Both halves were checked by breaking them. Runs in 1 s.

Union parity: lint:ci still reports the same 14 react-hooks/exhaustive-deps
warnings at the same file:line, once each, and 0 errors. Every rule this change
moves or restores was proven to fail the command from a seeded violation:
jsx-key, use-isnan, no-debugger, no-unused-vars, rules-of-hooks and the src/domain
import guard through oxlint, no-unsafe-optional-chaining and
explicit-function-return-type on .jsx/.cjs/.mjs through ESLint.

Wall time for lint:ci: 9.1 s on ESLint 8, 5.1 s cold on ESLint 9 alone, 4.3 s
cold with oxlint in front, 0.66 s warm. oxlint's own pass is 0.17 s.

CI needs no change: the lint job already runs npm run lint:ci and caches nothing.
@Pixnop
Pixnop force-pushed the chore/513-eslint9-oxlint branch from 9594ec4 to 51c3192 Compare September 20, 2026 14:18
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