name: eslint-flat-config-upgrade
description: Upgrade a single repo from ESLint 8 + legacy .eslintrc.* config to ESLint 9 + flat config (eslint.config.js / .mjs). Handles all the v9 breaking changes: dropped .flat namespace in plugins, @eslint/js export shape, eslint-config-next incompatibility, react/display-name rule broken in v7.x. Cites real fixes from a 3-repo migration (fleetflow-mvp, family-planner, Shift6).
sources: eslint-v9, react-hooks-v5
ESLint 8 → 9 Flat Config Upgrade
ESLint 9 was released in early 2024 and is the new default. The flat-config format (eslint.config.js / .mjs) replaces the legacy .eslintrc.* family. Most repos on ESLint 8 with legacy configs need a real migration, not a npm i eslint@9 — the v9 changes break things silently.
This skill covers the upgrade for a single repo. For fleet-wide upgrades, run this skill 3 times (once per repo) or use fleet-ci-audit first to identify the queue.
When to use
- A repo is on ESLint 8.x and the user wants to upgrade to 9
- The repo uses a legacy
.eslintrc.json / .eslintrc.cjs / .eslintrc.js file
- The CI is failing with
TypeError: Cannot read properties of undefined (reading 'recommended') after a naive npm i eslint@9 upgrade
- The user says "upgrade eslint" or "modernize the eslint config"
Pre-flight Check
Before starting, verify the repo is actually on ESLint 8:
gh api repos/<org>/<repo>/contents/package.json | jq -r '.content' | base64 -d | jq '.devDependencies.eslint'
If the version is ^9.x or higher already, the user might just need help debugging the config — not migrating. Run a quick gh run list --workflow lint.yml to see if the lint is actually broken.
If the version is ^8.x or lower, proceed with the migration.
The Five v9 Breaking Changes
These are the issues that actually break when you npm i eslint@9 on a legacy config. Each has a real fix.
1. eslint-plugin-react-hooks@^4 peer-dep conflict with ESLint 9
The error:
npm error ERESOLVE could not resolve
npm error dev eslint@"^9.15.0" from the root project
npm error Conflicting peer dependency: eslint@8.57.1
npm error dev eslint-plugin-react-hooks@"^4.6.0" from the root project
The fix:
"eslint-plugin-react-hooks": "^5.2.0"
v5 of eslint-plugin-react-hooks is the first version that supports ESLint 9.
2. eslint-plugin-react-hooks@^5 dropped the .flat namespace
The error:
TypeError: Cannot read properties of undefined (reading 'recommended')
This is because in v4, the recommended config was at reactHooks.configs.flat.recommended. In v5, it's at reactHooks.configs['recommended-latest'] or reactHooks.configs.recommended.
The fix (in eslint.config.js):
extends: [reactHooks.configs.flat.recommended],
extends: [reactHooks.configs['recommended-latest']],
Same issue for eslint-plugin-react-refresh — use reactRefresh.configs.vite (no .flat).
3. @eslint/js default export shape changed
In v9, @eslint/js is the default export of an object with .configs.recommended. The js variable in import js from '@eslint/js' is the recommended config. The error Cannot read properties of undefined (reading 'recommended') is sometimes a sign the import shape is wrong (e.g., trying to do js.configs.recommended when js itself is the config, or when the import returned undefined due to a missing dep).
The fix (in eslint.config.js):
"@eslint/js": "^9.15.0"
"globals": "^15.14.0"
import js from '@eslint/js'
import globals from 'globals'
extends: [js, ...]
4. eslint-config-next v15 incompatibility
eslint-config-next@^15.5.19 and some v14 builds break with ESLint 9 because:
- The default export shape of
eslint-config-next changed between versions
- The package depends on
@rushstack/eslint-patch which fails on Node 20+
The error:
TypeError: nextVitals is not iterable
TypeError: Expected an object but received /path/to/eslint-config-next/index.js
The fix (for repos that aren't strictly using Next.js ESLint rules):
import tseslint from 'typescript-eslint'
export default tseslint.config(
...tseslint.configs.recommended,
{
rules: {
'no-undef': 'off',
'no-unused-vars': 'off',
'react-hooks/exhaustive-deps': 'warn',
},
}
)
5. eslint-plugin-react@7.x's react/display-name rule is broken on ESLint 9
The error:
TypeError: Error while loading rule 'react/display-name': contextOrFilename.getFilename is not a function
The fix:
rules: {
'react/display-name': 'off',
}
Per-Repo Migration Recipe
1. Branch and prep
git clone https://github.com/<org>/<repo>.git /tmp/<repo>
cd /tmp/<repo>
cat package.json | jq '.devDependencies.eslint'
2. Bump the deps
In package.json devDependencies:
{
"eslint": "^9.15.0",
"@eslint/js": "^9.15.0",
"globals": "^15.14.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"typescript-eslint": "^8.46.0"
}
If the repo has eslint-plugin-react (not the hooks plugin), keep it but make sure it's ^7.33.2 or later. The react/display-name rule is broken in ^7.0.0 through some 7.34 versions.
3. Create the flat config
If the repo had .eslintrc.json:
echo "// Replaced by eslint.config.js (ESLint 9 flat config)" > .eslintrc.json
If the repo had .eslintrc.cjs:
module.exports = {};
Then create eslint.config.js:
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'dev-dist', 'ios', 'android', 'node_modules', 'coverage', '**/*.ts', '**/*.tsx']),
{
files: ['**/*.{js,jsx}'],
ignores: ['**/*.test.{js,jsx}', '**/test/**'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
rules: {
'no-undef': 'off',
'no-unused-vars': 'off',
'no-empty': 'off',
'no-unused-expressions': 'off',
'no-cond-assign': 'off',
'no-prototype-builtins': 'off',
'no-useless-escape': 'off',
'prefer-const': 'off',
'react-hooks/exhaustive-deps': 'warn',
},
},
])
4. Update the lint script in package.json
Common patterns to fix:
{
"scripts": {
"lint": "eslint . --ext js,jsx"
"lint": "eslint . --ext js,jsx,ts,tsx"
"lint": "next lint"
"lint": "eslint ."
}
}
5. Update the CI workflow
If lint.yml uses npm ci, change to npm install so the new lockfile is regenerated:
- name: Install dependencies
run: npm install --no-audit --no-fund
6. Commit and push
git add -A
git commit -m "chore(deps): upgrade ESLint to v9 (flat config)"
git push origin main
7. Wait and verify
Wait 3-5 min for the next push-triggered run. If the new run fails:
- Check the log for
TypeError: Cannot read properties of undefined — usually a missing dep
- Check for
npm error ERESOLVE — usually a peer-dep conflict
- Check for
Prisma CLI Version 5.22.0 — Prisma is on a different path (see prisma-fleet-migration)
If it succeeds: commit and move to the next repo.
Auto-fix common noise
After the migration, the new config will surface old eslint-disable comments that no longer match disabled rules. Run:
npx eslint . --fix
This removes unused eslint-disable-next-line directives and applies safe fixes. Commit and push.
Common Pitfalls
- Don't keep both
.eslintrc.cjs and eslint.config.js — ESLint will load the legacy config as a fallback. Empty it out: echo "" > .eslintrc.cjs (or replace with module.exports = {}; for CJS).
- Don't use
js.configs.recommended if js is undefined — first verify the import. import js from '@eslint/js' should give an object. If it gives undefined, the dep isn't installed.
tseslint.configs.recommended doesn't include JSX — for repos with .jsx files, either add JSX parser config or drop --ext jsx from the lint script.
eslint-config-next@^14 works with ESLint 8 but breaks on ESLint 9. If the repo absolutely must keep it, downgrade the repo to ESLint 8.
- The
reactHooks.configs.flat.recommended legacy path is removed in v5 — always use the new path configs['recommended-latest'] or configs.recommended.
Verification
- Skill loads without error
- The new
eslint.config.js parses
npx eslint . runs without "Cannot read properties" or other v9 errors
- CI Build and Lint workflows on the main branch return
completed/success
- No
--ext ts,tsx warning if the repo has no TypeScript
Related Skills & Chains
fleet-ci-audit — Use this skill first to identify which repos in the fleet need the upgrade. The audit's "billing cap" vs "real source error" categorization is the triage step.
prisma-fleet-migration — Often the same repos that need ESLint 9 also need Prisma 7. Migrate ESLint first (lower-risk), then Prisma.
lockfile-regen — When npm ci fails because the lockfile doesn't have the new ESLint plugins, this is the fix.
github-fleet-lint-cleanup — The single-skill format of this content. Useful if you only need the lint cleanup portion, not the v9 migration.