Files
suaveplan/openspec/archive/2026-05-05-enterprise-react-i18n/proposal.md
2026-05-27 23:29:53 +01:00

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-05 below)
  • Supersedes: openspec/changes/enterprise-react-translate/ (i18next-based; replaced by this change which builds on our universal @suaveplan/i18n core)
  • 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

  1. Built on @suaveplan/i18n.createI18n(...), not i18next. The Provider holds an I18nInstance from the universal core. Hooks subscribe to its event bus via useSyncExternalStore so they re-render on languageChanged / loaded / missingKey without polling.
  2. Backend-agnostic. The Provider accepts a backend (or list of backends) via @suaveplan/i18n's Backend interface. 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
  3. Suspense for lazy namespaces. The useTranslation('auth') hook triggers i18n.preload('auth') on mount. If the namespace is not yet loaded, the hook reads from useSuspenseQuery-style state and the parent <I18nSuspense fallback={...}> renders the fallback. No more i18next-react's "show key string until ready" flash.
  4. Rich-text via <Trans>. Backed by MessageParser.formatChunks<ReactNode> from i18n-completion. <Trans i18nKey="welcome">Hello <bold>world</bold></Trans> renders with the values map { bold: (chunks) => <b>{chunks}</b> }.
  5. Dev-mode loud failure. useTranslation outside a TranslationProvider throws ReactI18nError(REACT_I18N.NO_PROVIDER) in process.env.NODE_ENV !== 'production'; in production it logs once via @suaveplan/logger and falls through to a no-op identity translator that returns the key string.
  6. Missing-key overlay (dev-only). <MissingKeyOverlay /> is opt-in; when mounted, it subscribes to the missingKey event and renders a fixed-position badge listing untranslated keys. Tree-shaken out of production builds via process.env.NODE_ENV checks.
  7. Headless <LanguageSwitcher>. Render-prop API with built-in keyboard navigation (useReducer state machine), aria-* attributes, and useTransition-wrapped change so big namespace loads don't block rendering. No DOM markup imposed.
  8. 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.
  9. 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 chosen Backend.

What changes

@suaveplan/react-i18n (new package)

  • ADDED: TranslationProvider — wraps the app with an I18nInstance. Accepts i18n?: I18nInstance (caller-managed) or config: I18nConfig (Provider-managed). Connects the instance event bus to React state via useSyncExternalStore.
  • ADDED: useI18n() — returns the active I18nInstance for advanced consumers (plugin registration, manual preload).
  • ADDED: useTranslation(namespace?) — Suspense-aware. Auto-triggers preload on mount when a backend is registered. Returns { t, i18n, ready }. The t function is the ergonomic-options-bag form from i18n-completion.
  • ADDED: useTranslate(namespace?) — terse alias returning the t function directly.
  • ADDED: useLocale() / useDirection() — re-render on languageChanged. useDirection uses getTextDirection from the core.
  • ADDED: useErrorMessage(error: SuaveplanError | unknown) — looks up the error code in the errors namespace and falls back to error.message.
  • ADDED: usePlural(count, locale?) — returns { category, rule } derived from the active PluralizationEngine.
  • 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 on languageChanged.
  • ADDED: <Trans i18nKey="..." values={...} components={...}>...</Trans> — rich-text component using MessageParser.formatChunks<ReactNode>. Supports nested tags, fallback children-as-default-message, and count for plural selection.
  • ADDED: <LanguageSwitcher> — headless render-prop. Receives { locales, current, change, isPending }; consumers render their own DOM. Built-in useTransition for non-blocking namespace loads.
  • ADDED: <MissingKeyOverlay> — dev-mode badge. Subscribes to missingKey event; renders absolute-positioned list. Production-tree-shaken.
  • ADDED: <I18nSuspense fallback={...}> — convenience Suspense wrapper that catches namespace-load promises with a default aria-live region for accessibility.
  • ADDED: withTranslation HOC — class-component escape hatch. Injects t and i18n props.
  • ADDED: createI18nConfig(options) — config builder for TranslationProvider. Validates options through I18nConfigSchema (Zod). Returns a config object the Provider passes to createI18n.
  • ADDED: Error codes via defineCodes("REACT_I18N", [...]):
    • REACT_I18N.NO_PROVIDER — hook used outside a TranslationProvider
    • REACT_I18N.NAMESPACE_LOAD_FAILED — Suspense-thrown promise rejected and the boundary caught it
    • REACT_I18N.HYDRATION_MISMATCH — SSR initial messages do not satisfy TranslationRecordSchema
    • REACT_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_ms for <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 a Backend. 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/i18n as detector plugins (covered by i18n-completion §3). The Provider just registers them; this package adds no detector code.
  • A11y-styled <LanguageSwitcher> with menu UI — separate change once @suaveplan/react-ui is 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 hydration
  • packages/react/react-i18n/src/hooks/use-translation/use-translation.test.tsx — count / context / defaultValue / nested-ref / Suspense
  • packages/react/react-i18n/src/hooks/use-translation/use-translation.spec-conformance.test.tsx — dev-mode no-provider throw, prod-mode soft-fail
  • packages/react/react-i18n/src/components/trans/trans.test.tsx — tag interpolation, nested tags, plural, fallback
  • packages/react/react-i18n/src/components/language-switcher/language-switcher.test.tsx — headless render-prop, keyboard, useTransition
  • packages/react/react-i18n/src/components/missing-key-overlay/missing-key-overlay.test.tsx — dev only, subscribes to event bus, batches updates
  • packages/react/react-i18n/src/hooks/formatting/*.test.tsx — every formatting hook re-renders on languageChanged
  • packages/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:

  • LocaleDetector contract 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 order
  • useLocaleDetection() 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}, span i18n.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:

  • TranslationKey is exported from @suaveplan/react-i18n as a branded string union over keyof AppTranslationKeys | keyof ContributedTranslationKeys
  • Both interfaces empty by default; downstream packages augment ContributedTranslationKeys, consumer apps augment AppTranslationKeys
  • @suaveplan/react-i18n package.json contains NO leaf-package deps (CI gate scripts/lint/check-no-backwards-deps.ts enforces this)
  • Each downstream package generates its own augmentation file from its locales/en-GB.json via bun 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 tDynamic warn-log entries are not OTel — they go to the @suaveplan/logger sink and surface in centralised log aggregation