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

33 KiB

Capability Spec: react-i18n

Package: @suaveplan/react-i18n Status: PROPOSED

Every requirement carries a **Verified by:** line at authoring time. (See openspec/AGENTS.md Section 13.)

ADDED Requirements

Requirement: TranslationProvider mounts an I18nInstance

The package SHALL expose TranslationProvider that mounts and exposes an I18nInstance from @suaveplan/i18n to descendants via React context. The Provider SHALL accept either i18n: I18nInstance (caller-managed; Provider does NOT dispose) or config: I18nConfig (Provider-managed; calls createI18n(config) on mount and i18n.dispose() on unmount). It SHALL connect the instance event bus to React state via useSyncExternalStore so any subscribed hook re-renders on languageChanged / loaded.

Scenario: caller-managed instance is not disposed on unmount

  • GIVEN an externally created I18nInstance passed via i18n prop
  • WHEN the Provider unmounts
  • THEN the instance's dispose() was NOT called

Scenario: provider-managed instance is disposed on unmount

  • GIVEN a Provider mounted with config={...}
  • WHEN the Provider unmounts
  • THEN the internally-created instance's dispose() was called exactly once

Scenario: SSR initial messages hydrate without round-trip

  • GIVEN server-rendered HTML with embedded <script id="__i18n_initial__"> containing pre-loaded messages, and config.initialMessages = parseInitial() on the client
  • WHEN the Provider mounts on the client
  • THEN useTranslation returns translated strings synchronously on the first render with no Suspense fallback

Verified by: packages/react/react-i18n/src/provider/provider.test.tsx::"caller-managed instance not disposed" AND packages/react/react-i18n/src/provider/provider.test.tsx::"provider-managed instance disposed" AND packages/react/react-i18n/src/provider/provider.test.tsx::"SSR initial messages hydrate"


Requirement: useTranslation Suspense-aware preload

useTranslation(namespace?) SHALL return { t, i18n, ready } where t is the ergonomic-options-bag translator from the universal core. When a Backend is registered on the instance and the requested namespace is not yet loaded, the hook SHALL throw a Promise (Suspense semantics) returned by i18n.preload(namespace). The Suspense boundary catches it; once the load resolves, the hook re-renders with ready: true.

Scenario: namespace already loaded — no Suspense

  • GIVEN namespace auth preloaded
  • WHEN useTranslation('auth') is called
  • THEN the hook returns { ready: true, ... } synchronously and no fallback was rendered

Scenario: namespace not loaded — Suspense fallback rendered

  • GIVEN a backend registered and namespace auth not yet loaded
  • WHEN useTranslation('auth') is called inside a <Suspense fallback={<Loading />}>
  • THEN the <Loading /> fallback is rendered AND once the backend resolves, the hook re-renders with the translation

Scenario: namespace load failure surfaces ReactI18nError

  • GIVEN a backend whose read rejects with BACKEND_READ_FAILED
  • WHEN the Suspense promise rejects
  • THEN the nearest error boundary receives ReactI18nError(REACT_I18N.NAMESPACE_LOAD_FAILED) with error.cause being the original I18nError

Verified by: packages/react/react-i18n/src/hooks/use-translation/use-translation.test.tsx::"already loaded — no Suspense" AND packages/react/react-i18n/src/hooks/use-translation/use-translation.test.tsx::"not loaded — Suspense fallback" AND packages/react/react-i18n/src/hooks/use-translation/use-translation.test.tsx::"load failure surfaces NAMESPACE_LOAD_FAILED"


Requirement: useTranslation dev-mode loud failure

When called outside a TranslationProvider and process.env.NODE_ENV !== 'production', useTranslation SHALL throw ReactI18nError(REACT_I18N.NO_PROVIDER). In production, it SHALL log a warning once per process via @suaveplan/logger (or console.warn when the logger peer is absent) and return a no-op identity translator: t(key) === key.

Scenario: dev-mode throw

  • WHEN useTranslation() is called inside a render with no Provider ancestor and process.env.NODE_ENV === 'development'
  • THEN the render throws ReactI18nError(REACT_I18N.NO_PROVIDER)

Scenario: production identity translator

  • WHEN useTranslation() is called with no Provider ancestor and process.env.NODE_ENV === 'production'
  • THEN the hook returns { t, ready: false } where t('foo') equals 'foo' AND a single warn log entry was written

Verified by: packages/react/react-i18n/src/hooks/use-translation/use-translation.spec-conformance.test.tsx::"dev-mode throws NO_PROVIDER" AND packages/react/react-i18n/src/hooks/use-translation/use-translation.spec-conformance.test.tsx::"prod-mode soft-fails to identity"


Requirement: Trans component renders rich text via formatChunks

<Trans i18nKey="..." values={...} components={...} count={...}>...</Trans> SHALL render messages containing tag interpolation. The component SHALL look up i18nKey from the active catalog, parse it via MessageParser, then call formatChunks<ReactNode>(message, values, components) to produce a React-element array. The fallback for a missing key SHALL be the component's children (parsed as a default ICU message).

Scenario: tag substitution renders bold child

  • GIVEN catalog { welcome: 'Hello <b>{name}</b>!' } and the component <Trans i18nKey="welcome" values={{ name: 'Alice' }} components={{ b: <strong /> }} />
  • WHEN rendered
  • THEN the DOM contains Hello <strong>Alice</strong>!

Scenario: nested tags

  • GIVEN catalog { link: 'See <a><b>here</b></a>' } and <Trans i18nKey="link" components={{ a: <a href="/x" />, b: <strong /> }} />
  • WHEN rendered
  • THEN the DOM contains See <a href="/x"><strong>here</strong></a>

Scenario: count drives plural branch

  • GIVEN catalog { items: { one: '{count} item', other: '{count} items' } } and <Trans i18nKey="items" count={3} />
  • WHEN rendered
  • THEN the DOM contains '3 items'

Scenario: children as default message when key missing

  • GIVEN catalog without key 'absent'
  • WHEN <Trans i18nKey="absent">Hello <b>{{name}}</b>!</Trans> is rendered with values={{ name: 'World' }} and components={{ b: <strong /> }}
  • THEN the DOM contains Hello <strong>World</strong>!

Verified by: packages/react/react-i18n/src/components/trans/trans.test.tsx::"tag substitution" AND packages/react/react-i18n/src/components/trans/trans.test.tsx::"nested tags" AND packages/react/react-i18n/src/components/trans/trans.test.tsx::"count drives plural branch" AND packages/react/react-i18n/src/components/trans/trans.test.tsx::"children as default message"


Requirement: useLocale and useDirection re-render on languageChanged

useLocale() SHALL return { locale, setLocale } where setLocale(next) calls i18n.changeLanguage(next) (returns a Promise). The hook SHALL re-render its consumer when the bus emits languageChanged. useDirection() SHALL return { direction: 'ltr' | 'rtl', locale } derived from getTextDirection(locale). Both hooks SHALL use useSyncExternalStore so concurrent rendering reads are stable.

Scenario: setLocale triggers re-render with new locale

  • GIVEN a component using useLocale rendered under en
  • WHEN setLocale('fr') is called
  • THEN the component re-renders with locale === 'fr' after the language-change event resolves

Scenario: useDirection flips to rtl for Arabic

  • WHEN setLocale('ar') is called
  • THEN the component subscribed to useDirection re-renders with direction === 'rtl'

Verified by: packages/react/react-i18n/src/hooks/use-locale/use-locale.test.tsx::"setLocale triggers re-render" AND packages/react/react-i18n/src/hooks/use-direction/use-direction.test.tsx::"flips to rtl for Arabic"


Requirement: useErrorMessage maps SuaveplanError code to translation

useErrorMessage(error) SHALL accept a SuaveplanError | unknown. When the input is a SuaveplanError, the hook SHALL look up the key errors:<error.code> from the active catalog and return the translated string. When the key is absent, it SHALL return error.message. When the input is not a SuaveplanError or is undefined, it SHALL return ''.

Scenario: error code resolves to translated message

  • GIVEN catalog { errors: { 'AUTH.INVALID_TOKEN': 'Your session expired' } } and a SuaveplanError({ code: 'AUTH.INVALID_TOKEN' })
  • WHEN useErrorMessage(error) is called
  • THEN the result equals 'Your session expired'

Scenario: missing translation falls back to error.message

  • GIVEN catalog without key errors:CRYPTO.AEAD_TAG_INVALID and an error with that code and message: 'Invalid AEAD tag'
  • WHEN useErrorMessage(error) is called
  • THEN the result equals 'Invalid AEAD tag'

Scenario: non-SuaveplanError returns empty string

  • WHEN useErrorMessage(undefined) and useErrorMessage(new TypeError('x')) are called
  • THEN both return ''

Verified by: packages/react/react-i18n/src/hooks/use-error-message/use-error-message.test.tsx::"code resolves" AND packages/react/react-i18n/src/hooks/use-error-message/use-error-message.test.tsx::"falls back to error.message" AND packages/react/react-i18n/src/hooks/use-error-message/use-error-message.test.tsx::"non-SuaveplanError returns empty"


Requirement: Formatting hooks re-render on languageChanged

The package SHALL expose useDate(date, options?), useNumber(value, options?), useCurrency(amount, currency, options?), useRelativeTime(deltaMs, options?), and useList(values, options?) hooks. Each SHALL be a memoised wrapper around the corresponding universal-core formatter (formatDate, formatNumber, formatCurrency, formatRelativeTime, formatList). The memoisation key SHALL include (locale, ...inputs, options-hash). Each hook SHALL re-render when the locale changes.

Scenario: useNumber returns locale-formatted string

  • GIVEN locale en-US and useNumber(1234.5, { style: 'decimal', maximumFractionDigits: 2 })
  • WHEN rendered
  • THEN the result equals '1,234.5'

Scenario: useCurrency switches symbol on locale change

  • GIVEN locale en-US, useCurrency(42, 'GBP') initially returns '£42.00'
  • WHEN setLocale('fr-FR')
  • THEN the hook re-renders with the French formatting (e.g. '42,00 £GB' per Intl)

Verified by: packages/react/react-i18n/src/hooks/formatting/use-number.test.tsx::"locale-formatted string" AND packages/react/react-i18n/src/hooks/formatting/use-currency.test.tsx::"switches symbol on locale change"


Requirement: LanguageSwitcher headless render-prop

<LanguageSwitcher> SHALL accept a render prop children: (state: LanguageSwitcherState) => ReactNode where state = { locales: LocaleDescriptor[]; current: string; change: (locale: string) => Promise<void>; isPending: boolean }. The change callback SHALL be wrapped with useTransition so namespace loads on locale change do not block urgent updates. The component SHALL render no DOM markup itself.

Scenario: render prop receives full state

  • GIVEN <LanguageSwitcher locales={['en', 'fr', 'ar']}>{(state) => <pre>{JSON.stringify(state, null, 2)}</pre>}</LanguageSwitcher> mounted under en
  • WHEN rendered
  • THEN the <pre> contains current: 'en' AND locales: [{ code: 'en', ... }, ...] AND isPending: false

Scenario: change wraps in transition

  • GIVEN the same setup
  • WHEN state.change('fr') is called
  • THEN the next render observes isPending: true AND a subsequent render observes isPending: false and current: 'fr'

Verified by: packages/react/react-i18n/src/components/language-switcher/language-switcher.test.tsx::"render prop receives full state" AND packages/react/react-i18n/src/components/language-switcher/language-switcher.test.tsx::"change wraps in transition"


Requirement: MissingKeyOverlay subscribes to bus in dev only

<MissingKeyOverlay /> SHALL subscribe to the instance's missingKey event and render a fixed-position list of untranslated keys with aria-live="polite". The component SHALL render null when process.env.NODE_ENV === 'production'. The list SHALL be capped at the most-recent 50 entries; older entries SHALL drop off.

Scenario: missingKey event renders entry

  • GIVEN the overlay mounted in a dev build
  • WHEN t('absent.key') is called and emits missingKey
  • THEN the overlay's DOM contains the text 'absent.key'

Scenario: production renders nothing

  • GIVEN process.env.NODE_ENV === 'production'
  • WHEN the overlay mounts
  • THEN the rendered output is null (no DOM nodes)

Scenario: cap at 50 entries

  • GIVEN the overlay mounted in a dev build
  • WHEN 60 distinct missingKey events fire
  • THEN the rendered list has exactly 50 entries (the 50 most recent)

Verified by: packages/react/react-i18n/src/components/missing-key-overlay/missing-key-overlay.test.tsx::"event renders entry" AND packages/react/react-i18n/src/components/missing-key-overlay/missing-key-overlay.test.tsx::"production renders null" AND packages/react/react-i18n/src/components/missing-key-overlay/missing-key-overlay.test.tsx::"caps at 50 entries"


Requirement: Telemetry counters and histogram

The package SHALL emit OTel telemetry built via getMeter('suaveplan.react-i18n'):

  • Counter react_i18n.missing_key.total — labels: locale, namespace, componentName? (when available via the React Profiler API)
  • Counter react_i18n.suspense.fallback.total — labels: namespace, locale; incremented every time a Suspense fallback was rendered for a namespace load
  • Histogram react_i18n.render.duration_ms — emitted from <Trans> formatChunks render

Scenario: missing key in component increments counter

  • WHEN <Trans i18nKey="absent" /> renders inside a component named LoginForm
  • THEN react_i18n.missing_key.total records a sample with attributes { locale: 'en', namespace: '', componentName: 'LoginForm' }

Scenario: Suspense fallback increments counter

  • WHEN a backend-driven namespace load triggers a fallback render
  • THEN react_i18n.suspense.fallback.total records exactly one sample per fallback render with { namespace: 'auth', locale: 'en' }

Verified by: packages/react/react-i18n/src/components/trans/trans.spec-conformance.test.tsx::"missing key counter" AND packages/react/react-i18n/src/hooks/use-translation/use-translation.spec-conformance.test.tsx::"suspense fallback counter"


Requirement: ReactI18nError code registry

The package SHALL register four error codes via defineCodes("REACT_I18N", [...]) from @suaveplan/error:

  • REACT_I18N.NO_PROVIDER — hook used outside TranslationProvider in dev mode
  • REACT_I18N.NAMESPACE_LOAD_FAILED — Suspense-thrown promise rejected; error boundary received it
  • REACT_I18N.HYDRATION_MISMATCH — SSR initialMessages did not satisfy TranslationRecordSchema
  • REACT_I18N.TRANS_INVALID_CHILD — <Trans> received a child that cannot be matched to a tag pair

ReactI18nError SHALL extend SuaveplanError<ReactI18nCode> and SHALL be the only error type thrown by this package's React surface.

Scenario: codes registered at module load

  • WHEN the package is imported
  • THEN defineCodes("REACT_I18N", [...]) ran once AND REACT_I18N.NO_PROVIDER is a registered code

Verified by: packages/react/react-i18n/src/errors/react-i18n-error.test.ts::"codes registered at module load"


Requirement: Provider validates I18nConfig via Zod

createI18nConfig(options) SHALL validate options through I18nConfigSchema (Zod). Invalid options SHALL throw ReactI18nError(REACT_I18N.HYDRATION_MISMATCH) for initialMessages shape failures, or I18nError(I18N.INVALID_OPTIONS) for general option failures. The schema SHALL accept defaultLocale, fallbackLocale, supportedLocales, backend, backends, initialMessages, escapeHtml, nestingDepth, and plugins.

Scenario: invalid initialMessages shape rejected

  • WHEN createI18nConfig({ defaultLocale: 'en', initialMessages: { en: 'not-a-record' } as never }) is called
  • THEN it throws ReactI18nError(REACT_I18N.HYDRATION_MISMATCH) with context.zodIssues set

Verified by: packages/react/react-i18n/src/i18n/config/config.test.ts::"invalid initialMessages rejected"


Amendment 2026-05-05 — the requirements below were added by the cross-cutting design at openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md to lock in the locale-detection chain (previously deferred to a non-existent i18n-completion §3) and the branded TranslationKey typing contract that downstream packages (react-ui, react-forms, react-ui-theme-*, react-ui-pack-*) depend on.


Requirement: LocaleDetector contract

The package SHALL re-export LocaleDetector, DetectorContext, and createDetectorChain from @suaveplan/i18n/detector and SHALL ship eight built-in detectors plus a fallback detector covering the standard discovery surface. Each detector SHALL declare environment: 'browser' | 'server' | 'both'. Sync detectors SHALL implement detect(ctx): string | null; async detectors SHALL implement detectAsync(ctx): Promise<string | null>. Persisting detectors SHALL implement cache(locale, ctx): void | Promise<void>. Returning null SHALL signal "no preference detected — try the next detector". Returning a value SHALL trigger LocaleResolver.negotiate(value, supported) so an unsupported raw value can resolve to a closest supported variant via BCP-47 fallback.

Scenario: sync detector chain runs in order; first non-null wins

  • GIVEN detectors [querystringDetector(), cookieDetector(), navigatorDetector()] and cookieDetector is the only one returning a non-null value
  • WHEN createDetectorChain(detectors).detect(ctx) is called
  • THEN the result is the cookie detector's value AND navigatorDetector.detect was NOT invoked

Scenario: unsupported raw value triggers negotiation

  • GIVEN supportedLocales: ['en-GB', 'fr-FR'], a detector that returns 'en-US'
  • WHEN the chain runs
  • THEN the negotiated result is 'en-GB' (closest BCP-47 fallback)

Scenario: async detectors run in parallel after sync chain produces no match

  • GIVEN sync detectors all return null, and async detectors [userPreferenceDetector(), cloudflareIpGeoDetector()]
  • WHEN the chain runs
  • THEN both async detectors are invoked concurrently AND the first to resolve to a supported locale wins

Verified by: packages/react/react-i18n/src/detector/chain.test.ts::"sync chain order; first non-null wins" AND packages/react/react-i18n/src/detector/chain.test.ts::"unsupported raw value negotiates" AND packages/react/react-i18n/src/detector/chain.test.ts::"async detectors run in parallel"


Requirement: Built-in detectors shipped with v1

The package SHALL ship the following nine detectors as named exports, each independently importable from @suaveplan/react-i18n/detector/<name> for tree-shaking:

  1. querystringDetector(opts?: { paramName?: string }) — env 'both', sync, non-persisting; reads ?lang=... from ctx.request.url (server) or window.location.search (browser). Default paramName: 'lang'.
  2. userPreferenceDetector(opts: { endpoint: string; extractAuth?: (ctx) => HeadersInit; cacheKey?: (ctx) => string }) — env 'both', async, persisting (POSTs back on cache). Server-side requires extractAuth to thread the session header (typically the cookie header).
  3. cookieDetector(opts?: { name?: string; maxAgeSeconds?: number; sameSite?: 'lax' | 'strict' | 'none' }) — env 'both', sync, persisting. Default name: 'suaveplan_locale', maxAgeSeconds: 31536000, sameSite: 'lax'.
  4. localStorageDetector(opts?: { key?: string }) — env 'browser', sync, persisting. Default key: 'suaveplan-locale'. Returns null when localStorage is unavailable (private browsing, SSR).
  5. htmlLangDetector() — env 'browser', sync, non-persisting. Reads document.documentElement.lang.
  6. acceptLanguageDetector() — env 'server', sync, non-persisting. Parses Accept-Language header per RFC 9110 q-value rules.
  7. navigatorDetector() — env 'browser', sync, non-persisting. Iterates navigator.languages (or falls back to navigator.language); returns the first supported entry.
  8. cloudflareIpGeoDetector(opts?: { headerName?: string; countryToLocale?: Record<string, string> }) — env 'server', sync, non-persisting. Reads cf-ipcountry header (default headerName), maps the ISO 3166 country code to a locale via @suaveplan/locale-data (default mapping; consumer can override).
  9. fallbackDetector(locale: string) — env 'both', sync, non-persisting; always returns the configured fallback locale. SHALL appear last in any chain.
  • GIVEN chain [querystringDetector(), cookieDetector()] AND ?lang=cy-GB in the URL AND suaveplan_locale=ru-RU cookie
  • WHEN the chain runs
  • THEN the result is 'cy-GB'

Scenario: cloudflareIpGeoDetector maps country code

  • GIVEN request header cf-ipcountry: AE AND supportedLocales: ['en-GB', 'ar-AE']
  • WHEN cloudflareIpGeoDetector().detect(ctx) is called
  • THEN the result is 'ar-AE'

Scenario: cookieDetector persists choice via cache

  • GIVEN cookieDetector({ name: 'l', maxAgeSeconds: 100 }) and an empty Headers writer in ctx.responseHeaders
  • WHEN cookieDetector().cache('fr-FR', ctx) is called
  • THEN ctx.responseHeaders contains a Set-Cookie: l=fr-FR; Max-Age=100; SameSite=Lax; Path=/ entry

Scenario: userPreferenceDetector threads SSR auth via extractAuth

  • GIVEN userPreferenceDetector({ endpoint: '/api/me/locale', extractAuth: (c) => ({ cookie: c.request.headers.get('cookie')! }) }) AND a server-side ctx.request.headers containing cookie: session=abc
  • WHEN detectAsync(ctx) is called
  • THEN the underlying fetch call is made to /api/me/locale with header cookie: session=abc

Verified by: packages/react/react-i18n/src/detector/querystring.test.ts::"overrides cookie" AND packages/react/react-i18n/src/detector/cloudflare-ip-geo.test.ts::"maps country code" AND packages/react/react-i18n/src/detector/cookie.test.ts::"persists choice" AND packages/react/react-i18n/src/detector/user-preference.test.ts::"threads SSR auth" AND a sibling test per remaining detector exercising its happy path + null-return path


Requirement: Default chain order

The package SHALL export defaultDetectorChain(opts: { fallback: string; userPreferenceEndpoint?: string }): LocaleDetector[] returning the canonical chain in order:

  1. querystringDetector()
  2. userPreferenceDetector(...) — only when opts.userPreferenceEndpoint is provided
  3. cookieDetector()
  4. localStorageDetector()
  5. htmlLangDetector()
  6. acceptLanguageDetector()
  7. navigatorDetector()
  8. cloudflareIpGeoDetector()
  9. fallbackDetector(opts.fallback)

Querystring stays on top because explicit current intent beats stored preference. Consumers MAY assemble custom chains by importing detectors individually.

Scenario: defaultDetectorChain order matches spec

  • WHEN defaultDetectorChain({ fallback: 'en-GB', userPreferenceEndpoint: '/api/me/locale' }) is called
  • THEN the returned array's .map(d => d.name) equals ['querystring', 'userPreference', 'cookie', 'localStorage', 'htmlLang', 'acceptLanguage', 'navigator', 'cloudflareIpGeo', 'fallback']

Scenario: omitting userPreferenceEndpoint omits that detector

  • WHEN defaultDetectorChain({ fallback: 'en-GB' }) is called (no endpoint)
  • THEN the returned array does NOT include userPreferenceDetector AND its .map(d => d.name) length equals 8

Verified by: packages/react/react-i18n/src/detector/default-chain.test.ts::"order matches spec" AND packages/react/react-i18n/src/detector/default-chain.test.ts::"omits userPreference when endpoint absent"


Requirement: useLocaleDetection hook

The package SHALL export useLocaleDetection(): { detected: string | null; source: string | null; isPending: boolean; redetect: () => Promise<void> }. The hook SHALL run the configured detector chain on mount, set isPending: true until a detector resolves, then update detected and source (the winning detector's name). redetect() SHALL re-run the chain and update state. Persisting detectors SHALL be called via i18n.setLocale(newLocale) so a switcher selection sticks across cookies, localStorage, and the user-preference endpoint simultaneously.

Scenario: detection completes and exposes winning source

  • GIVEN a chain where cookieDetector returns 'fr-FR'
  • WHEN the hook mounts
  • THEN the next render observes { detected: 'fr-FR', source: 'cookie', isPending: false }

Scenario: setLocale walks all persisting detectors

  • GIVEN the chain [querystring, cookie, localStorage, userPreference, fallback]
  • WHEN i18n.setLocale('zh-CN') is called
  • THEN cookieDetector.cache('zh-CN', ctx) AND localStorageDetector.cache('zh-CN', ctx) AND userPreferenceDetector.cache('zh-CN', ctx) are each invoked exactly once; querystring and fallback (non-persisting) are NOT invoked

Verified by: packages/react/react-i18n/src/hooks/use-locale-detection/use-locale-detection.test.tsx::"completes and exposes source" AND packages/react/react-i18n/src/hooks/use-locale-detection/use-locale-detection.test.tsx::"setLocale walks persisting detectors"


Requirement: SSR detection contract

When the chain runs on the server during a request, the resolved locale SHALL be passed to the React tree via <TranslationProvider initialLocale={...} initialMessages={...}>. Browser-only detectors SHALL NOT run on the second pass during hydration; the server's choice is authoritative for the initial render to avoid hydration mismatch. After hydration, browser-only detectors MAY run on subsequent redetect() calls or when triggered by user action (e.g. language switcher), at which point persisting detectors are called via cache().

Scenario: browser detectors skipped during hydration

  • GIVEN SSR resolved locale: 'ar-AE' via cloudflareIpGeoDetector AND server passed <TranslationProvider initialLocale="ar-AE">
  • WHEN the client hydrates
  • THEN localStorageDetector.detect AND navigatorDetector.detect AND htmlLangDetector.detect are NOT invoked during the initial render AND the rendered text matches the server output (no hydration warning)

Scenario: redetect after user action runs full chain

  • GIVEN the same hydrated tree
  • WHEN useLocaleDetection().redetect() is called from a button click
  • THEN every detector with environment !== 'server' is invoked

Verified by: packages/react/react-i18n/src/detector/ssr-contract.test.tsx::"browser detectors skipped during hydration" AND packages/react/react-i18n/src/detector/ssr-contract.test.tsx::"redetect runs full chain"


Requirement: Detector telemetry

The package SHALL emit OTel telemetry for the detector chain via getMeter('suaveplan.react-i18n'):

  • Counter i18n.detector.match_total{detector} — incremented when a detector returns a non-null supported locale
  • Histogram i18n.detector.duration_ms{detector} — recorded for each detectAsync call
  • Span i18n.detect wrapping the entire chain run; span attributes include the winning detector name and the resolved locale

Scenario: counter records winning detector

  • GIVEN a chain where cookieDetector wins
  • WHEN the chain runs
  • THEN i18n.detector.match_total records exactly one sample with attribute { detector: 'cookie' }

Scenario: span captures resolved locale

  • WHEN the chain runs and resolves to 'zh-CN'
  • THEN the active i18n.detect span has attributes { resolved_locale: 'zh-CN', winning_detector: '<name>' }

Verified by: packages/react/react-i18n/src/detector/telemetry.test.ts::"counter records winning detector" AND packages/react/react-i18n/src/detector/telemetry.test.ts::"span captures resolved locale"


Requirement: Branded TranslationKey typing contract

The package SHALL export TranslationKey, AppTranslationKeys, and ContributedTranslationKeys. TranslationKey SHALL be a branded string type whose set of legal values is the union of keyof AppTranslationKeys and keyof ContributedTranslationKeys. Both interfaces SHALL be empty by default; downstream packages and consumer apps SHALL extend them via TypeScript module augmentation. The package SHALL NOT import from any leaf package (@suaveplan/react-ui, @suaveplan/react-forms, @suaveplan/react-ui-theme-*, @suaveplan/react-ui-pack-*) and SHALL declare none of them in package.json dependencies.

// Exported from @suaveplan/react-i18n:
export interface AppTranslationKeys {}
export interface ContributedTranslationKeys {}
export type TranslationKey =
  & string
  & { readonly __brand: 'TranslationKey' }
  & (keyof AppTranslationKeys | keyof ContributedTranslationKeys);

Scenario: empty interfaces by default — no key typechecks until augmented

  • GIVEN a fresh project with no augmentations
  • WHEN the consumer writes const k: TranslationKey = 'anything'
  • THEN the TypeScript compiler reports an error (the union of the two empty interfaces is never, so no string literal is assignable)

Scenario: downstream package augmentation contributes keys

  • GIVEN @suaveplan/react-ui ships declare module '@suaveplan/react-i18n' { interface ContributedTranslationKeys { 'react-ui.dialog.close': true } }
  • WHEN the consumer imports @suaveplan/react-ui (which side-effect-imports the augmentation file)
  • THEN const k: TranslationKey = 'react-ui.dialog.close' typechecks AND const k: TranslationKey = 'typo' does not

Scenario: consumer app augmentation contributes keys

  • GIVEN the consumer's own declare module '@suaveplan/react-i18n' { interface AppTranslationKeys { 'app.welcome': true } }
  • WHEN the consumer writes const k: TranslationKey = 'app.welcome'
  • THEN it typechecks

Scenario: react-i18n package.json has no leaf-package deps

  • WHEN the CI gate bun scripts/lint/check-no-backwards-deps.ts reads packages/react/react-i18n/package.json
  • THEN none of @suaveplan/react-ui, @suaveplan/react-forms, @suaveplan/react-ui-theme-*, @suaveplan/react-ui-pack-* appear in dependencies, peerDependencies, or devDependencies

Verified by: packages/react/react-i18n/src/translation-key/translation-key.test.ts::"empty interfaces — no key typechecks" AND packages/react/react-i18n/src/translation-key/translation-key.test.ts::"contributed augmentation" AND packages/react/react-i18n/src/translation-key/translation-key.test.ts::"app augmentation" AND scripts/lint/check-no-backwards-deps.test.ts::"react-i18n has no leaf deps"


Requirement: tDynamic runtime escape hatch

The package SHALL export tDynamic(key: string, values?: TranslationValues): string for the rare case where a translation key is computed at runtime (e.g. mapping an error code to its message). The function SHALL look up key in the active catalog. If the key is registered, it SHALL return the translated string. If the key is missing, it SHALL throw ReactI18nError(REACT_I18N.UNKNOWN_KEY) with context: { key, locale }. Each call SHALL emit a single warn-level log entry via @suaveplan/logger (or console.warn when the logger peer is absent) so observability captures every tDynamic use site.

Scenario: known key returns translation

  • GIVEN catalog { 'errors.AUTH.INVALID_TOKEN': 'Your session expired' }
  • WHEN tDynamic('errors.AUTH.INVALID_TOKEN') is called
  • THEN the result equals 'Your session expired' AND a single warn log entry was written

Scenario: unknown key throws UNKNOWN_KEY

  • GIVEN catalog without the requested key
  • WHEN tDynamic('errors.MISSING.CODE') is called
  • THEN it throws ReactI18nError(REACT_I18N.UNKNOWN_KEY) with context.key === 'errors.MISSING.CODE'

Verified by: packages/react/react-i18n/src/translation-key/t-dynamic.test.ts::"known key returns translation" AND packages/react/react-i18n/src/translation-key/t-dynamic.test.ts::"unknown key throws"


Requirement: REACT_I18N.UNKNOWN_KEY error code

The package's defineCodes("REACT_I18N", [...]) call SHALL include a fifth code REACT_I18N.UNKNOWN_KEY in addition to the four already registered (NO_PROVIDER, NAMESPACE_LOAD_FAILED, HYDRATION_MISMATCH, TRANS_INVALID_CHILD). The new code SHALL be thrown by tDynamic() when the requested key is absent from the active catalog.

Scenario: code registered at module load

  • WHEN the package is imported
  • THEN REACT_I18N.UNKNOWN_KEY is a registered code

Verified by: packages/react/react-i18n/src/errors/react-i18n-error.test.ts::"UNKNOWN_KEY registered"