Conversation
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
force-pushed
the
chore/513-eslint9-oxlint
branch
from
September 20, 2026 13:46
4cccce8 to
9594ec4
Compare
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
force-pushed
the
chore/513-eslint9-oxlint
branch
from
September 20, 2026 14:18
9594ec4 to
51c3192
Compare
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.
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.mjsreplaces.eslintrc.cjsand.eslintignore. It reproduces the old config rule for rule, including what the two@electron-toolkitshareable configs used to supply: they ship the legacy format only, so their env/parser block, their@typescript-eslintrule set and their*.jscarve-out forexplicit-function-return-typeare spelled out here instead. Thesrc/domainimport guard keeps its paths and patterns unchanged, and the thirteen-file downgrade ofreact-hooks/exhaustive-depsto a warning survives with its comment. An override forscripts/headless/**/*.mjscomes across from #511 with the same wording; the glob is harmless while that folder is absent.lintandlint:cilose--ext, which a flat config does not take, and gain--cacheundernode_modules/.cache/eslint.Three deliberate differences, all in the commit message:
@typescript-eslint/no-require-importsis switched off alongside theno-var-requiresit replaced in typescript-eslint 8, which is what keeps the CommonJS helpers inscripts/passing.eslint-plugin-prettieris gone.prettier/prettierwas a warning nobody could fail on, and the same CI job already runsnpm run format:checkover the whole tree.eslint-config-prettierstays, 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.jsonwritten by hand (@oxlint/migrateports two rules and drops everyextendsand bothoverridesblocks), andeslint-plugin-oxlintwired into the flat config so ESLint stops checking what oxlint now covers.lint:ciisoxlint && eslint,lintis both with their fix flags.tests/config/lint-guards.test.tsseeds 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:
correctnesscategory, 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 ruleseslint:recommendedand typescript-eslint used to fail CI on would still be printed but would gate nothing, sinceeslint-plugin-oxlinthands every one of them over."categories": { "correctness": "error" }gives them back the severity ESLint gave them.no-unsafe-optional-chainingandexplicit-function-return-typeon.jsx,.cjsand.mjsstay with ESLint, because oxlint cannot express them the way the old config did. Details under Parity.eslint-plugin-oxlintreturns are filtered by name, not by whether they carry anignoreskey. Two of the six carry one, and the second turnsno-unused-varsandrules-of-hooksoff for everything that is not.vue,.svelteor.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:cireports 0 errors and the same 14react-hooks/exhaustive-depswarnings, at the same file and line as ondev, 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.tsxsplits 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.jsonand re-enabled aftereslint-plugin-oxlintin the flat config.no-unsafe-optional-chaining.eslint:recommendedhad it at error and it reports nothing on this tree, because ESLint stops at theascast 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.jsonis spelled without theeslint/prefix on purpose:eslint-plugin-oxlintreads 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-typefor.jsx,.cjsand.mjs. oxlint's implementation only fires on TypeScript files, while the old config exempted plain*.jsand nothing else. Handing the rule over wholesale would have dropped those three extensions by accident rather than by decision, so ESLint keeps them (minus thescripts/headless/**/*.mjscarve-out, which is spelled out on both sides).no-unused-varsdoes move to oxlint, but had to be raised to an error there.@typescript-eslint/recommendedhad 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-anydirectives it no longer runs a rule for, and oxlint sees the fiveexhaustive-depsdirectives inListMods.tsxfor 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:cifails; deleting the file returns the command to 0 errors and the 14 known warnings.lint:cireact/jsx-key.mapreturning<li>with no keyuse-isnanx === NaNno-debuggerdebuggerstatementno-unused-varsconstreact-hooks/rules-of-hooksuseStateinside anifno-restricted-importssrc/domainfile importingelectronno-unsafe-optional-chaining(o?.a).b@typescript-eslint/explicit-function-return-typeexport function, once in.mjs,.cjsand.jsxeachreact-hooks/exhaustive-depsThe first three stand for the
correctnesscategory 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.tsis 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 whilelint:cistays 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 reasonvitest.config.tsalready gives the renderer-dom project 15 s.Timings
Median of three, Node 22, same machine and tree.
lint:ciondev(ESLint 8.57)lint:ciafter commit 1 (ESLint 9, flat)lint:ciafter commit 2 (oxlint then ESLint)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 forexhaustive-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 ofreact/no-did-update-set-state,react/immutabilityandreact/globals. All five are"off"in.oxlintrc.jsonwith 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-chaininghits oxlint sees and ESLint does not (the optional chain is cast before it is reached through, and ESLint stops at theTSAsExpression; 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 onsrc/global.d.ts, ondeclareinside an already-ambient block.tscaccepts 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 lintno longer fixes formatting.@electron-toolkit/eslint-config-prettierextendedplugin:prettier/recommended, so the oldlint --fixreformatted files on the way through andlint:cireported formatting as warnings. Onlyeslint-config-prettierstays. CI is unaffected, since.github/workflows/ci.ymlrunsnpm run format:checkin the same job right afterlint:ci, but a contributor who runsnpm run lintalone now has to runnpm run formatas well.Two rules were lost to the ESLint 8 to 9 jump rather than to oxlint.
no-inner-declarationswas an error undereslint:recommendedin ESLint 8, is not in ESLint 9's recommended set and is not in oxlint's config either, so a function declared inside anifblock is now reported by nothing (checked on a probe file: both linters silent, exit 0).@typescript-eslint/ban-typeswas removed in typescript-eslint 8 and split into narrower rules thatrecommendeddoes 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 && eslinthides 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:ciat 5.1 s cold and 0.53 s warm. That was checked by building the first commit's tree on its own and runninglint:ciagainst it, not just assumed. Nothing in commit 1 mentions oxlint.Testing
Full gate green on the final tree:
npm run typecheck(node, web, tests): cleannpm run lint:ci: 0 errors, 14 warningsnpm run format:check: cleannpm 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 floorsnpm run build: cleanThe same gate was run and green on commit 1 before commit 2 was written (242 files, 4461 passed).
.github/workflows/ci.ymlneeds no change: the lint job already runsnpm run lint:ciand caches nothing of its own.Closes #513