# 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 ``. 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 ``, `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 `` 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 `` - WHEN a child calls `useReactI18nRegistry().getFormatter("number")` - THEN it returns `undefined` #### Scenario: useReactI18nRegistry outside a provider throws - GIVEN a tree with no `` ancestor - WHEN `useReactI18nRegistry()` is called - THEN it throws `REACT_I18N.REGISTRY_NOT_PROVIDED` ### Requirement: useFormatter hook The package SHALL expose `useFormatter(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 `` 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 `` 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 `` 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 `` 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 `` 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, `` 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 `` 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`, registered via `registry.registerLocaleAdapter(plugin)`. Each adapter exposes `{ id: string; resolve(localeString: string): TLocale }` and SHALL be looked up via `useLocaleAdapter(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 `` (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 `` 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 `` 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 `` 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 `` ancestor exists) and route the message-render path through the post-processor pipeline. With no `` 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 `` mounted with no `` ancestor - WHEN any component reads `useTranslation()` and renders a message - THEN the rendered text matches today's output exactly (no post-processor side effects)