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

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 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)