Skip to content

chore(deps): upgrade i18n stack (remix-i18next 7.5.0, i18next 26, react-i18next 17) - #404

Merged
HerrBertling merged 2 commits into
mainfrom
chore/i18n-stack-upgrade
Sep 6, 2026
Merged

HerrBertling merged 2 commits into
mainfrom
chore/i18n-stack-upgrade

Conversation

@HerrBertling

Copy link
Copy Markdown
Owner

Upgrades the three i18n packages together. None of them can move on its own.

package from to
remix-i18next 7.4.2 7.5.0
i18next 25.8.7 26.4.2
react-i18next 15.7.4 17.0.13

Why all three must move together

Dependabot's #379 tries to bump react-i18next alone to 17.0.7. That PR is uninstallable, because of two peer walls:

  1. remix-i18next@7.4.2 caps react-i18next at ^13 || ^14 || ^15 || ^16 — so react-i18next@17 cannot be installed alongside it at all.
  2. react-i18next@17 requires i18next >= 26.0.10, but remix-i18next@7.4.2 caps i18next at ^24 || ^25.

remix-i18next@7.5.0 is a peer-range-only release (#458) that widens both sides:

i18next:       ^24.0.0 || ^25.0.0 || ^26.0.0
react-i18next: ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
react-router:  ^7.0.0

Because it still requires only react-router ^7, the whole i18n stack can move now without waiting on React Router 8.

Deliberately staying on React Router 7

This does not bump remix-i18next to 8.0.0. remix-i18next@8 requires react-router ^8, moves all exports to the package root (remix-i18next/middleware → remix-i18next), and changes the findLocale callback signature — none of which can land while the React Router 8 upgrade is blocked. 7.5.0 is the version that unblocks the i18n stack on RR7.

Breaking changes checked

No source changes were needed. Every documented breaking change was checked against this repo:

i18next 26.0.0 removes: initImmediate, the legacy monolithic interpolation.format function, the showSupportNotice console notice, and simplifyPluralSuffix. grep across app/, public/locales/, scripts/ and the root config files finds zero uses of any of them. app/entry.client.tsx and app/middleware/i18next.ts are the only places that call init()/createI18nextMiddleware, and neither passes any removed option.

react-i18next 16.0.0 was a peer-dependency bump only (no API change).

react-i18next 17.0.0 has exactly one potentially-breaking change: transKeepBasicHtmlNodesFor now preserves HTML tag names in auto-generated <Trans> keys. This repo has zero Trans / IcuTrans usage — the whole surface in use is useTranslation (11 files), I18nextProvider (app/entry.client.tsx, app/entry.server.tsx), initReactI18next (app/entry.client.tsx), and useChangeLanguage from remix-i18next/react (app/root.tsx). None of those changed in 16 or 17.

i18next-browser-languagedetector (used in app/entry.client.tsx) declares no peer dependencies at all, so nothing forces it to move. It stays at ^8.0.0.

The resulting lockfile change is minimal — 3 direct bumps plus html-parse-stringify 3.0.1 → 4.0.1 and @babel/runtime 7.28.4 → 7.29.7, use-sync-external-store 1.6.0 added, void-elements dropped. npm audit is unchanged at 10 findings, all in the pre-existing react-router / express / vite chain, none i18n-related.

Verification

npm install succeeded with no --force and no --legacy-peer-deps.

check result
npm ci clean
npm run typecheck pass
npm run build pass
npx biome check app scripts types *.ts *.js *.json 107 files, 0 findings
npx vitest --run --dir app 4 files / 73 tests passed

Build artifacts were verified after deleting build/ and .netlify/v1/functions/ first, since the Netlify tooling can fail silently while exiting 0:

  • .netlify/v1/functions/react-router-server.mjs — emitted
  • build/server/server.js — emitted

Browser smoke (dev server)

Since this is the i18n stack, the smoke test focused on translation resolution.

  • /de — German homepage, "Wir hören Dir zu!", fully translated.
  • /de/ich-suche-redezeit — coach search renders, 365 coaches, <html lang="de" dir="ltr">.
  • /en/blog — "The latest blog posts", English UI strings ("Continue reading...").
  • /uk — lang="uk", Ukrainian content ("Ласкаво просимо…", "Перейти до вмісту").
  • /ru — lang="ru", Russian content ("Добро пожаловать…", "Перейти к содержимому").

No raw i18n keys leaked. Every t() key used in the codebase (filter.gender, filter.language, filter.moreFilters, filter.popularTags, filter.showFilter, filter.submitCta, filter.tag, title.network, title.partner, title.media, machineMessage, skipToContent, ctaToBlogpost, noResult, …) was searched for as a literal string in the rendered body text of each locale — zero matches on all of /de, /de/ich-suche-redezeit, /uk and /ru.

Coach language filter exercised, counts change correctly (confirmed both by clicking the radios in the hydrated client and by SSR response):

filter coaches
none 365
?lang=de 365
?lang=en 100
?lang=uk 12
?lang=ru 18
?gender=weiblich 273

The filter's own language chips (Deutsch / English / Polskie / русский / Український) and the per-coach language labels render as translated text throughout.

Server logs: no errors. Console: only the runbook's documented benign Vite dep-optimizer burst right after a fresh npm ci (504 Outdated Optimize Dep + one failed dynamic import of entry.client.tsx). Confirmed benign per the documented procedure — the error list stayed frozen across three further navigations and a fresh network capture showed 200 OK on every request with a stable dep hash and zero 504s.

Conflicts with the React Router 8 work

⚠️ This conflicts with the separate React Router 8 upgrade, which needs remix-i18next@8.0.0 (and the import/findLocale migration that comes with it). Both touch package.json / package-lock.json and the remix-i18next major. Whichever merges second will need a rebase.

Refs #379 — that PR stays open and is superseded by this one; it cannot be installed on its own.

🤖 Generated with Claude Code

…ct-i18next 17)

Bumps the three i18n packages together, because none of them can move
alone:

- remix-i18next 7.4.2 -> 7.5.0
- i18next        25.8.7 -> 26.4.2
- react-i18next  15.7.4 -> 17.0.13

remix-i18next@7.4.2 pins `i18next: ^24 || ^25` and
`react-i18next: ^13..^16`, while react-i18next@17 requires
`i18next >= 26.0.10`. 7.5.0 is a peer-range-only release that widens both
(`i18next: ^24 || ^25 || ^26`, `react-i18next: ^13..^17`) and still
requires only `react-router ^7`, so the upgrade lands without React
Router 8.

Deliberately stays on remix-i18next 7.x: 8.0.0 requires react-router ^8.

No source changes needed. The documented breaking changes do not apply:

- i18next 26 removes `initImmediate`, the legacy `interpolation.format`
  function, `showSupportNotice` and `simplifyPluralSuffix` — none are
  used anywhere in this repo.
- react-i18next 16.0.0 was a peer bump only.
- react-i18next 17.0.0's single breaking change is `Trans`
  auto-generated key serialization; this app has zero `Trans` usage.

`i18next-browser-languagedetector` declares no peer dependencies and is
unaffected, so it stays at ^8.0.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The client i18next instance was initialised with only the locale the
document had been server-rendered with, plus German as the fallback:

    const locale = document.documentElement.lang || "de";
    resources = { [locale]: …, de: … }

The language switcher navigates client-side, so useChangeLanguage moves
i18next to the new locale without a reload. With no resource bundle for
that locale, every t() call resolves through fallbackLng: "de" instead.
The result is German UI on an English page, while CMS content — which
comes from the route loader, not i18next — is correctly translated.

Reproduced on production: land on /de/ich-suche-redezeit, switch to EN,
open the coach search, and the filter chrome reads "Häufige Themen" /
"Weitere Filter" / "Sprachen" under `<html lang="en">`. A hard reload
fixes it, because hydrate() then sees lang="en" and loads that bundle.

Only breaks away from German: de -> en/ru/uk all fail, while en -> de
works because German is loaded anyway as the fallback.

Load all four bundles instead. They are ~1.2 kB gzipped each, so this
costs ~2.6 kB over the previous locale-plus-fallback pair — cheaper than
importing on languageChanged, which would flash German while the import
resolves.

Verified against the dev server across every direction away from German:
de -> en gives "Popular topics"/"More filters"/"listeners found",
de -> ru gives "Популярные темы"/"Дополнительные фильтры",
de -> uk gives "Популярні теми"/"Додаткові фільтри",
each with zero German leakage, and en -> de still renders German.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@HerrBertling

Copy link
Copy Markdown
Owner Author

Added: fix for client-side language switching

Pushed 5b32267, which fixes a pre-existing bug on main (not introduced by this upgrade — reproduced on production before touching anything).

Symptom

Land on a German page, switch to English via the language switcher, and the UI chrome stays German — Häufige Themen, Weitere Filter, Sprachen, Zuhörer*innen gefunden — while <html lang="en"> and all CMS content are correctly English. A hard reload fixes it.

Cause

app/entry.client.tsx initialised i18next with only the locale the document was server-rendered with, plus German as fallback:

const locale = document.documentElement.lang || "de";
resources = { [locale]: …, de: … }

The switcher navigates client-side, so useChangeLanguage moves i18next to the new locale without a reload. With no bundle for that locale, every t() resolves through fallbackLng: "de". CMS strings come from the route loader rather than i18next, which is why only the chrome regresses.

It only breaks away from German — en → de works, because German is loaded as the fallback regardless. That asymmetry is why it went unnoticed.

Fix

Load all four bundles at hydrate. They are ~1.2 kB gzipped each, so ~2.6 kB more than the previous locale-plus-fallback pair. Importing lazily on languageChanged was rejected: it would flash German while the import resolves, for no meaningful saving at this size.

Verification

Every direction away from German, via the real switcher with client-side navigation:

Path Result
de → en Popular topics / More filters / listeners found / Languages
de → ru Популярные темы / Дополнительные фильтры
de → uk Популярні теми / Додаткові фільтри
en → de still German (no regression)

Zero German leakage in all three, <html lang> correct throughout. npm ci, typecheck, build, scoped biome (107 files, 0 findings) and scoped vitest (4 files / 73 tests) all pass. Console shows only the pre-existing noise documented for this repo.

Worth noting: this bug was invisible to the whole dependency-queue verification regime, which only ever navigated directly to /de, /en, /uk, /ru. Direct loads always work. It takes a client-side locale switch to surface it.

🤖 Generated with Claude Code

@HerrBertling
HerrBertling merged commit f0bee2e into main Sep 6, 2026
6 checks passed
@HerrBertling
HerrBertling deleted the chore/i18n-stack-upgrade branch September 6, 2026 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant