Repository navigation
chore(deps): upgrade React Router 7 → 8 (+ remix-i18next 8, Netlify plugin 4, Node 22.22) - #405
Conversation
Deploy-preview render checkCI is green (
The Loading |
…Node 22.22
Coordinated upgrade — these versions only resolve together:
- react-router / @react-router/{node,serve,dev,fs-routes} 7.15.0 -> 8.3.1
- remix-i18next 7.4.2 -> 8.0.0 (peer: react-router ^8)
- @netlify/vite-plugin-react-router 2.1.3 -> 4.0.0 (ESM-only)
- react / react-dom 19.2.6 -> 19.2.8 (RR8 peer floor is >=19.2.7; the two
move in lockstep because react-dom pins react with a caret on the exact
version)
@netlify/vite-plugin-react-router 2.1.3 is not optional to leave behind: RR8
makes Vite's Environment API mandatory, and 2.1.3 gates its work on
`isSsrBuild`, which is never true under that model. It then silently stops
emitting .netlify/v1/functions/react-router-server.mjs while `npm run build`
still exits 0 — netlify/remix-compute#698, fixed in v4.0.0.
Node floor: RR8, remix-i18next 8 and the Netlify plugin all require
>=22.22.0. .nvmrc goes to v22.23.2 (current 22 LTS "Jod"); engines.node to
>=22.22.0. CI reads .nvmrc via node-version-file, and so does Netlify.
Installed with plain `npm install` — no --force, no --legacy-peer-deps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four documented breaking changes, one commit each in spirit but landed
together because none of them typechecks without the others:
1. react-router.config.ts — `future.v8_middleware` was removed in RR 8.0.0
(react-router#15078); middleware is unconditionally on. `FutureConfig`
now only carries `unstable_optimizeDeps` / `unstable_enableNodeReadableStream`,
so leaving the flag is a hard tsc error. The app was already running with
the flag set to true, so no behavioural migration was needed.
2. app/middleware/i18next.ts — remix-i18next 8 collapsed its subpath exports
to the package root, so `remix-i18next/middleware` becomes `remix-i18next`,
and the custom locale finder now receives the full middleware args:
`findLocale(request)` -> `findLocale({ request })`.
3. app/root.tsx — remix-i18next 8 deletes `useChangeLanguage` (and the whole
`remix-i18next/react` subpath) with no replacement export. Its own v7
@deprecated note prescribed `i18n.changeLanguage(loaderData.locale)`, so
the hook is reimplemented locally as exactly that, in an effect with the
same `i18n.language !== locale` guard the removed hook used.
4. 11 route modules — RR8 removed the deprecated `data` field on `MetaArgs`
in favour of `loaderData` (react-router#14931). Renamed in every `meta`
that used it: $locale._index, $locale.$slug, $locale.blog.$post, the four
speaking-time routes (de.ich-suche-redezeit, {en,ru,uk}.i-need-speaking-time)
and the four network-partner routes ({de.netzwerk-partner-medien,
{en,ru,uk}.network-partner-media).
Also audited and found not applicable: `react-router-dom` (already unused),
`hasErrorBoundary`, the Cloudflare dev proxy, `getLoadContext`, and
`future.v8_splitRouteModules` (never set; now a top-level option defaulting
to true).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
React Router 8 passes middleware the raw single-fetch request URL. A
client-side navigation to a locale root therefore arrives as "/uk.data",
so `pathname.split("/").at(1)` returned "uk.data" — not in
supportedLanguages — and remix-i18next fell through to Accept-Language,
resolving the root loader's locale to "en".
Observed before this fix: clicking the language switcher to Ukrainian
rendered <html lang="en"> and an English cookie banner on the Ukrainian
homepage. React Router 7 handed middleware the already-stripped
pathname, so this only regressed with the v8 upgrade.
Nested paths were unaffected ("/de/ich-suche-redezeit.data" still yields
"de"), which is why direct page loads and deeper routes looked fine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
974c6c7 to
50c776f
Compare
Rebased onto
|
| Path | Result |
|---|---|
de → en |
Popular topics / More filters / Languages / listeners found |
de → ru |
Популярные темы / Дополнительные фильтры |
de → uk |
Популярні теми / Додаткові фільтри |
en → de |
German restored |
Worth flagging: before the fix, the filter-page assertions also passed. The German-leak checks alone would have let this ship. It only surfaced because <html lang> and the cookie banner were checked on the locale-root landing page — the one place the bug shows.
🤖 Generated with Claude Code
Upgrades React Router 7 → 8 as one coordinated change. Replaces dependabot's #394.
Why this instead of #394
Dependabot proposed the React Router family +
remix-i18nextbump in #394, but that PR cannot work, because a working RR8 upgrade needs three things dependabot is not allowed to touch:@netlify/vite-plugin-react-router2.1.3 → 4.0.0. Not in chore(deps): bump react-router, @react-router/node, @react-router/serve, remix-i18next, @react-router/dev and @react-router/fs-routes #394's scope, and without it the deploy is silently broken (see below).remix-i18next8 and the Netlify plugin v4 all require Node>=22.22.0..nvmrcwasv22.14.0.#394 also targets
react-router8.3.0; this uses 8.3.1, the current 8.3 patch.The blocker: the Netlify plugin silently stops emitting the server handler
RR8 makes Vite's Environment API mandatory (
future.v8_viteEnvironmentApiwas removed and the behaviour is now unconditional — react-router#15077).@netlify/vite-plugin-react-router@2.1.3gates its work onisSsrBuild, which is never true under that model. It then produces no.netlify/v1/functions/react-router-server.mjsat all, whilenpm run buildstill exits0. That is netlify/remix-compute#698, fixed in plugin v4.0.0.Measured here, single-variable (same tree, same commit, only
node_modules/@netlify/vite-plugin-react-routerswapped,rm -rf build .netlify/v1/functionsbefore each build):npm run build.netlify/v1/functions/react-router-server.mjsbuild/server/main— RR 7.15.0 + plugin 2.1.3server.js,server-build.js.netlify/v1/is emptyindex.jsindex.jsSo a green
npm run buildand a green CIverifyare not sufficient evidence for this PR, and neither was checked by #394.Note the artifact shape legitimately changes with plugin v4: it no longer injects a virtual
netlify-serverentry into the React Router build, so there is nobuild/server/server.jsany more. Instead the generated function imports the standard RR server build directly:I imported that emitted
.mjsin Node and invoked its default export directly, to prove the handler is not just present but functional:<html><title>/delang="de" dir="ltr"/de/ich-suche-redezeitlang="de"/en/bloglang="en"/uklang="uk"/rulang="ru"Those titles come out of the
metafunctions, so this also exercises thedata→loaderDatarename end to end.Node bump
.nvmrcv22.14.0→v22.23.2(current Node 22 LTS "Jod"),engines.node>=22.12.0→>=22.22.0. Required by all three of RR8,remix-i18next8 and Netlify plugin v4. CI picks this up automatically vianode-version-file: .nvmrc, and Netlify reads.nvmrctoo.Dependencies
react-router,@react-router/{node,serve,dev,fs-routes}remix-i18next@netlify/vite-plugin-react-routerreact,react-domremix-i18next8 belongs in this PR and not a separate one: it declaresreact-router: ^8as a peer, so it cannot land before RR8 and RR8 cannot land while it is on 7.x.React moves because RR8's peer floor is
>=19.2.7.reactandreact-dommove together becausereact-domdeclaresreact: ^19.2.8— a skew there is a real renderer bug, not a cosmetic lockfile difference.Installed with plain
npm install. No--force, no--legacy-peer-deps. Lockfile churn is 13 added / 100 removed / 46 changed, and every changed entry is downstream of these bumps (notablyexpress4 → 5 and its middleware, pulled in by@react-router/serve8). No unrelated direct dependency moved.Source changes
Each maps to a documented RR8 / remix-i18next 8 breaking change:
react-router.config.ts—future.v8_middlewareremoved in 8.0.0 (react-router#15078); middleware is always on.FutureConfignow only holdsunstable_optimizeDeps/unstable_enableNodeReadableStream, so leaving the flag is a hardtscerror. The app was already running with the flagtrue, so there was no behavioural migration to do — just drop the block.app/middleware/i18next.ts— remix-i18next 8 collapsed its subpath exports to the package root (remix-i18next/middleware→remix-i18next) and now handsfindLocalethe full middleware args:findLocale(request)→findLocale({ request }).app/root.tsx— remix-i18next 8 deletesuseChangeLanguageand the wholeremix-i18next/reactsubpath, with no replacement export. Its own v7@deprecatednote prescribedi18n.changeLanguage(loaderData.locale), so the hook is reimplemented locally as exactly that, in auseEffectwith the samei18n.language !== localeguard the removed hook used.datafield onMetaArgsin favour ofloaderData(react-router#14931):$locale._index,$locale.$slug,$locale.blog.$post, the four speaking-time routes (de.ich-suche-redezeit,{en,ru,uk}.i-need-speaking-time) and the four network-partner routes (de.netzwerk-partner-medien,{en,ru,uk}.network-partner-media).Audited against the rest of the v8 changelog and found not applicable here:
react-router-dom(already unused),hasErrorBoundary, the removed Cloudflare dev proxy,getLoadContext(none — the Netlify plugin owns the handler), andfuture.v8_splitRouteModules(never set; now a top-level option defaulting totrue).Verification
npm install— clean, no--force/--legacy-peer-depsnpm ci— exit 0npm run typecheck(react-router typegen && tsc) — exit 0npm run build— exit 0rm -rf build .netlify/v1/functions, rebuild, handler emitted and functionally verified (table above)npx biome check app scripts types *.ts *.js *.json— 107 files, no fixes (identical file count tomain)npx vitest --run --dir app— 4 files / 73 tests passed/de,/en,/ru,/uk,/de/ich-suche-redezeit,/uk/i-need-speaking-time,/en/blogall render fully translated content. Grepped the SSR HTML of all seven for leaked i18n keys (filter.*,languageTags.*,genderTags.*, …) — zero hits, which is the thing most likely to break across aremix-i18nextmajor.main's baselines exactly: no filter 365,lang=de365,lang=en100,lang=uk12,lang=ru18. Also driven through the real client-side path: checking a language radio fires the<Form method="get">submit, the URL becomes?lang=ru&search=and the list goes 365 → 18 without a document load — so hydration,useSubmitand loader revalidation all work under RR8.npm ciVite dep-optimizer504 (Outdated Optimize Dep)burst (count frozen at 18 across further navigations; a fresh network capture is all 200s, zero 504s) and the pre-existingdata-headlessui-focus-visiblehydration attribute mismatch. No server-side errors.vite-tsconfig-pathsdeprecation notice in the build output is pre-existing onmain(Vite 8), unchanged here.Conflicts with #404
#404 (the i18n stack upgrade:
remix-i18next7.5.0,i18next26,react-i18next17) and this PR both moveremix-i18next— to 7.5.0 there, 8.0.0 here — and 8.0.0 requiresreact-router ^8, so they cannot both apply. Whichever merges second needs a rebase:remix-i18nextpushed back up to 8.0.0.remix-i18nextchange entirely and keep only thei18next/react-i18nextmoves (remix-i18next8.0.0 acceptsi18next^24 || ^25 || ^26, so those stay compatible).Refs #394 (supersedes — please close it when this lands), #389.
🤖 Generated with Claude Code