Files
2026-05-27 23:29:53 +01:00

182 lines
11 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Spec Delta — react-i18n plugin registry
## ADDED Requirements
### Requirement: ReactI18nRegistry plugin substrate
The package SHALL expose `createReactI18nRegistry(): ReactI18nRegistry` and a `ReactI18nRegistry` interface that accepts three plugin kinds — detectors, formatters, post-processors — through three registration methods. Each registration SHALL return a `Disposable` that, when disposed, removes the plugin from the registry. Registration SHALL be idempotent for the same plugin instance and SHALL throw `REACT_I18N.PLUGIN_DUPLICATE_ID` when a different plugin instance is registered with an `id` already present in the same kind. Plugins SHALL be validated against `DetectorPluginSchema` / `FormatterPluginSchema` / `PostProcessorPluginSchema` at registration time; validation failures throw `REACT_I18N.PLUGIN_INVALID` with the underlying Zod issues attached.
**Verified by:** `src/registry/registry.test.ts`, `src/registry/registry.spec-conformance.test.ts`.
#### Scenario: registering a detector plugin returns a disposable that removes it
- GIVEN a fresh `ReactI18nRegistry`
- WHEN a caller registers a `DetectorPlugin` with `id: "ab-bucket"` and disposes the returned handle
- THEN the registry no longer reports the plugin in `listDetectors()`
#### Scenario: re-registering the same plugin instance is idempotent
- GIVEN a `ReactI18nRegistry` with detector plugin `ab-bucket` already registered
- WHEN the same plugin instance is registered again
- THEN no error is thrown and `listDetectors()` returns exactly one entry for `ab-bucket`
#### Scenario: registering a different plugin with a duplicate id throws
- GIVEN a `ReactI18nRegistry` with detector plugin `ab-bucket` v1 registered
- WHEN a different `DetectorPlugin` instance with `id: "ab-bucket"` is registered
- THEN registration throws `REACT_I18N.PLUGIN_DUPLICATE_ID`
#### Scenario: schema-invalid plugin throws PLUGIN_INVALID
- GIVEN a malformed `FormatterPlugin` (missing `format` function)
- WHEN registration is attempted
- THEN `REACT_I18N.PLUGIN_INVALID` is thrown with the Zod issue list attached as `cause`
### Requirement: RegistryProvider context
The package SHALL expose `<RegistryProvider plugins? disableBuiltInFormatters? children>`. On mount, the provider SHALL create a fresh registry, register the six built-in formatter plugins (`number`, `date`, `currency`, `list`, `relative-time`, `plural`) unless `disableBuiltInFormatters: true`, and register every plugin in the `plugins` prop. On unmount, the provider SHALL dispose every plugin handle in reverse registration order. The active registry SHALL be exposed via `useReactI18nRegistry()`. Outside a `<RegistryProvider>`, `useReactI18nRegistry()` SHALL throw `REACT_I18N.REGISTRY_NOT_PROVIDED`.
**Verified by:** `src/registry/registry-context.test.tsx`.
#### Scenario: provider registers built-in formatters by default
- GIVEN a `<RegistryProvider>` with no props
- WHEN a child calls `useReactI18nRegistry().getFormatter("number")`
- THEN it returns the built-in number formatter plugin
#### Scenario: disableBuiltInFormatters omits built-ins
- GIVEN a `<RegistryProvider disableBuiltInFormatters />`
- WHEN a child calls `useReactI18nRegistry().getFormatter("number")`
- THEN it returns `undefined`
#### Scenario: useReactI18nRegistry outside a provider throws
- GIVEN a tree with no `<RegistryProvider>` ancestor
- WHEN `useReactI18nRegistry()` is called
- THEN it throws `REACT_I18N.REGISTRY_NOT_PROVIDED`
### Requirement: useFormatter hook
The package SHALL expose `useFormatter<TArgs, TResult>(id: string, args: TArgs): TResult`. The hook SHALL look up the `FormatterPlugin` from the active registry by `id`, call `plugin.format(useLocale(), args)`, and memoize the result by `(locale, id, args)`. Lookup of an unknown id SHALL throw `REACT_I18N.FORMATTER_NOT_REGISTERED`. The existing per-type hooks (`useNumber`, `useDate`, `useCurrency`, `useList`, `useRelativeTime`, `usePlural`) SHALL delegate through `useFormatter` when a registry is in scope; when no registry is in scope they SHALL fall back to the current direct-import implementation so callers without `<RegistryProvider>` see no behavior change.
**Verified by:** `src/hooks/use-formatter/use-formatter.test.tsx`, updated `use-number.test.tsx` etc.
#### Scenario: built-in formatter id resolves through useFormatter
- GIVEN a `<RegistryProvider>` mounted in a tree
- WHEN a component calls `useFormatter("number", { value: 1234.5 })` with locale `en-GB`
- THEN it returns the same string `useNumber(1234.5)` would return: `"1,234.5"`
#### Scenario: caller-registered formatter overrides built-in
- GIVEN a `<RegistryProvider plugins={[customNumberPlugin]}/>` where `customNumberPlugin.id === "number"`
- WHEN a component calls `useFormatter("number", { value: 1234.5 })`
- THEN it returns the custom plugin's output, not the built-in's
#### Scenario: unknown formatter id throws FORMATTER_NOT_REGISTERED
- GIVEN a `<RegistryProvider>` with no plugin for id `"duration"`
- WHEN a component calls `useFormatter("duration", { ms: 5000 })`
- THEN it throws `REACT_I18N.FORMATTER_NOT_REGISTERED`
### Requirement: Post-processor pipeline runs after ICU interpolation
The `<Trans>` component SHALL run every registered `PostProcessorPlugin` against the rendered message string after ICU interpolation but before mounting the result into the DOM. Plugins SHALL be invoked in ascending `priority` order; ties SHALL be broken lexicographically by `id`. With no post-processor plugins registered, `<Trans>` output SHALL be byte-identical to current behavior.
**Verified by:** `src/registry/post-processors/post-processors.test.ts`, `src/components/trans/trans.test.tsx`.
#### Scenario: pseudo-localizer post-processor wraps every message
- GIVEN a `PostProcessorPlugin { id: "pseudo", priority: 100, process: m => "[" + m + "]" }` registered
- WHEN `<Trans i18nKey="hello"/>` renders a message that resolves to `"Hello"`
- THEN the rendered text content is `"[Hello]"`
#### Scenario: same-priority plugins execute in lexicographic id order
- GIVEN two post-processors with `priority: 100`, ids `"a"` and `"b"`, where `"a"` upper-cases and `"b"` reverses
- WHEN a message `"hi"` enters the pipeline
- THEN the rendered output is the result of `b(a("hi"))` — `"IH"`, not `"HI"` reversed
### Requirement: Detector chain accepts plugins from registry
`defaultDetectorChain(options)` SHALL accept an optional `registry?: ReactI18nRegistry`. When provided, every `DetectorPlugin` registered in the registry SHALL be spliced into the built-in chain at the position determined by its `priority` (lower numbers earlier). Built-in detectors keep their current relative order. Ties between a built-in and a plugin SHALL place the built-in first. Ties between two plugins SHALL be broken lexicographically by `id`. With no registry passed, the chain SHALL match the existing 9-element ordering exactly.
**Verified by:** `src/detector/default-chain/default-chain.test.ts`.
#### Scenario: plugin with priority 0 runs before all built-ins
- GIVEN a `DetectorPlugin` with `priority: 0, id: "ab-bucket"` registered
- WHEN `defaultDetectorChain({ registry })` is invoked
- THEN `ab-bucket` is the first entry in the returned array
#### Scenario: no registry preserves existing chain
- GIVEN no `registry` argument
- WHEN `defaultDetectorChain()` is invoked
- THEN the returned array is byte-identical to today's 9-element output
### Requirement: Plugin lifecycle observability
The registry SHALL emit OTel metrics:
- counter `react_i18n.registry.plugins_total{kind,outcome}` (`kind` ∈ `detector|formatter|post-processor`, `outcome` ∈ `registered|disposed|duplicate|invalid`)
- histogram `react_i18n.formatter.duration_ms{id}` recorded around every `useFormatter` call
- counter `react_i18n.formatter.errors_total{id,code}` incremented when a formatter throws
Attribute cardinality SHALL be bounded — `id` is allowed because formatter plugins have a registered id; arbitrary user input SHALL NEVER appear as an attribute value.
**Verified by:** `src/registry/registry.test.ts` (metric assertions via `@suaveplan/testing`).
#### Scenario: registering a new detector plugin emits the registered counter
- GIVEN a fresh registry
- WHEN a `DetectorPlugin` is registered
- THEN `react_i18n.registry.plugins_total{kind:detector,outcome:registered}` increments by 1
### Requirement: LocaleAdapterPlugin slot for third-party locale objects
The package SHALL expose a fourth plugin kind, `LocaleAdapterPlugin<TLocale>`, registered via `registry.registerLocaleAdapter(plugin)`. Each adapter exposes `{ id: string; resolve(localeString: string): TLocale }` and SHALL be looked up via `useLocaleAdapter<TLocale>(id: string): TLocale`. Lookup of an unknown id SHALL throw `REACT_I18N.LOCALE_ADAPTER_NOT_REGISTERED`. A built-in `"date-fns"` adapter SHALL be auto-registered by `<RegistryProvider>` (via dynamic import so non-Calendar consumers do not pay the bundle cost). The same registry / disposal / schema / OTel surface as the other plugin kinds applies.
**Verified by:** `src/registry/locale-adapters/locale-adapters.test.tsx`.
#### Scenario: built-in date-fns adapter resolves to a date-fns Locale
- GIVEN a `<RegistryProvider>` mounted in a tree at locale `en-GB`
- WHEN a component calls `useLocaleAdapter("date-fns")`
- THEN it returns the date-fns `enGB` `Locale` object
#### Scenario: a luxon adapter overrides the default
- GIVEN a `<RegistryProvider plugins={[luxonAdapterPlugin]}/>` where `luxonAdapterPlugin.id === "date-fns"` and `resolve` returns a luxon `DateTime` zone object
- WHEN a child calls `useLocaleAdapter("date-fns")`
- THEN it returns the luxon adapter's output, not the date-fns built-in
#### Scenario: unknown adapter id throws
- GIVEN no plugin registered under id `"luxon"`
- WHEN `useLocaleAdapter("luxon")` is called
- THEN it throws `REACT_I18N.LOCALE_ADAPTER_NOT_REGISTERED`
### Requirement: Plugin error codes
The package SHALL add the following codes to its `REACT_I18N` registry: `PLUGIN_DUPLICATE_ID`, `PLUGIN_INVALID`, `REGISTRY_NOT_PROVIDED`, `FORMATTER_NOT_REGISTERED`, `DETECTOR_PLUGIN_DUPLICATE_ID`, `DETECTOR_PLUGIN_INVALID`, `LOCALE_ADAPTER_NOT_REGISTERED`. Each code SHALL be wired to a real call site in `src/` and exercised by tests; codes SHALL NOT exist in the registry without being thrown.
**Verified by:** `src/errors/error-codes.test.ts` (registry assertions), and the per-feature tests above.
## MODIFIED Requirements
### Requirement: TranslationProvider mounts an I18nInstance
`<TranslationProvider>` SHALL continue to mount and expose an `I18nInstance` from `@suaveplan/i18n` to descendants via React context, including all existing scenarios for caller-managed vs provider-managed lifecycle and `useSyncExternalStore` re-render wiring. The provider SHALL additionally pick up the active `ReactI18nRegistry` (if a `<RegistryProvider>` ancestor exists) and route the message-render path through the post-processor pipeline. With no `<RegistryProvider>` ancestor, behavior SHALL be identical to today.
**Verified by:** existing `provider.test.tsx` (unchanged scenarios) plus a new scenario asserting post-processor wiring is opt-in.
#### Scenario: TranslationProvider without RegistryProvider preserves current behavior
- GIVEN a `<TranslationProvider>` mounted with no `<RegistryProvider>` ancestor
- WHEN any component reads `useTranslation()` and renders a message
- THEN the rendered text matches today's output exactly (no post-processor side effects)