Skip to content

feat: add Estonian (et) locale - #101

Closed
raudraido wants to merge 5 commits into
root-fr:mainfrom
raudraido:feat/add-estonian-locale
Closed

feat: add Estonian (et) locale#101
raudraido wants to merge 5 commits into
root-fr:mainfrom
raudraido:feat/add-estonian-locale

Conversation

@raudraido

@raudraido raudraido commented Aug 6, 2026

Copy link
Copy Markdown

Summary

Adds Estonian as a fully supported locale, following the same pattern used when Polish was added:

  • Full translation of locales/et/common.json (same key set as every other locale, verified by the parity test).
  • Registered in i18n/routing.ts, components/ui/language-switcher.tsx (native label Eesti), and components/providers/intl-provider.tsx (pre-loaded message bundle).
  • Every existing locale gets language.estonian / language.switch_to_estonian entries, mirroring the existing convention where a language's own native name (e.g. "polish": "Polski") is identical across every bundle, while switch_to_X is translated into the viewing locale's own language.
  • lib/__tests__/locale-parity.test.ts updated: imports the new bundle, bumps the expected locale count to 12, and adds an Estonian-labels check mirroring the existing Polish one.

Also included, found while preparing/testing this change:

  • A one-line fix: for npm run lint, which is currently broken on main — Next.js 16 removed the next lint command entirely, but package.json still calls it (CONTRIBUTING.md asks every contributor to run this before opening a PR).
  • A fix: for several strings that bypass useTranslations() entirely and always render in English regardless of locale, found by testing the new Estonian translation end-to-end:
    • Reply / Reply all / Forward labels in the email viewer were hardcoded JSX text.
    • System mailbox names (Inbox, Drafts, Sent, Trash, Archive, Junk, ...) are rendered from the JMAP mailbox's own name everywhere (sidebar, mobile header, tab title, "Move to..." popover, context-menu submenu) instead of the already-translated sidebar.mailboxes.* labels that existed in every locale but were never used. Added a shared getMailboxDisplayName(role, name, t) helper and applied it everywhere a mailbox name is shown, including the move-to-popover's search filter.
    • The open-email header timestamp hardcoded toLocaleString('en-US', ...); the reply-quote header used the browser/OS default locale instead of the app's selected one.
    • formatDate() (the relative "2h ago" timestamps in every email list row) hardcoded English strings and en-US, even though translated date.just_now / date.minutes_ago / date.hours_ago / date.days_ago keys already existed in every locale and were simply never called.
    • A few list-item components hardcoded "(no subject)" / "No preview available" / "Unknown" instead of using the equivalent keys that already existed elsewhere (added email_list.no_preview to every locale since no existing key matched that exact string).
  • A fix: for a real race condition in IntlProvider that made the selected locale silently revert to the server default on every reload. zustand's persist middleware rehydrates from localStorage asynchronously (even for a synchronous backend like localStorage), so currentLocale can still read as its pre-hydration null for the first tick after mount. The effect that seeds the store with the server-rendered default locale ran unconditionally whenever currentLocale was falsy — including that pre-hydration tick — so it would frequently win the race and overwrite a real saved locale with en before the persisted value ever loaded. Fixed by tracking hydration explicitly via persist.hasHydrated()/onFinishHydration() and only seeding the default once hydration is confirmed complete.
    • Follow-up fix in the same area: persist.hasHydrated/onFinishHydration are only present in a browser context — during Next.js SSR there's no localStorage and .persist itself is undefined, so calling it unconditionally 500'd on every server render. The vitest suite didn't catch this since it runs in jsdom, where .persist always exists. Guarded with optional chaining; a missing .persist is treated as "already hydrated" (correct for SSR — nothing to wait for).

Test plan

  • npx vitest run — 877/877 passing.
  • npx eslint . — clean, no warnings.
  • tsc --noEmit — clean.
  • npx next build --webpack — builds clean.
  • Manually verified in a real deployment: language switcher shows "Eesti", selecting it translates the full UI including mailbox names/dates/reply buttons, reloading persists the choice, and the app actually renders (caught the SSR crash live before this fix landed).

Full translation of the common.json message bundle, wired into the
app the same way Polish was added: registered in i18n/routing.ts,
components/ui/language-switcher.tsx (native label), and
components/providers/intl-provider.tsx (pre-loaded message bundle).

Every existing locale also gets `language.estonian`/
`language.switch_to_estonian` entries, matching the existing pattern
where each language's own native name is universal across bundles and
the "switch to X" phrase is translated per viewing locale — same as
when Polish was added.

lib/__tests__/locale-parity.test.ts updated: imports the new bundle,
bumps the expected locale count to 12, and adds an Estonian-labels
parity check mirroring the existing Polish one.
Next.js 16 dropped the `next lint` command entirely, so `npm run lint`
(and `lint:fix`) — which CONTRIBUTING.md instructs every contributor to
run before opening a PR — has been silently broken since the v1.6.0
Next 16 upgrade. Run eslint directly instead.
Found while testing the new Estonian locale end-to-end — a handful of
user-facing strings were never wired to useTranslations() at all, so
they always rendered in English regardless of the selected language:

- Reply / Reply all / Forward labels in the email viewer's quick-action
  bar and its overflow dropdown were hardcoded JSX text.
- System mailbox names (Inbox, Drafts, Sent, Trash, Archive, Junk, ...)
  are rendered from the JMAP mailbox's own `name` property everywhere
  (sidebar tree, mobile header, tab title, "Move to..." popover and
  context-menu submenu) instead of the already-translated labels that
  were sitting unused in every locale's `sidebar.mailboxes.*` keys.
  Added a shared getMailboxDisplayName(role, name, t) helper (maps the
  JMAP RFC 8621 role to its translated label; falls back to the literal
  name for custom/shared folders, which correctly stay untranslated)
  and used it everywhere a mailbox name is displayed, including the
  move-to-popover's search filter so typing a translated name actually
  matches.
- The open-email header timestamp used toLocaleString('en-US', ...)
  unconditionally; the reply-quote header used the browser/OS default
  locale rather than the app's selected one. Both now use useLocale().
- formatDate() (relative timestamps like "2h ago" in every email list
  row) hardcoded "Just now" / "Xm ago" / "Xh ago" / "Xd ago" in English
  and "en-US" for the older-date fallback, even though translated
  date.just_now / date.minutes_ago / date.hours_ago / date.days_ago
  keys already existed in every locale and were simply never called.
  It now takes a translator + locale and all 5 call sites were updated.
- "(no subject)" / "No preview available" / "Unknown" sender fallbacks
  were hardcoded in a few list-item components even though translated
  equivalents already existed elsewhere in the app; consolidated onto
  the existing keys (adding a new `email_list.no_preview` key, since no
  existing key matched that exact string, to every locale).
zustand's persist middleware rehydrates from localStorage
asynchronously (even for synchronous storage backends like
localStorage), so on mount `currentLocale` can still read as its
pre-hydration `null` default for one tick. The mount effect that seeds
the store with the server-rendered default locale ran unconditionally
whenever `currentLocale` was falsy — including that pre-hydration tick
— so it would frequently win the race and overwrite a real saved
locale (e.g. "et") with the server default ("en") before the persisted
value ever had a chance to load. Every reload silently reset the
language back to English while every other persisted setting (theme,
density, etc. — none of which have this seed-on-mount pattern) was
unaffected.

Track hydration explicitly via zustand's own
`persist.hasHydrated()`/`onFinishHydration()` API and only seed the
default once hydration is confirmed complete.
zustand's persist API (`.persist.hasHydrated()`, `.persist.onFinishHydration()`)
is only present in a browser context — during Next.js SSR there's no
localStorage to hydrate from, and `useLocaleStore.persist` is
undefined there. Calling `.hasHydrated()` unconditionally on it broke
every server render with "Cannot read properties of undefined (reading
'hasHydrated')", a 500 on every request. The vitest suite didn't catch
this because it runs in jsdom, where `window`/localStorage always
exist and `.persist` is always defined — this only reproduces in an
actual Next.js SSR pass.

Guard with optional chaining and treat a missing `.persist` as
"already hydrated" (correct for SSR: there's nothing to wait for,
just use the server-rendered initialLocale).
@raudraido raudraido closed this Aug 8, 2026
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