517 lines
33 KiB
Markdown
517 lines
33 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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.
|
|
|
|
#### Scenario: querystring overrides cookie
|
|
|
|
- **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.
|
|
|
|
```ts
|
|
// 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"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|