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

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.md and SUMMARY.md so @suaveplan/react-translate is replaced by @suaveplan/react-i18n in the pending list
  • 0.3 Document the consumer-side rename @suaveplan/react-translate → @suaveplan/react-i18n in the React migration section of docs/

1. Spec authoring

  • 1.1 Read this folder's proposal.md and specs/react-i18n/spec.md end-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.md Sections 13 and 14
  • 1.4 openspec change validate enterprise-react-i18n exits with no errors

2. Scaffold

  • 2.1 Create packages/react/react-i18n/ with package.json at 0.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 ReactI18nError and four codes via defineCodes("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 .md for the error class

4. I18nConfig + provider

4.1 Config

  • 4.1.1 createI18nConfig(options) builds an I18nConfig validated through I18nConfigSchema (Zod)
  • 4.1.2 Schema fields: defaultLocale, fallbackLocale, supportedLocales, backend, backends, initialMessages, escapeHtml, nestingDepth, plugins, saveMissing
  • 4.1.3 Throws ReactI18nError(HYDRATION_MISMATCH) on invalid initialMessages; 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 TranslationProvider accepts i18n (caller-managed) OR config (Provider-managed); rejects both / neither at runtime via Zod refinement
  • 4.2.2 Internal subscription via useSyncExternalStore to i18n.events.on('languageChanged' | 'loaded')
  • 4.2.3 SSR-safe: useSyncExternalStore server 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 active I18nInstance; throws ReactI18nError(NO_PROVIDER) in dev when no Provider
  • 5.1.2 useTranslation(namespace?) triggers i18n.preload(namespace) on first mount when a backend is registered; throws the in-flight Promise (Suspense)
  • 5.1.3 Returns { t, i18n, ready } where t is the ergonomic-options-bag form
  • 5.1.4 useTranslate(namespace?) is useTranslation(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 (asserts process.env.NODE_ENV switch with vitest stubGlobal)
  • 5.1.8 Sibling .md documenting Suspense semantics + the dev/prod no-provider distinction

6. Hooks — locale + direction

  • 6.1 useLocale() returns { locale, setLocale }; setLocale calls i18n.changeLanguage
  • 6.2 useDirection() returns { direction, locale } derived from getTextDirection
  • 6.3 Both via useSyncExternalStore subscribing to languageChanged
  • 6.4 use-locale.test.tsx, use-direction.test.tsx — re-render on locale change, ar → rtl
  • 6.5 Sibling .md for each

7. Hooks — formatting

  • 7.1 useDate(date, options?) — memoised formatDate(date, locale, options)
  • 7.2 useNumber(value, options?) — memoised formatNumber(value, locale, options)
  • 7.3 useCurrency(amount, currency, options?) — memoised formatCurrency(amount, currency, locale, options)
  • 7.4 useRelativeTime(deltaMs, options?) — memoised formatRelativeTime(deltaMs, locale, options)
  • 7.5 useList(values, options?) — memoised formatList(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 .md for each

8. Hooks — specialised

  • 8.1 useErrorMessage(error) — errors:<error.code> lookup with error.message fallback
  • 8.2 usePlural(count, locale?) — returns { category, rule } from PluralizationEngine
  • 8.3 use-error-message.test.tsx, use-plural.test.tsx
  • 8.4 Sibling .md for each

9. Components — Trans

  • 9.1 <Trans i18nKey values components count children?> parses message via MessageParser and calls formatChunks<ReactNode>(message, values, components)
  • 9.2 Fallback when key missing: parse the component's children as the default ICU message
  • 9.3 count prop drives plural branch via PluralizationEngine.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_ms histogram
  • 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 .md with 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 in useTransition
  • 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 missingKey event; renders fixed-position list with aria-live="polite"
  • 11.2 Tree-shaken to null in production via process.env.NODE_ENV check
  • 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.Suspense with default aria-live fallback region
  • 12.2 Increments react_i18n.suspense.fallback.total on 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) injects t and i18n props
  • 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; NO export *
  • 14.2 Subpath exports declared in package.json for every public module
  • 14.3 README.md at 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 no i18next*, no react-i18next deps
  • 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 via useDirection
  • 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 MeterProvider in *.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 (use provider: '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; expectTypeOf assertions on useTranslation return type, <Trans> values shape
  • 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.md and STATUS.md to mark @suaveplan/react-i18n shipped

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/detector exports LocaleDetector, DetectorContext, createDetectorChain. If absent (deferred from a since-archived i18n-completion), add them to @suaveplan/i18n first via a separate PR landing before this one, with their own test coverage.
  • 21.2 Re-export LocaleDetector, DetectorContext, createDetectorChain from @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=... from URL.searchParams. Configurable paramName. Tests cover server (URL injected via ctx.request.url) and browser (window.location.search).
  • 22.2 userPreferenceDetector — env 'both', async, persisting; calls fetch(opts.endpoint) with extractAuth(ctx) headers. POSTs back on cache(locale, ctx). Tests cover SSR auth threading, browser path, network failure (returns null), persisting flow.
  • 22.3 cookieDetector — env 'both', sync, persisting; reads Cookie header (server) or document.cookie (browser). Writes Set-Cookie (server via ctx.responseHeaders) or document.cookie (browser) on cache. Configurable name, maxAge, sameSite.
  • 22.4 localStorageDetector — env 'browser', sync, persisting; reads/writes localStorage[opts.key]. Returns null if localStorage unavailable.
  • 22.5 htmlLangDetector — env 'browser', sync; reads document.documentElement.lang.
  • 22.6 acceptLanguageDetector — env 'server', sync; parses Accept-Language per RFC 9110 q-values; returns highest-priority supported entry.
  • 22.7 navigatorDetector — env 'browser', sync; iterates navigator.languages || [navigator.language]; returns first supported.
  • 22.8 cloudflareIpGeoDetector — env 'server', sync; reads cf-ipcountry header; 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 whether opts.userPreferenceEndpoint is set
  • 22.11 Per-detector co-located .md files satisfying §4 of openspec/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 in src/hooks/use-locale-detection/
  • 23.2 <TranslationProvider> runs the chain on mount and exposes the result via useSyncExternalStore
  • 23.3 setLocale(newLocale) walks every persisting detector and calls cache(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 via getMeter('suaveplan.react-i18n')
  • 24.2 Histogram i18n.detector.duration_ms{detector} for detectAsync calls
  • 24.3 Span i18n.detect wrapping the chain run, attributes { resolved_locale, winning_detector }
  • 24.4 Tests use a real in-memory MeterProvider and TracerProvider

25. Branded TranslationKey typing contract

  • 25.1 src/translation-key/translation-key.types.ts exports AppTranslationKeys, ContributedTranslationKeys, TranslationKey
  • 25.2 TranslationKey is string & { readonly __brand: 'TranslationKey' } & (keyof AppTranslationKeys | keyof ContributedTranslationKeys)
  • 25.3 expectTypeOf assertions: empty interfaces → TranslationKey extends never; after augmentation → branded literal union typechecks
  • 25.4 Co-located .md documenting the augmentation pattern with two worked examples (consumer-app augmentation; downstream-package augmentation)
  • 25.5 scripts/generate-translation-types.ts reads a package's locales/en-GB.json and emits <package>/src/locales/translation-keys.ts (the union) plus <package>/src/locales/augmentation.d.ts (augments ContributedTranslationKeys). Same script ships for use by every downstream package.
  • 25.6 react-i18n itself uses the script to publish its own internal-error keys (REACT_I18N.NO_PROVIDER etc. resolved via useErrorMessage)

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.json declares no @suaveplan/react-ui* / @suaveplan/react-forms* deps
  • 26.3 Wired into bunx turbo lint for the @suaveplan/react-i18n package
  • 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) with context: { key, locale }
  • 27.3 Each call emits one warn log via @suaveplan/logger (or console.warn fallback)
  • 27.4 Co-located .md documenting the rare-use intent and the warn-log observability surface
  • 27.5 Add REACT_I18N.UNKNOWN_KEY to the defineCodes("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 — expectTypeOf assertions for empty and augmented states
  • 28.4 translation-key/t-dynamic.spec-conformance.test.ts — known key returns; unknown key throws UNKNOWN_KEY; warn log fires once per call