Skip to content

chore: Adopt oxlint and oxfmt, and modernise the lint/format toolchain#2666

Open
VincentSmedinga wants to merge 10 commits into
developfrom
chore/replace-eslint-prettier-with-oxlint-oxfmt
Open

chore: Adopt oxlint and oxfmt, and modernise the lint/format toolchain#2666
VincentSmedinga wants to merge 10 commits into
developfrom
chore/replace-eslint-prettier-with-oxlint-oxfmt

Conversation

@VincentSmedinga

Copy link
Copy Markdown
Contributor

Adopt oxlint and oxfmt, and modernise the lint/format toolchain

What

Moves the design system’s linting and formatting onto the oxc toolchain, with related tooling cleanups:

  • oxfmt fully replaces Prettier for all formatting (JS/TS/JSX, JSON, CSS/SCSS, YAML, HTML).
  • oxlint is the sole linter for all JavaScript/TypeScript, running its native rules plus eslint-plugin-perfectionist, eslint-plugin-baseline-js, and eslint-plugin-storybook through oxlint's jsPlugins loader.
  • ESLint is reduced to the languages oxlint can’t lint — JSON, Markdown, and MDX.
  • lint-stagednano-staged (zero-dependency, faster) with the same pre-commit behaviour.
  • Markdown content linting now actually runs. It was a silent no-op (markdown.configs.recommended.rules is undefined in @eslint/markdown 8); it now uses the GFM language with the recommended rules, so require-alt-text, no-empty-links, etc. are enforced.
  • Root-level ESLint plugins are declared explicitly (eslint-plugin-storybook, eslint-plugin-mdx, eslint-plugin-react) instead of relying on pnpm hoisting from sub-packages.
  • AGENTS.md gains efficient linter/formatter guidance (tool ownership, fast targeted commands, "don't hand-format/sort").

Removed: prettier, eslint-config-prettier, eslint-plugin-oxlint, lint-staged, and the now-unused ESLint JS/TS plugins (@typescript-eslint/*, @vitest/eslint-plugin, eslint-plugin-import-x, eslint-import-resolver-typescript, globals, @eslint/js).

Added: oxlint, oxfmt, nano-staged. The published packages (CSS, tokens, React, assets) are unchanged — this only affects the developer toolchain.

Why

ESLint and Prettier were the slowest parts of the local and CI feedback loop; oxlint and oxfmt are Rust-based and much faster, and consolidating on one toolchain removes the ESLint/Prettier coordination. Along the way this surfaced and fixed real gaps: Markdown linting was doing nothing, and the root tooling depended on plugins declared only in sub-packages.

Like many others, we haven’t been able to upgrade to ESLint 10 for a while due to a bloocking issue in eslint-plugin-react. This lead us to research alternatives, where this article pointed us at the oxc toolchain, mentioning “oxfmt now has 1:1 compatibility with Prettier […] and oxlint is also production-ready.”

How

Seven reviewable commits, each independently revertible:

  1. replace ESLint/Prettier tooling with oxlint and oxfmt — generates .oxlintrc.json (@oxlint/migrate), adds .oxfmtrc.json mirroring the old Prettier style, converts stale eslint-disableoxlint-disable.
  2. style: format with oxfmt — the one-off oxfmt pass (a single embedded-script semicolon; the codebase already matched).
  3. lint all JS/TS with oxlint via jsPlugins, slim ESLint to JSON/Markdown/MDX.
  4. replace lint-staged with nano-staged.
  5. document efficient linter and formatter use in AGENTS.md.
  6. declare the root-level ESLint plugins explicitly.
  7. enable Markdown content linting — the GFM/recommended fix, plus the two issues it surfaced (a CSS block missing its language; a broken #guidelines anchor in the Page Header README).

Parity for the JS/TS rules was verified by linting a deliberately-broken file with both tools (identical findings), and the new Markdown rules were verified to flag require-alt-text and no-empty-links.

oxfmt honours <!-- prettier-ignore --> (its documented Markdown ignore syntax), so those comments were kept.

CI needs no workflow change — a new lint:format script is picked up by the existing pnpm run lint aggregator.

Checklist

  • Add or update unit tests — not applicable; no source behaviour changes. Full suite (1085 + 78) passes.
  • Add or update documentation — AGENTS.md (incl. the React package note), definition-of-done.md, CONTRIBUTING.md (VS Code extension → Oxc), and two doc fixes surfaced by Markdown linting.
  • Add or update stories — not applicable.
  • Add or update exports in index.* files — not applicable.
  • Comment /chromatic test and verify visual regression tests pass — no visual changes expected (only lint-directive comments in story files); worth running to confirm.
  • Start the PR title with a Conventional Commit prefix.

Additional notes

Performance (median of 3, Apple M1 Max):

Command Before (ESLint + Prettier) After (oxlint + oxfmt)
Formatter, whole repo prettier --check . 4.39s oxfmt --check . 0.54s
lint:js (the command devs run) eslint . 10.36s oxlint && eslint . 4.68s
pnpm run lint (full) 16.54s 12.59s

For reviewers to weigh in on:

  • oxlint's jsPlugins loader is alpha (its own schema notes "not subject to semver") and is now the only thing linting our JS/TS. It's isolated in commit 3, so git revert <commit-3> falls back to a fully-stable setup (oxlint native rules + a reduced ESLint) without touching the Prettier → oxfmt work.
  • Rules dropped in the jsPlugins phase (no native/jsPlugins equivalent): consistent-return, react/prefer-read-only-props, padding-line-between-statements (generated logos), @typescript-eslint/no-invalid-this.
  • MDX content linting is still off.mdx is syntax-validated but its content isn't linted (no remark-lint config). Intentionally out of scope here; tracked as a separate follow-up.
  • MDX is not formatted by oxfmt (Prettier never did either; ~108 files of churn avoided). Config-flip to enable later.

Adopt the oxc toolchain: oxlint takes over the natively-supported JS/TS
rules and oxfmt fully replaces Prettier. ESLint is retained but reduced --
it now only runs what oxlint cannot (perfectionist sorting, baseline-js,
Storybook, JSON/Markdown/MDX linting), with eslint-plugin-oxlint disabling
the overlapping rules.

- Add .oxlintrc.json (generated via @oxlint/migrate, native rules only) and
  wire eslint-plugin-oxlint into eslint.config.mjs.
- Add .oxfmtrc.json mirroring the previous Prettier style; remove prettier,
  eslint-config-prettier, .prettierrc.json and .prettierignore.
- Convert stale eslint-disable directives to oxlint-disable.
- Update scripts (format, lint:format, lint:js, lint-fix:js), lint-staged,
  the SVGR generate scripts, and contributor docs.

MDX is left out of oxfmt for now (Prettier never formatted it); enabling it
later is a small config change.
Apply oxfmt across the repo. Only an inline <script> changes: its trailing
semicolon is dropped to match the no-semicolon house style.
…Markdown/MDX

Move perfectionist, baseline-js and eslint-plugin-storybook into oxlint's
jsPlugins loader so oxlint is the single linter for all JavaScript and
TypeScript. ESLint now only lints the languages oxlint cannot: JSON,
Markdown and MDX.

- Add jsPlugins (perfectionist, baseline-js, storybook) to .oxlintrc.json.
- Reduce eslint.config.mjs to JSON/Markdown/MDX; the Storybook partial is
  MDX-only.
- Delete eslint.rules.mjs and the React eslint partials; drop the
  eslint-plugin-oxlint disabler and the now-unused ESLint JS/TS dev deps.
- lint-staged runs only oxlint on JS/TS.

Note: jsPlugins is alpha. Rules without an oxlint/jsPlugins equivalent are
dropped: consistent-return, react/prefer-read-only-props,
padding-line-between-statements (generated logos), no-invalid-this.
nano-staged is a zero-dependency, faster drop-in for lint-staged with the
same pre-commit behaviour. Move the config from .lintstagedrc.json to
.nano-staged.json (identical globs and commands) and run nano-staged from
the husky pre-commit hook.
Add a "Linting and formatting" note covering tool ownership (oxlint for
JS/TS, ESLint for JSON/Markdown/MDX, Stylelint for CSS, oxfmt for
formatting), running the fast targeted tools while iterating, and not
hand-formatting or hand-sorting. Add a `pnpm run format` command to the
table, name the fast tools in the lint guidance, and correct the React
package config-change note.
oxlint loads eslint-plugin-storybook through its jsPlugins loader, and the
root ESLint config loads eslint-plugin-mdx (and, for MDX code blocks,
eslint-plugin-react). These were resolved only by pnpm hoisting from
storybook/ and packages/react/, so a dedupe or hoisting change could break
linting at the repository root. Declare them as root devDependencies at the
versions the sub-packages already use (no duplicate installs).
The ESLint markdown block spread `markdown.configs.recommended.rules`, but
in @eslint/markdown 8 `recommended` is a flat-config array, so `.rules` was
undefined and no markdown rules ran — content linting was a silent no-op.

Switch to the GFM language with the recommended rules (so task lists and
tables parse correctly and don't false-positive), ignore all CHANGELOG.md
files, and turn off no-missing-label-refs (noisy on literal brackets; our
docs use inline links). Fix the two real issues this surfaced: a fenced
code block missing its language in definition-of-done.md and a broken
`#guidelines` anchor in the Page Header README.
@VincentSmedinga VincentSmedinga requested a review from a team as a code owner June 8, 2026 12:09
Copilot AI review requested due to automatic review settings June 8, 2026 12:09
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for React components

Status Category Percentage Covered / Total
🟢 Lines 100% 743 / 743
🟢 Statements 99.36% 778 / 783
🟠 Functions 98.5% 197 / 200
🟢 Branches 99.08% 540 / 545
File CoverageNo changed files found.
Generated in workflow #303 for commit b040aa6 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Size Change: 0 B

Total Size: 351 kB

ℹ️ View Unchanged
Filename Size
packages-proprietary/react-icons/dist/index.esm.js 51.1 kB
packages-proprietary/temp/assets-fonts.tar.gz 129 kB
packages-proprietary/temp/assets-others.tar.gz 111 kB
packages-proprietary/tokens/dist/compact.css 591 B
packages-proprietary/tokens/dist/index.css 8.9 kB
packages/css/dist/index.css 14.5 kB
packages/react/dist/index.esm.js 35.4 kB
packages/react/dist/index.js 464 B

compressed-size-action

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR modernises the repo’s linting/formatting toolchain by adopting the oxc stack: oxfmt for formatting and oxlint for JS/TS linting, while reducing ESLint to JSON/Markdown/MDX only and swapping lint-staged for nano-staged.

Changes:

  • Replace Prettier with oxfmt (new config + root scripts) and introduce oxlint as the primary JS/TS linter (new config).
  • Slim ESLint to JSON/Markdown/MDX and update Storybook/package configs accordingly.
  • Replace lint-staged with nano-staged + update docs/guidance for the new tooling.

Reviewed changes

Copilot reviewed 34 out of 35 changed files in this pull request and generated 24 comments.

Show a summary per file
File Description
storybook/src/utils/Prose/Prose.stories.tsx Updates lint-disable directive for oxlint in Storybook story helper.
storybook/src/pages/public/ProjectPage/ProjectPage.stories.tsx Converts eslint-disable directive to oxlint-disable in story meta render.
storybook/src/pages/public/ProductPage/ProductPage.stories.tsx Converts eslint-disable directive to oxlint-disable in story meta render.
storybook/src/pages/public/NavigationPage/NavigationPage.stories.tsx Converts multiple eslint-disable directives to oxlint-disable for Storybook render args.
storybook/src/pages/public/HomePage/HomePage.stories.tsx Converts eslint-disable directive to oxlint-disable for Storybook render args.
storybook/src/pages/public/FormFlow/FormFlow.stories.tsx Converts multiple eslint-disable directives to oxlint-disable for Storybook render args.
storybook/src/pages/internal/HomePage/HomePage.stories.tsx Converts eslint-disable directive to oxlint-disable for Storybook render args.
storybook/src/components/SearchField/SearchField.stories.tsx Converts no-alert disable directive to oxlint variant.
storybook/src/components/Menu/Menu.stories.tsx Converts eslint disable directives to oxlint variants for unused vars handling.
storybook/src/components/LinkList/LinkList.stories.tsx Removes now-unneeded eslint-disable directive.
storybook/src/components/Grid/Grid.stories.tsx Removes now-unneeded eslint-disable directive.
storybook/src/components/DateInput/DateInput.stories.tsx Converts eslint-disable directives to oxlint-disable for unused render args.
storybook/eslint.config-partial.mjs Removes JS/TS + Storybook ESLint configs; keeps MDX linting config.
storybook/config/preview-head.html Formatting-only change consistent with oxfmt (semicolon removal).
pnpm-lock.yaml Updates lockfile for added/removed tooling dependencies (oxlint/oxfmt/nano-staged, etc.).
packages/react/src/ImageSlider/utils/debounce.test.ts Converts eslint-disable directive to oxlint-disable in test.
packages/react/package.json Switches logo generation formatting to oxfmt (and keeps a lint step).
packages/react/eslint.config-partial.mjs Removes React/TS ESLint partial config (JS/TS lint moved to oxlint).
packages/react/AGENTS.md Updates agent guidance to reference the new lint/format tools.
packages/css/src/components/page-header/README.md Fixes an internal Markdown anchor link.
packages-proprietary/react-icons/package.json Switches icon generation formatting to oxfmt (and keeps a lint step).
packages-proprietary/react-icons/eslint.config-partial.mjs Removes react-icons ESLint partial config (JS/TS lint moved to oxlint).
package.json Adds format / lint:format; updates JS lint scripts to use oxlint+ESLint; removes Prettier.
eslint.rules.mjs Removes extracted ESLint rule definitions (JS/TS lint moved to oxlint).
eslint.config.mjs Slims ESLint scope to JSON/Markdown/MDX and enables Markdown content linting via GFM recommended config.
documentation/definition-of-done.md Updates docs to mention oxlint/oxfmt; adds missing code fence language.
CONTRIBUTING.md Updates editor tooling guidance to use Oxc VS Code extension and fix-on-save.
AGENTS.md Adds/updates workflow guidance for oxlint/oxfmt and new scripts.
.prettierrc.json Removes Prettier config (replaced by .oxfmtrc.json).
.prettierignore Removes Prettier ignore file (replaced by .oxfmtrc.json ignorePatterns).
.oxlintrc.json Adds oxlint configuration (rules + jsPlugins integrations and overrides).
.oxfmtrc.json Adds oxfmt configuration (style + ignore patterns + overrides).
.nano-staged.json Adds nano-staged config to replace lint-staged pre-commit behavior.
.lintstagedrc.json Removes lint-staged config (replaced by nano-staged).
.husky/pre-commit Switches pre-commit hook to nano-staged.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread storybook/src/utils/Prose/Prose.stories.tsx Outdated
Comment thread storybook/src/pages/public/ProjectPage/ProjectPage.stories.tsx Outdated
Comment thread storybook/src/pages/public/ProductPage/ProductPage.stories.tsx Outdated
Comment thread storybook/src/pages/public/NavigationPage/NavigationPage.stories.tsx Outdated
Comment thread storybook/src/pages/public/HomePage/HomePage.stories.tsx Outdated
Comment thread storybook/src/components/Menu/Menu.stories.tsx Outdated
Comment thread storybook/src/components/Menu/Menu.stories.tsx Outdated
Comment thread storybook/eslint.config-partial.mjs Outdated
Comment thread packages/react/package.json Outdated
Comment thread packages-proprietary/react-icons/package.json Outdated
The generate-logos and generate scripts called `eslint --fix` to auto-fix
generated TSX/JS files. ESLint now only covers JSON, Markdown, and MDX,
so those calls were a silent no-op. Replace with `oxlint --fix` so that
oxlint's auto-fixable rules (import ordering, type imports, etc.) still
run on newly generated source files.
Replace the `@typescript-eslint/` prefix with the canonical oxlint names:
- `@typescript-eslint/no-unused-vars` → `no-unused-vars`
- `@typescript-eslint/no-explicit-any` → `typescript/no-explicit-any`

The old names worked as aliases, but keeping the `@typescript-eslint/`
prefix is misleading now that ESLint no longer handles TypeScript.
…config

The two `react/jsx-no-undef: off` and `react/prop-types: off` entries were
defensive placeholders against a React plugin that is no longer loaded by
ESLint at all. Removing them also makes the rules override redundant, so
collapse the flatCodeBlocks entry back to a name-only override.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 34 out of 35 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)

storybook/src/components/Grid/Grid.stories.tsx:36

  • cellMeta is only referenced via StoryObj<typeof cellMeta> (type-only), so it may be reported as unused by oxlint’s no-unused-vars. Re-introduce an oxlint-disable-next-line no-unused-vars directive (matching the pattern used elsewhere) to prevent lint failures.
const cellMeta = {
  component: Grid.Cell,
  argTypes: {
    span: {
      control: { max: 12, min: 1, type: 'number' },
    },
    start: {
      control: { max: 12, min: 1, type: 'number' },
    },
  },
} satisfies Meta<typeof Grid.Cell>

Comment thread storybook/src/components/LinkList/LinkList.stories.tsx
@VincentSmedinga

Copy link
Copy Markdown
Contributor Author

/Chromatic test

@RubenSibon RubenSibon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Great work!

I have yet to test and review this properly, but I’m sure the build process will be much faster and the config will be even more consolidated and consistent. And it’s great that Markdown linting now actually works, even though some work on MDX linting/formatting remains.

But a few things from the description I find unfortunate:

  • “ESLint is reduced to the languages oxlint can’t lint — JSON, Markdown, and MDX.” -- it would be better if we could get rid of ESLint altogether, now we still have similar tooling for similar tasks. How does this affect the full code quality checking pipeline?
  • “Root-level ESLint plugins are declared explicitly (eslint-plugin-storybook, eslint-plugin-mdx, eslint-plugin-react) instead of relying on pnpm hoisting from sub-packages.” -- I see that oxlint uses the JSON format and that cannot import partials. They do not support (M)JS? Because I actually like the separation of concerns we had with package particular rules scoped to that package. Another downside of basic JSON is that there can be no clarifying comments or use comments and newlines to improve structure and readability. The latter downsides could maybe be avoided with JSONC or JSON5.
  • “MDX content linting is still off — .mdx is syntax-validated, but its content isn't linted (no remark-lint config). Intentionally out of scope here; tracked as a separate follow-up.” -- Is that issue on the backlog already?
  • Rules dropped in the jsPlugins phase (no native/jsPlugins equivalent): consistent-return, react/prefer-read-only-props, padding-line-between-statements (generated logos), @typescript-eslint/no-invalid-this. -- Can you elaborate on this? Why can’t we have these rules any more? We recently added prefer-read-only-props.

I’m going to test this with Visual Studio Code extensions for ESLint & Prettier that I currently have set up. I think that contributors should ideally be able to continue developing without any or too many workflow disruptions.

Furthermore, I’ll also add other regular contributors (@alimpens, @dlnr, @Evi-2003) as reviewers so that they can test the linting and formatting with their IDEs and set-ups.

Comment thread AGENTS.md

- Before submitting work, cross-check the full definition-of-done checklist in [documentation/definition-of-done.md](documentation/definition-of-done.md).
- Run the most specific relevant lint/test commands for the package you touched before relying on full `pnpm run lint` / `pnpm run test`.
- Run the most specific relevant lint/test command — e.g. `oxlint` for JS/TS or `oxfmt --check` for formatting — before relying on the full `pnpm run lint` / `pnpm run test`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This tells me you haven’t included the agent hooks in this PR yet.

But now that I’ve read that part of the article again (see link above) I do see that it is agent vendor specific. Can you do some research if there’s a more generic option that at the very least works with GitHub Copilot?

@RubenSibon RubenSibon requested review from Evi-2003, alimpens and dlnr June 9, 2026 08:13

@RubenSibon RubenSibon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I’ve done some testing:

  • Linting and formatting from the command-line works flawlessly!
  • But I couldn’t rely on the ESLint and Prettier extensions in VS Code any more.
  • The Oxc extension does work as expected. But I had to manually look for it; I did not see a prompt to install it.

So, in conclusion:

  • I think I can accept this change after you’ve addressed my concerns in the earlier remark.
  • It might be wise to warn contributors about that they have to look at their tooling.
  • Reviews from regular contributors with other IDEs and set-ups might still be valuable.

@RubenSibon RubenSibon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I’ve done a bit more testing by intentionally adding code and formatting mistakes to TableOfContents.tsx.

Most issues were found and automatically fixed by either the Oxc linter or formatter, but the import order rules were not applied:

/**
 * @license EUPL-1.2+
 * Copyright Gemeente Amsterdam
 */

import type { HeadingProps } from '../Heading'

import { Heading } from '../Heading'

import { forwardRef } from 'react'
import { TableOfContentsLink } from './TableOfContentsLink'
import { clsx } from 'clsx'
import { TableOfContentsList } from './TableOfContentsList'
import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react'

Above import order is incorrect.

This is because eslint-plugin-import-x is no longer included in the project. Isn’t that a major limitation? Maybe add it to the ESLint config again for now if Oxc does not support it?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants