19 KiB
OpenSpec Change: enterprise-react-i18n
- Change ID: enterprise-react-i18n
- Package:
@suaveplan/react-i18n - Category: react
- Target path:
packages/react/react-i18n - Status: PROPOSED
- Date: 2026-05-04 (amended 2026-05-05 — see
## Amendment 2026-05-05below) - Supersedes:
openspec/changes/enterprise-react-translate/(i18next-based; replaced by this change which builds on our universal@suaveplan/i18ncore) - Cross-cutting design:
openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md(this proposal is one of six changes implementing that design — see §13 of the design doc for the full list)
Why
SUMMARY.md lists @suaveplan/react-translate as a pending migration; the legacy enterprise-react-translate proposal scaffolded that package on top of i18next + react-i18next. Since that proposal was written, genesis has shipped its own universal i18n core (@suaveplan/i18n — MessageCatalog, InterpolationEngine, PluralizationEngine, MessageParser, LocaleResolver, middleware, formatters) plus the i18n-storage-backends and i18n-completion changes (this wave) which round it out to feature parity with i18next. Building React bindings on i18next now would duplicate every primitive we already own and force two parallel observability stacks (i18next emits its own events, our core emits via @suaveplan/events).
This change supersedes the legacy proposal. We rebuild the React package as @suaveplan/react-i18n, built on top of our universal core, with the same public hooks the legacy proposal targeted (useTranslation, useErrorMessage, useDirection, withTranslation, TranslationProvider) plus a <Trans> rich-text component (the universally expected piece the legacy proposal omitted), Suspense-driven lazy namespace loading via the new Backend interface, and a missing-key dev overlay. @suaveplan/react-translate is retired before it ships — there is no migration cost because nothing in genesis imports it yet.
This package is the foundation every other React package will depend on for translated strings, so it must land before the enterprise-react-ui rewrite.
Current-state audit (genesis)
Package does not exist on genesis. packages/react/react-i18n/ does not exist. No genesis consumer imports @suaveplan/react-translate.
Available workspace deps on genesis
| Package | Role |
|---|---|
@suaveplan/i18n (workspace:^) |
Universal core: catalog, parser, plurals, formatters, plugin host |
@suaveplan/error (workspace:^) |
ReactI18nError extends SuaveplanError |
@suaveplan/events (workspace:^) |
Subscribe to loaded / missingKey / languageChanged |
@suaveplan/logger (workspace:^) |
Missing-key telemetry log sink |
@suaveplan/storage (workspace:^) |
Used by StorageBackend consumers via @suaveplan/i18n/backend/storage |
@suaveplan/locale-data (workspace:^) |
Country / language metadata for the language switcher |
@suaveplan/types (workspace:^) |
Shared Clock, IdGenerator contracts |
@opentelemetry/api (catalog:observability) |
Missing-key counter, render-duration histogram |
External: react (peer, ≥18), tslib catalog:build, zod catalog:forms. No i18next, no react-i18next, no i18next-browser-languagedetector, no i18next-http-backend — all replaced by the universal core's Backend plugins, detector plugins, and event bus.
Public API surface
// Provider
TranslationProvider, TranslationProviderProps
// Top-level instance access
useI18n, UseI18nReturn
// Translation hooks
useTranslation, UseTranslationReturn
useTranslate, UseTranslateReturn // hook-form returning the bound `t` directly
// Locale / direction hooks
useLocale, UseLocaleReturn
useDirection, UseDirectionReturn
// Specialised hooks
useErrorMessage, UseErrorMessageReturn
usePlural, UsePluralReturn
useDate, UseDateReturn
useNumber, UseNumberReturn
useCurrency, UseCurrencyReturn
useRelativeTime, UseRelativeTimeReturn
useList, UseListReturn
// Components
<Trans /> // rich-text component using MessageParser.formatChunks
<LanguageSwitcher /> // headless-by-default switcher; render-prop API
<MissingKeyOverlay /> // dev-mode visible badge for untranslated keys
<I18nSuspense /> // declarative Suspense wrapper for lazy backends
// HOC (escape hatch for class components)
withTranslation
// Config
createI18nConfig, I18nConfig
// Types
ReactI18nError, ReactI18nNamespace, ReactSupportedLocale, LanguageBundle
Design decisions
- Built on
@suaveplan/i18n.createI18n(...), not i18next. The Provider holds anI18nInstancefrom the universal core. Hooks subscribe to its event bus viauseSyncExternalStoreso they re-render onlanguageChanged/loaded/missingKeywithout polling. - Backend-agnostic. The Provider accepts a backend (or list of backends) via
@suaveplan/i18n'sBackendinterface. Common deployment shapes:<TranslationProvider backend={multiFileFsBackend({ root: '/locales' })}>— Node SSR with on-disk JSON<TranslationProvider backend={httpBackend({ loadPath: '/api/i18n/{locale}/{namespace}.json' })}>— browser SPA<TranslationProvider backend={storageBackend({ storage: opfsStorage, layout: 'flat-keys' })}>— offline-first PWA<TranslationProvider backend={chainedBackend([memoryBackend(seed), httpBackend(...)])}>— seeded + lazy
- Suspense for lazy namespaces. The
useTranslation('auth')hook triggersi18n.preload('auth')on mount. If the namespace is not yet loaded, the hook reads fromuseSuspenseQuery-style state and the parent<I18nSuspense fallback={...}>renders the fallback. No morei18next-react's "show key string until ready" flash. - Rich-text via
<Trans>. Backed byMessageParser.formatChunks<ReactNode>fromi18n-completion.<Trans i18nKey="welcome">Hello <bold>world</bold></Trans>renders with the values map{ bold: (chunks) => <b>{chunks}</b> }. - Dev-mode loud failure.
useTranslationoutside aTranslationProviderthrowsReactI18nError(REACT_I18N.NO_PROVIDER)inprocess.env.NODE_ENV !== 'production'; in production it logs once via@suaveplan/loggerand falls through to a no-op identity translator that returns the key string. - Missing-key overlay (dev-only).
<MissingKeyOverlay />is opt-in; when mounted, it subscribes to themissingKeyevent and renders a fixed-position badge listing untranslated keys. Tree-shaken out of production builds viaprocess.env.NODE_ENVchecks. - Headless
<LanguageSwitcher>. Render-prop API with built-in keyboard navigation (useReducerstate machine),aria-*attributes, anduseTransition-wrapped change so big namespace loads don't block rendering. No DOM markup imposed. - Server-side rendering. Provider is SSR-safe. Use
createI18nConfig({ initialLocale, initialMessages })to pre-populate the catalog from server-rendered HTML; the Provider hydrates synchronously without a network round-trip. - No bundled translations. The legacy proposal hardcoded 18 translation files (
en.ts,en-gb.ts,cy.ts,auth-en.ts, ...). That decision belongs in the consumer app, not in the React binding. This package ships only the React surface; translations come via the consumer's chosenBackend.
What changes
@suaveplan/react-i18n (new package)
- ADDED:
TranslationProvider— wraps the app with anI18nInstance. Acceptsi18n?: I18nInstance(caller-managed) orconfig: I18nConfig(Provider-managed). Connects the instance event bus to React state viauseSyncExternalStore. - ADDED:
useI18n()— returns the activeI18nInstancefor advanced consumers (plugin registration, manualpreload). - ADDED:
useTranslation(namespace?)— Suspense-aware. Auto-triggerspreloadon mount when a backend is registered. Returns{ t, i18n, ready }. Thetfunction is the ergonomic-options-bag form fromi18n-completion. - ADDED:
useTranslate(namespace?)— terse alias returning thetfunction directly. - ADDED:
useLocale()/useDirection()— re-render onlanguageChanged.useDirectionusesgetTextDirectionfrom the core. - ADDED:
useErrorMessage(error: SuaveplanError | unknown)— looks up the error code in theerrorsnamespace and falls back toerror.message. - ADDED:
usePlural(count, locale?)— returns{ category, rule }derived from the activePluralizationEngine. - ADDED:
useDate(date, options?),useNumber(value, options?),useCurrency(amount, currency, options?),useRelativeTime(deltaMs, options?),useList(values, options?)— memoised wrappers around the universal-core formatters; re-render onlanguageChanged. - ADDED:
<Trans i18nKey="..." values={...} components={...}>...</Trans>— rich-text component usingMessageParser.formatChunks<ReactNode>. Supports nested tags, fallback children-as-default-message, andcountfor plural selection. - ADDED:
<LanguageSwitcher>— headless render-prop. Receives{ locales, current, change, isPending }; consumers render their own DOM. Built-inuseTransitionfor non-blocking namespace loads. - ADDED:
<MissingKeyOverlay>— dev-mode badge. Subscribes tomissingKeyevent; renders absolute-positioned list. Production-tree-shaken. - ADDED:
<I18nSuspense fallback={...}>— convenience Suspense wrapper that catches namespace-load promises with a defaultaria-liveregion for accessibility. - ADDED:
withTranslationHOC — class-component escape hatch. Injectstandi18nprops. - ADDED:
createI18nConfig(options)— config builder forTranslationProvider. Validates options throughI18nConfigSchema(Zod). Returns a config object the Provider passes tocreateI18n. - ADDED: Error codes via
defineCodes("REACT_I18N", [...]):REACT_I18N.NO_PROVIDER— hook used outside aTranslationProviderREACT_I18N.NAMESPACE_LOAD_FAILED— Suspense-thrown promise rejected and the boundary caught itREACT_I18N.HYDRATION_MISMATCH— SSR initial messages do not satisfyTranslationRecordSchemaREACT_I18N.TRANS_INVALID_CHILD—<Trans>received a non-element / non-string child that can't be matched to a tag pair
Telemetry
- ADDED: counter
react_i18n.missing_key.total(mirrors core counter but tagged with React component name when available via React Profiler hook) - ADDED: histogram
react_i18n.render.duration_msfor<Trans>formatChunks render - ADDED: counter
react_i18n.suspense.fallback.total— incremented every time a Suspense fallback was rendered for a namespace load
Out of scope
- Translation bundles (
en.ts,cy.ts, etc. from the legacy proposal) — wrong layer. Consumer apps own their translations and choose aBackend. We do publish a seed helper (createSeedBackend(record)) for tests and Storybook, no actual bundles. - Built-in language detector configurations (browser, server, mixed) — these belong in
@suaveplan/i18nas detector plugins (covered byi18n-completion§3). The Provider just registers them; this package adds no detector code. - A11y-styled
<LanguageSwitcher>with menu UI — separate change once@suaveplan/react-uiis rebuilt; the headless version here is the building block. - i18next interop shim — out of scope for genesis 0.x. If a consumer needs to migrate from i18next, write a one-shot codemod after this package stabilises.
- React Server Components experimental APIs — defer until Next 16 / React 20 stabilise the boundary.
Source mapping
This change supersedes the legacy ../suaveplan/enterprise/react-translate.md. The supersession reason is structural (different core; cannot be a verbatim port). The hooks API is preserved by name and shape so the legacy spec's requirements still apply.
| Legacy requirement | Source | Migrated form | Notes |
|---|---|---|---|
TranslationProvider wraps with i18next |
enterprise/react-translate.md |
TranslationProvider wraps with I18nInstance |
Same external API, different internal engine |
useTranslation hook |
legacy | useTranslation |
Now Suspense-aware; auto-preload |
useDirection hook |
legacy | useDirection |
Direct port; reads from LocaleResolver |
useErrorMessage hook |
legacy | useErrorMessage |
Direct port |
withTranslation HOC |
legacy | withTranslation |
Direct port |
| Lazy bundle loading | legacy via i18next-http-backend |
<I18nSuspense> + Backend interface |
Genesis architecture; superior because the backend is pluggable per-deployment |
| Missing-key telemetry | legacy via i18next event | core missingKey event + react_i18n.missing_key.total counter |
Same observable; different wire |
| Translation bundles (18 files) | legacy | DROPPED — wrong layer | Justification in ## Out of scope; bundles belong in consumer apps |
Requirements with no source line
| Requirement | Justification |
|---|---|
<Trans> rich-text component |
Universal in React i18n libs; missing from legacy proposal. Unblocks complex strings like <b>{name}</b> joined the chat. |
| Suspense-driven lazy loading | Modern React idiom; eliminates the "show raw key" flash that legacy react-i18next consumers complain about. |
Headless <LanguageSwitcher> |
Composable building block; concrete UI lives in @suaveplan/react-ui. |
<MissingKeyOverlay> dev tool |
Accelerates translator workflow; standard in modern i18n stacks. |
useDate / useNumber / useCurrency / useRelativeTime / useList |
Memoised, locale-aware, re-render on locale change. The single most-asked feature in i18n React libraries. |
Verification approach
Each requirement carries a **Verified by:** line in the spec delta.
packages/react/react-i18n/src/provider/provider.test.tsx— Provider mount, instance hand-off, SSR initial-messages hydrationpackages/react/react-i18n/src/hooks/use-translation/use-translation.test.tsx— count / context / defaultValue / nested-ref / Suspensepackages/react/react-i18n/src/hooks/use-translation/use-translation.spec-conformance.test.tsx— dev-mode no-provider throw, prod-mode soft-failpackages/react/react-i18n/src/components/trans/trans.test.tsx— tag interpolation, nested tags, plural, fallbackpackages/react/react-i18n/src/components/language-switcher/language-switcher.test.tsx— headless render-prop, keyboard, useTransitionpackages/react/react-i18n/src/components/missing-key-overlay/missing-key-overlay.test.tsx— dev only, subscribes to event bus, batches updatespackages/react/react-i18n/src/hooks/formatting/*.test.tsx— every formatting hook re-renders onlanguageChangedpackages/react/react-i18n/e2e/react-i18n.spec.ts— Playwright: live language switch, RTL flip on Arabic, lazy-namespace Suspense, missing-key overlay, SSR hydration
Migration impact
Pure additive. @suaveplan/react-translate does not exist on genesis (it was scoped in the legacy proposal but never built). Consumers from ../suaveplan/ that imported @suaveplan/react-translate will need a one-line rename when they migrate to genesis (@suaveplan/react-translate → @suaveplan/react-i18n); document the rename in the React migration guide once written.
Affected dependents
grep -rl "@suaveplan/react-translate\|@suaveplan/react-i18n" /home/ubuntu/web/genesis/packages/ --include="package.json" — currently zero. Future React packages will declare @suaveplan/react-i18n workspace:^ as a peer.
React versioning
Peer react ^18.0.0 || ^19.0.0. useSyncExternalStore was added in React 18 (and shimmed in 17 via use-sync-external-store/shim); we target 18+ to avoid the shim. Verify only one React version is hoisted after install per CLAUDE.md "Vitest / WSL2" guidance.
Amendment 2026-05-05
The cross-cutting design at openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md adds three capability blocks to this change. The amendment is non-breaking — existing requirements stand as-is; the new requirements extend the public surface.
A1. Locale-detection chain (was deferred to a non-existent i18n-completion §3)
The original proposal punted detector-chain configuration to a follow-up i18n-completion change that was never authored. The detector chain is required by the React UI stack (theme switcher, language switcher, SSR contract) and lands here. Spec deltas added under ## ADDED Requirements in specs/react-i18n/spec.md:
LocaleDetectorcontract re-exported from@suaveplan/i18n/detector- Nine built-in detectors:
querystring,userPreference,cookie,localStorage,htmlLang,acceptLanguage,navigator,cloudflareIpGeo,fallback defaultDetectorChain(opts)factory exporting the canonical orderuseLocaleDetection()React hook- SSR contract — server's choice authoritative for initial render; browser detectors skipped during hydration;
redetect()re-runs full chain on user action - Detector telemetry:
i18n.detector.match_total{detector},i18n.detector.duration_ms{detector}, spani18n.detect
cloudflareIpGeoDetector reads the free cf-ipcountry header and maps the ISO 3166 country code to a locale via @suaveplan/locale-data. Other IP-geo providers ship as user-provided lookup injected into a generic detector — covered by the universal core, not this package.
The userPreferenceDetector requires SSR auth threading via extractAuth(ctx) => HeadersInit — documented in §7.2.1 of the cross-cutting design.
A2. Branded TranslationKey typing contract
Required by the no-hardcoded-strings rule (scripts/lint/check-no-hardcoded-strings.ts) shipped by enterprise-react-ui-v2, enterprise-react-forms-v2, enterprise-react-ui-theme-dracula, and enterprise-react-ui-pack-auth. The contract:
TranslationKeyis exported from@suaveplan/react-i18nas a branded string union overkeyof AppTranslationKeys | keyof ContributedTranslationKeys- Both interfaces empty by default; downstream packages augment
ContributedTranslationKeys, consumer apps augmentAppTranslationKeys @suaveplan/react-i18npackage.json contains NO leaf-package deps (CI gatescripts/lint/check-no-backwards-deps.tsenforces this)- Each downstream package generates its own augmentation file from its
locales/en-GB.jsonviabun scripts/generate-translation-types.ts
This pattern is identical to how react-router augments register types — proven in production. The leaf-package CI gate exists because if react-i18n ever grew a dep on a leaf package, the cycle would silently re-emerge.
A3. tDynamic() runtime escape hatch + REACT_I18N.UNKNOWN_KEY
For the rare case where a translation key is computed at runtime (e.g. mapping error.code to errors.<code>). tDynamic(unknownString) does a runtime catalog assertion; missing key throws ReactI18nError(REACT_I18N.UNKNOWN_KEY). Every call emits a warn-level log so observability surfaces every use site (the typed path via t(literalKey) is preferred 99% of the time).
A4. Tasks added under §11 below
A new task block in tasks.md covers the detector implementations, the useLocaleDetection hook, the TranslationKey typing contract, the generate-translation-types.ts script, and the check-no-backwards-deps.ts CI gate. Existing scaffolding tasks (§2) and existing module tasks (§3-§10) are unaffected.
A5. Telemetry additions
Added to the existing telemetry table in this proposal:
- Counter
i18n.detector.match_total{detector}(per A1) - Histogram
i18n.detector.duration_ms{detector}(per A1) - Span
i18n.detect(per A1) - The
tDynamicwarn-log entries are not OTel — they go to the@suaveplan/loggersink and surface in centralised log aggregation