feat: add Estonian (et) locale - #101
Closed
raudraido wants to merge 5 commits into
Closed
Conversation
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds Estonian as a fully supported locale, following the same pattern used when Polish was added:
locales/et/common.json(same key set as every other locale, verified by the parity test).i18n/routing.ts,components/ui/language-switcher.tsx(native labelEesti), andcomponents/providers/intl-provider.tsx(pre-loaded message bundle).language.estonian/language.switch_to_estonianentries, mirroring the existing convention where a language's own native name (e.g."polish": "Polski") is identical across every bundle, whileswitch_to_Xis translated into the viewing locale's own language.lib/__tests__/locale-parity.test.tsupdated: 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:
fix:fornpm run lint, which is currently broken onmain— Next.js 16 removed thenext lintcommand entirely, butpackage.jsonstill calls it (CONTRIBUTING.mdasks every contributor to run this before opening a PR).fix:for several strings that bypassuseTranslations()entirely and always render in English regardless of locale, found by testing the new Estonian translation end-to-end:nameeverywhere (sidebar, mobile header, tab title, "Move to..." popover, context-menu submenu) instead of the already-translatedsidebar.mailboxes.*labels that existed in every locale but were never used. Added a sharedgetMailboxDisplayName(role, name, t)helper and applied it everywhere a mailbox name is shown, including the move-to-popover's search filter.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 anden-US, even though translateddate.just_now/date.minutes_ago/date.hours_ago/date.days_agokeys already existed in every locale and were simply never called."(no subject)"/"No preview available"/"Unknown"instead of using the equivalent keys that already existed elsewhere (addedemail_list.no_previewto every locale since no existing key matched that exact string).fix:for a real race condition inIntlProviderthat made the selected locale silently revert to the server default on every reload. zustand'spersistmiddleware rehydrates fromlocalStorageasynchronously (even for a synchronous backend likelocalStorage), socurrentLocalecan still read as its pre-hydrationnullfor the first tick after mount. The effect that seeds the store with the server-rendered default locale ran unconditionally whenevercurrentLocalewas falsy — including that pre-hydration tick — so it would frequently win the race and overwrite a real saved locale withenbefore the persisted value ever loaded. Fixed by tracking hydration explicitly viapersist.hasHydrated()/onFinishHydration()and only seeding the default once hydration is confirmed complete.persist.hasHydrated/onFinishHydrationare only present in a browser context — during Next.js SSR there's nolocalStorageand.persistitself isundefined, so calling it unconditionally 500'd on every server render. The vitest suite didn't catch this since it runs in jsdom, where.persistalways exists. Guarded with optional chaining; a missing.persistis 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.