20 KiB
Tasks: enterprise-react-i18n
Change ID: enterprise-react-i18n
Package: @suaveplan/react-i18n
Target path: packages/react/react-i18n
Prerequisite changes: i18n-storage-backends, i18n-completion (must be archived before this change ships)
Prerequisite packages on Verdaccio: @suaveplan/i18n (with the two changes above folded in), @suaveplan/error, @suaveplan/events, @suaveplan/logger, @suaveplan/locale-data, @suaveplan/types
0. Supersession
- 0.1 Move
openspec/changes/enterprise-react-translate/→openspec/archive/<yyyy-mm-dd>-enterprise-react-translate-superseded/with a top-level note explaining the supersession (legacy was i18next-based; this change supersedes by building on the universal core) - 0.2 Update
STATUS.mdandSUMMARY.mdso@suaveplan/react-translateis replaced by@suaveplan/react-i18nin the pending list - 0.3 Document the consumer-side rename
@suaveplan/react-translate→@suaveplan/react-i18nin the React migration section ofdocs/
1. Spec authoring
- 1.1 Read this folder's
proposal.mdandspecs/react-i18n/spec.mdend-to-end - 1.2 Read
openspec/specs/i18n/spec.md(folded with the two prerequisite changes) — every API used here should be listed there - 1.3 Read
openspec/AGENTS.mdSections 13 and 14 - 1.4
openspec change validate enterprise-react-i18nexits with no errors
2. Scaffold
- 2.1 Create
packages/react/react-i18n/withpackage.jsonat0.1.0,tsconfig.lib.json,vitest.config.ts,vite.config.ts, scripts. React-library tier (extends ../../tsconfig/react-library.json). - 2.2 Dependencies:
@suaveplan/i18n workspace:^,@suaveplan/error workspace:^,@suaveplan/events workspace:^,@suaveplan/logger workspace:^,@suaveplan/locale-data workspace:^,@suaveplan/types workspace:^,@opentelemetry/api catalog:observability,tslib catalog:build,zod catalog:forms - 2.3 Peer dependencies:
react ^18.0.0 || ^19.0.0 - 2.4 Dev dependencies:
@suaveplan/testing workspace:^,@suaveplan/testing-dom workspace:^,@testing-library/react,@types/react,@types/react-dom,react,react-dom,vitest,vite,vite-plugin-dts,fast-check,@vitest/coverage-istanbul - 2.5 NO
i18next,react-i18next,i18next-browser-languagedetector,i18next-http-backend— verified by grep before commit - 2.6
"sideEffects": false - 2.7 Subpath exports:
.,./provider,./hooks,./components,./components/trans,./components/language-switcher,./components/missing-key-overlay,./components/i18n-suspense,./hoc,./errors,./schemas
3. Errors and types
- 3.1 Define
ReactI18nErrorand four codes viadefineCodes("REACT_I18N", [...]):NO_PROVIDER,NAMESPACE_LOAD_FAILED,HYDRATION_MISMATCH,TRANS_INVALID_CHILD - 3.2 Define types:
I18nConfig,LocaleDescriptor,LanguageSwitcherState,TranslationProviderProps,UseTranslationReturn,UseTranslateReturn,UseLocaleReturn,UseDirectionReturn,UseErrorMessageReturn,UseI18nReturn, formatting-hook return types - 3.3
react-i18n-error.test.ts— code registry, error chain, JSON wire format - 3.4 Sibling
.mdfor the error class
4. I18nConfig + provider
4.1 Config
- 4.1.1
createI18nConfig(options)builds anI18nConfigvalidated throughI18nConfigSchema(Zod) - 4.1.2 Schema fields:
defaultLocale,fallbackLocale,supportedLocales,backend,backends,initialMessages,escapeHtml,nestingDepth,plugins,saveMissing - 4.1.3 Throws
ReactI18nError(HYDRATION_MISMATCH)on invalidinitialMessages;I18nError(INVALID_OPTIONS)on other shape failures - 4.1.4
config.test.ts— happy path, every Zod rejection path - 4.1.5 Sibling
.md
4.2 Provider
- 4.2.1
TranslationProvideracceptsi18n(caller-managed) ORconfig(Provider-managed); rejects both / neither at runtime via Zod refinement - 4.2.2 Internal subscription via
useSyncExternalStoretoi18n.events.on('languageChanged' | 'loaded') - 4.2.3 SSR-safe:
useSyncExternalStoreserver snapshot returns the synchronous catalog state - 4.2.4 Disposes the instance on unmount only when Provider-managed
- 4.2.5 OTel span on mount with attributes
{ locale, supportedLocales } - 4.2.6
provider.test.tsx— caller-managed-not-disposed, provider-managed-disposed, SSR initial-messages hydration, both-i18n-and-config-rejected - 4.2.7 Sibling
.md
5. Hooks — translation
5.1 useI18n / useTranslation / useTranslate
- 5.1.1
useI18n()returns the activeI18nInstance; throwsReactI18nError(NO_PROVIDER)in dev when no Provider - 5.1.2
useTranslation(namespace?)triggersi18n.preload(namespace)on first mount when a backend is registered; throws the in-flight Promise (Suspense) - 5.1.3 Returns
{ t, i18n, ready }wheretis the ergonomic-options-bag form - 5.1.4
useTranslate(namespace?)isuseTranslation(namespace).t(terse alias) - 5.1.5 Production no-provider path: log once + identity translator
- 5.1.6
use-translation.test.tsx— preload Suspense + already-loaded sync path + namespace-load failure - 5.1.7
use-translation.spec-conformance.test.tsx— dev throw, prod soft-fail (assertsprocess.env.NODE_ENVswitch with vitest stubGlobal) - 5.1.8 Sibling
.mddocumenting Suspense semantics + the dev/prod no-provider distinction
6. Hooks — locale + direction
- 6.1
useLocale()returns{ locale, setLocale };setLocalecallsi18n.changeLanguage - 6.2
useDirection()returns{ direction, locale }derived fromgetTextDirection - 6.3 Both via
useSyncExternalStoresubscribing tolanguageChanged - 6.4
use-locale.test.tsx,use-direction.test.tsx— re-render on locale change, ar → rtl - 6.5 Sibling
.mdfor each
7. Hooks — formatting
- 7.1
useDate(date, options?)— memoisedformatDate(date, locale, options) - 7.2
useNumber(value, options?)— memoisedformatNumber(value, locale, options) - 7.3
useCurrency(amount, currency, options?)— memoisedformatCurrency(amount, currency, locale, options) - 7.4
useRelativeTime(deltaMs, options?)— memoisedformatRelativeTime(deltaMs, locale, options) - 7.5
useList(values, options?)— memoisedformatList(values, locale, options) - 7.6 Memoisation key:
(locale, ...inputs, JSON.stringify(options))— re-render on locale change verified - 7.7
use-date.test.tsx,use-number.test.tsx,use-currency.test.tsx,use-relative-time.test.tsx,use-list.test.tsx - 7.8 Sibling
.mdfor each
8. Hooks — specialised
- 8.1
useErrorMessage(error)—errors:<error.code>lookup witherror.messagefallback - 8.2
usePlural(count, locale?)— returns{ category, rule }fromPluralizationEngine - 8.3
use-error-message.test.tsx,use-plural.test.tsx - 8.4 Sibling
.mdfor each
9. Components — Trans
- 9.1
<Trans i18nKey values components count children?>parses message viaMessageParserand callsformatChunks<ReactNode>(message, values, components) - 9.2 Fallback when key missing: parse the component's
childrenas the default ICU message - 9.3
countprop drives plural branch viaPluralizationEngine.resolve - 9.4 Throws
ReactI18nError(TRANS_INVALID_CHILD)when a child cannot be matched to a tag pair - 9.5 Emits
react_i18n.render.duration_mshistogram - 9.6
trans.test.tsx— tag substitution, nested tags, count, default message, missing-key fallback - 9.7
trans.spec-conformance.test.tsx— missing-key counter, render duration histogram - 9.8 Sibling
.mdwith five rich-text examples (bold, link, nested, plural with tag, fallback) - 9.9
trans.stories.tsx— Storybook with all five examples
10. Components — LanguageSwitcher
- 10.1 Headless render-prop API:
children: (state) => ReactNode - 10.2
state.change(locale)wraps inuseTransition - 10.3 Locales sourced from
i18n.supportedLocales(or override prop) with metadata from@suaveplan/locale-data - 10.4
language-switcher.test.tsx— render prop receives full state, change wraps in transition, isPending toggles - 10.5 Sibling
.md - 10.6
language-switcher.stories.tsx— Storybook with native<select>, custom button list, command-palette-style picker
11. Components — MissingKeyOverlay
- 11.1 Subscribes to
missingKeyevent; renders fixed-position list witharia-live="polite" - 11.2 Tree-shaken to
nullin production viaprocess.env.NODE_ENVcheck - 11.3 50-entry cap with FIFO eviction
- 11.4
missing-key-overlay.test.tsx— event renders entry, production null, 50-cap - 11.5 Sibling
.md - 11.6
missing-key-overlay.stories.tsx
12. Components — I18nSuspense
- 12.1 Wraps
React.Suspensewith defaultaria-livefallback region - 12.2 Increments
react_i18n.suspense.fallback.totalon every fallback render - 12.3
i18n-suspense.test.tsx— fallback rendered, counter incremented - 12.4 Sibling
.md
13. HOC — withTranslation
- 13.1
withTranslation<P>(Component)injectstandi18nprops - 13.2 Hoists static methods via
hoist-non-react-statics-equivalent (write a 30-line internal helper to avoid the dep) - 13.3
with-translation.test.tsx— class-component happy path, displayName correct - 13.4 Sibling
.md
14. Wiring and exports
- 14.1
src/index.ts— explicit named exports for every public symbol; NOexport * - 14.2 Subpath exports declared in
package.jsonfor every public module - 14.3
README.mdat the package root with: install, quickstart (5 lines), every hook with one example, every component with one example, link to each co-located.md - 14.4
tests/dep-graph.test.ts— verifies noi18next*, noreact-i18nextdeps - 14.5 Add to
MANIFEST.md
15. Storybook
- 15.1
provider.stories.tsx— language-switcher playground swapping en/de/ar/fr - 15.2
rtl.stories.tsx— Arabic with mirrored layout viauseDirection - 15.3
plurals.stories.tsx—<Trans count={n}>demo - 15.4
formatting.stories.tsx— every formatting hook in one panel - 15.5
suspense.stories.tsx— namespace lazy-load with Suspense fallback - 15.6
missing-key.stories.tsx— overlay demo
16. E2E (Playwright)
- 16.1
e2e/react-i18n.spec.ts:- Switch language en → de → ar; assert UI strings update
- Switch to ar; assert
<html dir="rtl">(or wrapper) applied - Trigger lazy-namespace load; assert Suspense fallback renders then resolves
- Visit page with intentional missing key; assert
<MissingKeyOverlay>lists it (dev build only) - SSR mode: render server-side, hydrate; assert no Suspense fallback flashed during hydration
17. OTel instrumentation
- 17.1
react_i18n.missing_key.total{locale,namespace,componentName}— emit from<Trans> - 17.2
react_i18n.suspense.fallback.total{namespace,locale}— emit from<I18nSuspense> - 17.3
react_i18n.render.duration_ms— emit from<Trans>formatChunks render - 17.4 Tested with in-memory
MeterProviderin*.spec-conformance.test.tsx
18. Pre-archive verification (the 5-gate triple)
- 18.1
bun run check— Biome 0/0/0 across the monorepo - 18.2
bunx turbo lint typecheck test --filter=@suaveplan/react-i18n— green - 18.3
bunx turbo test:coverage --filter=@suaveplan/react-i18n— 100/100/100/100 (useprovider: 'istanbul') - 18.4
bun scripts/openspec-conformance.ts @suaveplan/react-i18n— exit 0 - 18.5
bunx turbo build --filter=@suaveplan/react-i18n— green - 18.6 Verify only one React version installed:
find . -path "*/node_modules/react/package.json" | head -5
19. Pre-archive 7-row checklist (AGENTS.md Section 13)
- 19.1 Generics threaded;
expectTypeOfassertions onuseTranslationreturn type,<Trans>valuesshape - 19.2 Zod schemas at every public boundary (
I18nConfigSchema,TranslationProviderPropsSchema) - 19.3 All four error codes thrown from at least one production path
- 19.4 Strict-mode tested: dev no-provider throw, prod soft-fail, hydration mismatch, Trans invalid child
- 19.5 OTel histograms / counters emitted from production paths and asserted with a real in-memory
MeterProvider - 19.6 Lifecycle dedup: caller-managed instance not disposed, Provider-managed disposed exactly once
- 19.7 No retroactive tick — every box reflects work done in this change
20. Publish + archive
- 20.1
bun scripts/publish.ts --package @suaveplan/react-i18n - 20.2 Fold spec into
openspec/specs/react-i18n/spec.md(new capability folder) - 20.3 Move
openspec/changes/enterprise-react-i18n/→openspec/archive/<yyyy-mm-dd>-enterprise-react-i18n/ - 20.4 Refresh
MANIFEST.md - 20.5 Refresh
SUMMARY.mdandSTATUS.mdto mark@suaveplan/react-i18nshipped
Tasks added by Amendment 2026-05-05
These tasks are introduced by the cross-cutting design at openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md (see proposal ## Amendment 2026-05-05 for context). They land in the same change set; sequenced after §3-§10 module work but before the §15 conformance suite so the conformance tests exercise them.
21. Locale-detection chain — universal core re-export
- 21.1 Verify
@suaveplan/i18n/detectorexportsLocaleDetector,DetectorContext,createDetectorChain. If absent (deferred from a since-archivedi18n-completion), add them to@suaveplan/i18nfirst via a separate PR landing before this one, with their own test coverage. - 21.2 Re-export
LocaleDetector,DetectorContext,createDetectorChainfrom@suaveplan/react-i18n/detector(no behaviour added; React glue lives separately)
22. Built-in detectors
Each detector lives in its own folder under src/detector/ per CLAUDE.md 5-files-per-feature layout.
- 22.1
querystringDetector— env'both', sync; reads?lang=...fromURL.searchParams. ConfigurableparamName. Tests cover server (URL injected viactx.request.url) and browser (window.location.search). - 22.2
userPreferenceDetector— env'both', async, persisting; callsfetch(opts.endpoint)withextractAuth(ctx)headers. POSTs back oncache(locale, ctx). Tests cover SSR auth threading, browser path, network failure (returnsnull), persisting flow. - 22.3
cookieDetector— env'both', sync, persisting; readsCookieheader (server) ordocument.cookie(browser). WritesSet-Cookie(server viactx.responseHeaders) ordocument.cookie(browser) on cache. Configurable name, maxAge, sameSite. - 22.4
localStorageDetector— env'browser', sync, persisting; reads/writeslocalStorage[opts.key]. ReturnsnulliflocalStorageunavailable. - 22.5
htmlLangDetector— env'browser', sync; readsdocument.documentElement.lang. - 22.6
acceptLanguageDetector— env'server', sync; parsesAccept-Languageper RFC 9110 q-values; returns highest-priority supported entry. - 22.7
navigatorDetector— env'browser', sync; iteratesnavigator.languages || [navigator.language]; returns first supported. - 22.8
cloudflareIpGeoDetector— env'server', sync; readscf-ipcountryheader; maps ISO 3166 country code to locale via@suaveplan/locale-data. Configurable header name and country→locale override map. - 22.9
fallbackDetector(locale)— env'both', sync; always returns the configured fallback. Always last in chain. - 22.10
defaultDetectorChain(opts)factory — returns the canonical 8- or 9-detector array depending on whetheropts.userPreferenceEndpointis set - 22.11 Per-detector co-located
.mdfiles satisfying §4 ofopenspec/AGENTS.md - 22.12 Detector tests at 100% statement / branch / function / line coverage
- 22.13 Property test (fast-check): for any sequence of detector outputs, the chain selects the first non-null supported value
23. React glue — useLocaleDetection
- 23.1
useLocaleDetection()hook insrc/hooks/use-locale-detection/ - 23.2
<TranslationProvider>runs the chain on mount and exposes the result viauseSyncExternalStore - 23.3
setLocale(newLocale)walks every persisting detector and callscache(newLocale, ctx) - 23.4
redetect()re-runs the chain - 23.5 SSR contract:
<TranslationProvider initialLocale=...>skips browser detectors during hydration; tests assert no hydration mismatch warning under React strict mode
24. Detector telemetry
- 24.1 Counter
i18n.detector.match_total{detector}emitted viagetMeter('suaveplan.react-i18n') - 24.2 Histogram
i18n.detector.duration_ms{detector}fordetectAsynccalls - 24.3 Span
i18n.detectwrapping the chain run, attributes{ resolved_locale, winning_detector } - 24.4 Tests use a real in-memory
MeterProviderandTracerProvider
25. Branded TranslationKey typing contract
- 25.1
src/translation-key/translation-key.types.tsexportsAppTranslationKeys,ContributedTranslationKeys,TranslationKey - 25.2
TranslationKeyisstring & { readonly __brand: 'TranslationKey' } & (keyof AppTranslationKeys | keyof ContributedTranslationKeys) - 25.3
expectTypeOfassertions: empty interfaces →TranslationKey extends never; after augmentation → branded literal union typechecks - 25.4 Co-located
.mddocumenting the augmentation pattern with two worked examples (consumer-app augmentation; downstream-package augmentation) - 25.5
scripts/generate-translation-types.tsreads a package'slocales/en-GB.jsonand emits<package>/src/locales/translation-keys.ts(the union) plus<package>/src/locales/augmentation.d.ts(augmentsContributedTranslationKeys). Same script ships for use by every downstream package. - 25.6
react-i18nitself uses the script to publish its own internal-error keys (REACT_I18N.NO_PROVIDERetc. resolved viauseErrorMessage)
26. CI gate — no-backwards-deps
- 26.1
scripts/lint/check-no-backwards-deps.ts— Bun script using TypeScript compiler API - 26.2 Asserts
@suaveplan/react-i18n/package.jsondeclares no@suaveplan/react-ui*/@suaveplan/react-forms*deps - 26.3 Wired into
bunx turbo lintfor the@suaveplan/react-i18npackage - 26.4 Sibling co-located test (
scripts/lint/check-no-backwards-deps.test.ts) covers passing case and three failing cases (one per banned dep type)
27. tDynamic runtime escape hatch
- 27.1
src/translation-key/t-dynamic.ts—tDynamic(key, values?)looks up active catalog - 27.2 Missing key throws
ReactI18nError(REACT_I18N.UNKNOWN_KEY)withcontext: { key, locale } - 27.3 Each call emits one
warnlog via@suaveplan/logger(orconsole.warnfallback) - 27.4 Co-located
.mddocumenting the rare-use intent and the warn-log observability surface - 27.5 Add
REACT_I18N.UNKNOWN_KEYto thedefineCodes("REACT_I18N", [...])registry; existing four codes unchanged
28. Conformance — amendment additions
The existing §15 conformance suite gains four new spec-conformance tests:
- 28.1
detector/chain.spec-conformance.test.ts— exercises every default-chain order rule - 28.2
detector/ssr-contract.spec-conformance.test.tsx— server resolves; browser detectors skipped during hydration;redetect()runs full chain - 28.3
translation-key/branded-type.spec-conformance.test.ts—expectTypeOfassertions for empty and augmented states - 28.4
translation-key/t-dynamic.spec-conformance.test.ts— known key returns; unknown key throwsUNKNOWN_KEY; warn log fires once per call