11 KiB
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
DetectorPluginwithid: "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
ReactI18nRegistrywith detector pluginab-bucketalready registered - WHEN the same plugin instance is registered again
- THEN no error is thrown and
listDetectors()returns exactly one entry forab-bucket
Scenario: registering a different plugin with a duplicate id throws
- GIVEN a
ReactI18nRegistrywith detector pluginab-bucketv1 registered - WHEN a different
DetectorPlugininstance withid: "ab-bucket"is registered - THEN registration throws
REACT_I18N.PLUGIN_DUPLICATE_ID
Scenario: schema-invalid plugin throws PLUGIN_INVALID
- GIVEN a malformed
FormatterPlugin(missingformatfunction) - WHEN registration is attempted
- THEN
REACT_I18N.PLUGIN_INVALIDis thrown with the Zod issue list attached ascause
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 localeen-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]}/>wherecustomNumberPlugin.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
DetectorPluginwithpriority: 0, id: "ab-bucket"registered - WHEN
defaultDetectorChain({ registry })is invoked - THEN
ab-bucketis the first entry in the returned array
Scenario: no registry preserves existing chain
- GIVEN no
registryargument - 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 everyuseFormattercall - 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
DetectorPluginis 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 localeen-GB - WHEN a component calls
useLocaleAdapter("date-fns") - THEN it returns the date-fns
enGBLocaleobject
Scenario: a luxon adapter overrides the default
- GIVEN a
<RegistryProvider plugins={[luxonAdapterPlugin]}/>whereluxonAdapterPlugin.id === "date-fns"andresolvereturns a luxonDateTimezone 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)