Passer d'ESLint et de Prettier à Oxlint et Oxfmt
Sur mon projet ComptaOpen, un passage complet d'ESLint prenait plus d'une minute en local, et surtout il consommait énormément de RAM. Le genre d'étape qu'on finit par ne plus lancer qu'en CI, où il prenait encore 42 secondes.
J'ai même pris l'habitude, en local, de "linter" uniquement les fichiers modifiés, et le verdict sur l'ensemble du projet ne tombait qu'au moment du push avec les GitHub Actions.
Je sais, j'abuse 😒😂🤣
Côté formatage, aucun style commun : sur près de 1 200 fichiers TypeScript, 559 importaient entre guillemets doubles, 525 entre guillemets simples, etc.
J'ai réglé les deux le même soir avec deux outils de la suite Oxc :
- Oxlint à la place d'ESLint,
- Oxfmt comme formateur.
Le lint complet tient maintenant en 2 secondes.
Voyons tout ça un peu plus en détail.
Oxc en deux mots
Oxc, pour JavaScript Oxidation Compiler, est une suite d'outils JavaScript et TypeScript écrits en Rust : parser, linter, formateur, transformer, minifier, resolver. Le projet est porté par VoidZero, l'équipe derrière Vite, et fait déjà tourner Rolldown.
Deux de ces outils remplacent des briques que tout projet JS connaît :
- Oxlint, le linter, stable depuis sa version 1.0. Plus de 870 règles en natif : celles d'ESLint, de typescript-eslint, de React et des hooks, de jsx-a11y, d'import, de Next.js, et d'autres. Le projet annonce un linter 50 à 100 fois plus rapide qu'ESLint.
- Oxfmt, le formateur, en bêta (versions 0.x). Il prend la sortie de Prettier comme référence et passe 100 % de ses tests de conformité JavaScript et TypeScript. Le tri des classes Tailwind et le tri des imports sont intégrés, sans plugin. Le projet annonce environ 30 fois la vitesse de Prettier.
Les deux s'installent comme n'importe quel paquet npm. Rien ne change dans le reste de la chaîne : Next.js, Turbopack et Vitest restent en place.
Avant de migrer : des sondes
Qu'Oxlint soit plus rapide, ce n'était pas la question. La question, c'était : est-ce qu'il attrape encore ce qu'ESLint attrape pour moi ?
Avant de toucher au package.json, je devais m'assurer qu'Oxlint attrapait la totalité des erreurs et avertissements que me remontait ESLint de base. J'ai donc écrit trois fichiers jetables remplis de violations volontaires, une par règle qui compte pour le projet. Puis j'ai lancé les deux linters dessus, Oxlint via pnpm dlx avec la config générée par l'outil de migration officiel, et j'ai comparé.
| Violation volontaire | ESLint | Oxlint, config migrée |
|---|---|---|
setState direct dans un effet | attrapé | attrapé |
Date.now() pendant le rendu | attrapé | attrapé |
ref.current lu pendant le rendu | attrapé | attrapé |
Hook dans un if | attrapé | attrapé |
| Dépendance d'effet manquante | attrapé | attrapé |
<img> brut sans alt (deux règles) | attrapé | attrapé |
| Variable inutilisée | attrapé | attrapé |
| Dépendance à Next.js ou React dans un paquet portable (deux sondes) | attrapé | attrapé |
Import profond par alias de chemin (@/utils/cn) | attrapé | rien |
Import relatif profond à contresens des dépendances (../bj/paie/compute) | attrapé | rien |
ESLint : 12 sur 12. Oxlint : 10 sur 12.
Les trois premières lignes sont les règles issues du React Compiler (set-state-in-effect, purity, refs), les plus récentes de eslint-plugin-react-hooks. Oxlint les fait tourner en natif, sans plugin JS. C'est ce qui m'a convaincu de continuer. Restaient donc les deux trous en bas du tableau.
Le piège : * ne descend pas dans les sous-dossiers
Les deux trous ont la même cause : un * dans les motifs de no-restricted-imports.
J'avais ici pour objectif d'interdire l'utilisation de l'alias de chemin @/ dans un dossier prévu pour être et rester portable.
{
group: ["@/*", "@@/*"],
message: "src/lib/{core,bj}/ must stay portable: no project alias.",
},-
Chez ESLint, le motif s'applique de façon récursive :
@/*correspond à@/utils, et tout ce qui se trouve sous@/utilsest bloqué avec lui, quelle que soit la profondeur (@/utils/cn,@/utils/format/date). -
Oxlint lit le même motif comme un glob, sans récursion :
*couvre un seul niveau et s'arrête au premier/. Il bloque donc@/utils, mais laisse passer@/utils/cnou@/components/ui/button, et tout import plus profond. Bref, la règle ne bloque presque plus rien.
L'outil de migration recopie les motifs tels quels, sans avertissement. Le CI reste vert, puisque le code existant respecte déjà la règle. Tant que personne n'écrit l'import interdit, une règle qui ne bloque plus rien ne se distingue pas d'une règle qui fonctionne.
La correction : /** à la place de /*, dans chaque motif.
{
"group": ["@/**", "@@/**"],
"message": "src/lib/{core,bj}/ must stay portable: no project alias.",
}Avec ces motifs, les sondes donnent 12 sur 12.
Si ta config ESLint utilise * dans no-restricted-imports, repasse donc chaque motif après la migration et teste-le avec une sonde : un import profond volontaire qu'Oxlint doit bloquer.
La migration
L'outil officiel, @oxlint/migrate, lit une config ESLint au format flat et écrit un .oxlintrc.json :
pnpm dlx @oxlint/migrate eslint.config.mjsSur ComptaOpen : 80 règles converties, 7 écartées, aucune dont j'avais besoin. L'outil prévient aussi que le parser d'eslint-config-next est remplacé par celui d'Oxlint, et que certains settings d'ESLint sont ignorés.
Le fichier généré fonctionne, mais il est verbeux. Il recopie un par un les globals du navigateur, là où "env": { "browser": true } suffit, et il liste toutes les règles que la config d'origine se contentait de désactiver. Je l'ai réécrit à la main sur cette base, puis j'ai corrigé les motifs.
Ensuite, le remplacement :
pnpm add -D oxlint
pnpm remove eslint eslint-config-next @eslint/eslintrc
rm eslint.config.mjs"scripts": {
"lint": "oxlint"
}L'étape Lint du CI appelle toujours pnpm lint, elle n'a pas bougé. Le lockfile, lui, perd 270 paquets.
Restent les commentaires de désactivation. Oxlint comprend les eslint-disable, mais les noms de règles changent : react-hooks/set-state-in-effect devient react/set-state-in-effect, @next/next/no-img-element devient nextjs/no-img-element. L'option --replace-eslint-comments de l'outil de migration s'en charge. J'ai tout converti en oxlint-disable avec les noms Oxlint, et supprimé ceux qui ne masquaient plus rien.
Lis la documentation : https://oxc.rs/docs/guide/usage/linter
Oxfmt : un seul style pour tout le projet
Oxfmt reprend les options de Prettier et produit la même sortie, à quelques lignes près, mais ce n'est pas un Prettier réécrit de bout en bout. JavaScript, TypeScript, JSON, CSS, YAML, TOML, GraphQL et Markdown passent par son moteur natif, en Rust. HTML, Vue, Svelte, Astro et MDX sont délégués à un Prettier embarqué dans le paquet npm, qui a besoin de Node.js. Le binaire autonome publié sur GitHub ignore donc ces fichiers : pour tout formater, il faut le paquet npm.
Côté style, il règle le problème de l'intro : un seul type de guillemets dans tout le code, sauf pour une chaîne qui contient déjà ce guillemet, où il garde l'autre pour éviter un échappement. Ce qui demandait des plugins chez Prettier est intégré. Le tri des classes Tailwind reprend l'algorithme de prettier-plugin-tailwindcss. Avec Tailwind v4, où la config vit dans le CSS, l'option stylesheet pointe vers ce fichier pour qu'Oxfmt connaisse les utilitaires maison. Le tri des imports reste désactivé par défaut. Le tri du package.json, lui, est actif d'office : ce fichier apparaîtra dans le diff du premier passage.
Mesuré sur les 1 200 fichiers du projet, en mode --check :
| Prettier | Oxfmt | Écart | |
|---|---|---|---|
| Sans tri Tailwind | 18,6 s | 0,5 s | environ 37 fois |
| Avec tri Tailwind | 22,1 s (avec prettier-plugin-tailwindcss) | 2,4 s | environ 9 fois |
VS Code : l'extension Oxc
Pour avoir les mêmes règles dans l'éditeur que dans le CI, il faut l'extension Oxc (oxc.oxc-vscode) dans le marketplace de VS Code. Elle fonctionne aussi dans Cursor et les autres éditeurs dérivés. Une seule extension pour les deux outils : les diagnostics d'Oxlint dans le code, et le formatage par Oxfmt. Elle utilise l'Oxfmt du projet, il doit donc être installé en dépendance de dev, pas en global.
Pour que toute l'équipe se la voie proposer à l'ouverture du projet :
{
"recommendations": ["oxc.oxc-vscode"]
}Il faut ensuite la déclarer comme formateur par défaut. La documentation propose ceci :
{
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
}Le piège : VS Code fait passer un réglage propre à un langage devant un réglage général, même quand le premier vient de tes réglages utilisateur et le second du workspace. Un "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" } défini dans tes réglages utilisateur pour tes autres projets, et Oxc est ignoré sans le moindre message : le fichier est formaté par Prettier à l'enregistrement, et le CI le refuse au push.
La parade : un bloc par langage dans le workspace.
{
"[javascript]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[javascriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[typescript]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[typescriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[css]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[tailwindcss]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[json]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[jsonc]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[yaml]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[dockercompose]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[github-actions-workflow]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
}Les blocs tailwindcss, dockercompose et github-actions-workflow ne sont pas du zèle. Selon les extensions installées, certains fichiers reçoivent un identifiant de langage inattendu : tailwindcss pour un .css, dockercompose pour un fichier Docker Compose, github-actions-workflow pour un workflow GitHub. La commande oxfmt formate tous ces fichiers. Sans le bloc correspondant, l'éditeur ne les formate pas à l'enregistrement, et l'écart ne se voit qu'au CI.
Le bilan
| Étape du CI | Avant | Après |
|---|---|---|
| Lint | 42 s | 2 s |
| Vérification du formatage | 22 s | 2 s |
En plus du temps : 270 paquets de moins dans le lockfile, un style unique sur tout le code, et les mêmes règles dans l'éditeur et dans le CI. Le lint complet se lance de nouveau en local, sur tout le projet, sans y penser.
Ce qu'il faut garder en tête
Oxfmt est en bêta. Il ne charge pas les plugins Prettier. Les plus répandus ont un équivalent intégré : Tailwind, Svelte, Astro, le tri des imports et celui du package.json. Pour tout autre plugin, vérifie qu'Oxfmt couvre le besoin avant de migrer.
Les plugins JS d'Oxlint sont en alpha. Ils permettent de charger des plugins ESLint qui n'ont pas d'équivalent natif. Si ta config en dépend, teste-les avant de supprimer ESLint.
Le lint typé n'était pas en jeu ici. eslint-config-next n'active aucune règle qui a besoin des types, donc je n'ai rien perdu. Si ta config utilise no-floating-promises et compagnie, Oxlint a un mode type-aware, construit sur le portage natif de TypeScript : regarde où il en est avant de migrer.
Mes sondes couvrent les règles qui comptent pour ce projet, pas les 80 règles une par une. C'est un choix : je voulais vérifier les règles dont l'absence me coûterait quelque chose.
Le prochain goulot : TypeScript
Le typecheck est maintenant l'étape la plus longue du CI, et de loin.
TypeScript 7, le compilateur réécrit en Go, est sorti en juillet 2026, et Next.js 16.3 l'accepte déjà pour vérifier les types. Ce qui me retenait, c'était ESLint. eslint-config-next embarque typescript-eslint, qui s'appuie sur l'API programmatique de TypeScript, absente de la 7.0 et prévue pour la 7.1. En attendant, typescript-eslint 8.71 exige typescript <6.1.0.
Oxlint a son propre parser et ne dépend pas de typescript : le blocage est parti avec ESLint. Prochaine étape, TypeScript 7.
Sources :