Files
skills/docs/localization/i18n/react-i18n/README.md
2026-05-29 08:20:56 +01:00

75 KiB

@suaveplan/react-i18n - React bindings for @suaveplan/i18n: TranslationProvider, hooks, , , locale-detection chain glue, branded TranslationKey contract.

Summary

React bindings for @suaveplan/i18n. Ships the <TranslationProvider>, twelve hooks, four headless components, the class-component HOC, the nine built-in locale detectors plus the default chain, the useLocaleDetection hook, the branded TranslationKey typing contract, the tDynamic runtime escape hatch, the React-tier OTel instruments, and the warn-once / identity-translator soft-failure substrate. The package is the leaf-most React library in the project — every other React package augments it (via ContributedTranslationKeys) but no React package may declare it as a downstream dependency. The check-no-backwards-deps.ts CI gate enforces that invariant on every commit.

Documentation tree

- [fallback-bundle](./src/components/i18n-suspense/fallback-bundle.md) — Ships the strings that `` needs before the active catalog loads its own copies. Consumers can override th…

Install

bun add @suaveplan/react-i18n

Peer-installs:

bun add react react-dom

The package targets react ^18.0.0 || ^19.0.0 so useSyncExternalStore is available without the use-sync-external-store/shim polyfill. All internal @suaveplan/* deps resolve through the workspace.

Quick start

The minimum tree wires a Provider, a Trans component, and a hook-driven locale switch. The t function exposed by useTranslation() is the ergonomic-options-bag form from @suaveplan/i18n.

import {
    Trans,
    TranslationProvider,
    createI18nConfig,
    useTranslation,
} from "@suaveplan/react-i18n";

const config = createI18nConfig({
    defaultLocale: "en-GB",
    fallbackLocale: "en-GB",
    supportedLocales: ["en-GB", "fr-FR"],
    initialMessages: {
        "en-GB": { greeting: "Hello, <bold>{name}</bold>" },
        "fr-FR": { greeting: "Bonjour, <bold>{name}</bold>" },
    },
});

function Welcome({ name }: { name: string }): JSX.Element {
    const { t } = useTranslation();
    return (
        <>
            <p>{t("welcome", { defaultValue: "Welcome" })}</p>
            <p>
                <Trans
                    i18nKey="greeting"
                    values={{ name }}
                    components={{ bold: <strong /> }}
                />
            </p>
        </>
    );
}

export function App({ children }: { children: React.ReactNode }) {
    return (
        <TranslationProvider config={config}>
            <Welcome name="Alice" />
            {children}
        </TranslationProvider>
    );
}

Modules

Every implementation file ships a sibling .md with the canonical sections (Purpose, Features, Basic + Advanced examples, API Reference, Implementation Notes). Read them inline alongside the source.

Provider and context

  • <TranslationProvider> — mounts an I18nInstance and exposes it through React context. Accepts either a caller-managed i18n prop (borrowed; not disposed) or a Provider-managed config prop (created on mount, disposed on unmount).
  • TranslationContext — the raw context object for advanced consumers (mostly tests).

Hooks

  • useI18n — returns the active I18nInstance (or null when outside a Provider).
  • useTranslation — Suspense-aware { t, i18n, ready } triple. Auto-triggers i18n.catalog.preload(locale, namespace) when a backend is registered and the namespace is not yet loaded.
  • useTranslate — terse alias returning the bound t function directly.
  • useLocale — { locale, setLocale } re-rendering on languageChanged.
  • useDirection — { direction, locale } derived from getTextDirection(locale).
  • useErrorMessage — maps a SuaveplanError | unknown to a translated string.
  • usePlural — the plural category + rule for a count + locale.
  • useDate — memoised Intl.DateTimeFormat wrapper.
  • useNumber — memoised Intl.NumberFormat wrapper with the clearUseNumberCache() test helper.
  • useCurrency — memoised currency formatter.
  • useRelativeTime — memoised Intl.RelativeTimeFormat wrapper.
  • useList — memoised Intl.ListFormat wrapper.
  • useLocaleDetection — runs the configured detector chain on mount, exposes { detected, source, isPending, redetect }.
  • DetectorChainContext / DetectorChainProvider — the context wiring that exposes the chain to descendants.

Components

  • <Trans> — rich-text component using MessageParser.formatChunks<ReactNode>. Supports tag interpolation, nested tags, plural via count, and a children-as- default-message fallback.
  • <LanguageSwitcher> — headless render-prop component. Receives { locales, current, change, isPending }. Wraps change in useTransition so namespace loads on locale change do not block urgent updates.
  • <MissingKeyOverlay> — dev-mode overlay listing untranslated keys in real-time, capped at 50 entries with aria-live="polite". Renders null in production.
  • <I18nSuspense> — convenience wrapper around React's <Suspense> with a default aria-live="polite" fallback and a fallback-render counter (react_i18n.suspense.fallback.total). The default loading label is resolved from the active catalog with the bundled English fallback supplied by fallback-bundle.
  • stringifyChild / childrenToMessageString — internal <Trans> helpers exposed for advanced consumers building custom rich-text components.

HOC

  • withTranslation — class-component escape hatch. Injects t and i18n props.

Locale detectors

Each detector is independently importable from @suaveplan/react-i18n (and tree-shaken when unused).

Branded TranslationKey

  • TranslationKey / AppTranslationKeys / ContributedTranslationKeys — branded string union pattern. Both interfaces start empty; downstream packages augment ContributedTranslationKeys and consumer apps augment AppTranslationKeys.
  • tDynamic — runtime escape hatch. Throws ReactI18nError(REACT_I18N.UNKNOWN_KEY) on a missing key; emits a warn log on every call so observability surfaces every use site.

Errors and observability

  • ReactI18nError — extends SuaveplanError<ReactI18nCode>. The only error type the React surface throws.
  • REACT_I18N_CODES — five frozen codes: NO_PROVIDER, NAMESPACE_LOAD_FAILED, HYDRATION_MISMATCH, TRANS_INVALID_CHILD, UNKNOWN_KEY.
  • react_i18n.* OTel instruments — counters (missing_key.total, suspense.fallback.total), histogram (render.duration_ms), in-memory metrics state ring for tests, plus the public getReactI18nMetrics() / recordMissingKey() / recordSuspenseFallback() / recordRenderDuration() helpers.

Schemas, config, and utils

  • createI18nConfig — Zod validator that returns a typed I18nConfig. Routes initialMessages failures to ReactI18nError(HYDRATION_MISMATCH) and other shape failures to I18nError(I18N.INVALID_OPTIONS) so observability dashboards group on the React-tier code where appropriate.
  • toI18nInstanceOptions — pure mapping from I18nConfig to the universal-core I18nInstanceOptions shape.
  • I18nConfigSchema / TranslationProviderPropsSchema — Zod boundary schemas for the Provider's accepted prop shape and the config bag.
  • isProductionEnv — single source of truth for the dev-vs-prod branch every soft-failure path uses.
  • getIdentityTranslator / IDENTITY_TRANSLATION_RETURN — no-op identity translator returned by useTranslation outside a Provider in production builds.
  • warnOncePerProcess — global bag-of-warnings dedup so a misbehaving consumer logs each diagnostic exactly once across the process lifetime.

Detector chain wiring

The detector chain is the same on the server and the browser; the SSR render captures the resolved locale, embeds it in initialLocale, and the client hydrates without re-running browser-only detectors (per the spec's "browser detectors skipped during hydration" requirement).

import {
    DetectorChainProvider,
    TranslationProvider,
    cookieDetector,
    defaultDetectorChain,
    useLocaleDetection,
} from "@suaveplan/react-i18n";

const detectors = defaultDetectorChain({
    fallback: "en-GB",
    userPreferenceEndpoint: "/api/me/locale",
});

function Bootstrap({ children }: { children: React.ReactNode }) {
    return (
        <TranslationProvider config={config}>
            <DetectorChainProvider detectors={detectors}>
                {children}
            </DetectorChainProvider>
        </TranslationProvider>
    );
}

function LocaleStatus(): JSX.Element {
    const { detected, source, isPending, redetect } = useLocaleDetection();
    return (
        <output aria-busy={isPending}>
            <span data-testid="locale">{detected ?? "(unresolved)"}</span>
            <span data-testid="source">via {source ?? "(none)"}</span>
            <button type="button" onClick={() => void redetect()}>
                Re-detect
            </button>
        </output>
    );
}

For ad-hoc chains, import detectors individually:

import {
    DetectorChainProvider,
    cookieDetector,
    fallbackDetector,
    navigatorDetector,
} from "@suaveplan/react-i18n";

const detectors = [
    cookieDetector({ name: "lang", maxAgeSeconds: 31_536_000 }),
    navigatorDetector(),
    fallbackDetector("en-GB"),
];

Branded TranslationKey

The branded type prevents misspelled translation keys from passing typecheck. Both the consumer-app interface (AppTranslationKeys) and the downstream-package interface (ContributedTranslationKeys) start empty; augmentation contributes the keys.

Consumer app augmentation

In your app, generate the augmentation file from the canonical locales/en-GB.json via the generate-translation-types.ts script:

bun packages/react/react-i18n/scripts/generate-translation-types.ts \
    --source locales/en-GB.json \
    --target src/generated/translation-keys.d.ts \
    --interface AppTranslationKeys \
    --module @suaveplan/react-i18n

The script emits a .d.ts like:

declare module "@suaveplan/react-i18n" {
    interface AppTranslationKeys {
        "app.welcome": true;
        "app.signin.title": true;
        "app.signin.submit": true;
    }
}

After importing the generated file once at the app entrypoint, every typed t(literalKey) call typechecks the key against the registered union.

import type { TranslationKey } from "@suaveplan/react-i18n";

const k: TranslationKey = "app.welcome";   // ✅ typechecks
const bad: TranslationKey = "appp.welcome"; // ✗ TS error: not assignable

Downstream package augmentation

When publishing a react-ui-pack-* or react-ui-theme-* package, augment ContributedTranslationKeys instead — that interface is namespaced for downstream contributions so consumer-app augmentations do not collide with library-defined keys.

declare module "@suaveplan/react-i18n" {
    interface ContributedTranslationKeys {
        "react-ui.dialog.close": true;
        "react-ui.dialog.confirm": true;
    }
}

The downstream package side-effect-imports the augmentation file from its index.ts. Once a consumer adds the package to their tree, the keys become assignable to TranslationKey without any per-consumer configuration.

tDynamic runtime escape hatch

For the rare case where a key is computed at runtime — most often mapping an error code like error.code to errors:<code> — use tDynamic. It performs a runtime catalog lookup and throws ReactI18nError(REACT_I18N.UNKNOWN_KEY) when the key is missing. Every call also emits a warn-level log so observability surfaces each use site (the typed t(literalKey) path is preferred 99% of the time).

import { ReactI18nError, tDynamic, useI18n } from "@suaveplan/react-i18n";

function ErrorMessage({ error }: { error: { code: string } }): string {
    const i18n = useI18n();
    if (i18n === null) return "";
    try {
        return tDynamic(i18n, `errors:${error.code}`);
    } catch (cause) {
        if (
            cause instanceof ReactI18nError &&
            cause.code === "REACT_I18N.UNKNOWN_KEY"
        ) {
            return error.code;
        }
        throw cause;
    }
}

No-backwards-deps gate

@suaveplan/react-i18n is the leaf-most React package . Every downstream React package — @suaveplan/react-ui, @suaveplan/react-forms, every @suaveplan/react-ui-theme-*, every @suaveplan/react-ui-pack-* — augments ContributedTranslationKeys via TypeScript module augmentation. For that pattern to stay sound, react-i18n MUST NOT depend on any of those packages in any of its dep buckets (dependencies, peerDependencies, devDependencies).

The CI gate at scripts/lint/check-no-backwards-deps.ts enforces the rule on every commit:

bun scripts/lint/check-no-backwards-deps.ts \
    --package-json packages/react/react-i18n/package.json

The script is wired into package.json's lint step so bunx turbo lint --filter=@suaveplan/react-i18n fails fast if a banned dep ever lands.

Telemetry

OTel instruments published via getMeter("suaveplan.react-i18n"):

  • Counter react_i18n.missing_key.total — labels: locale, namespace, componentName?. Incremented from <Trans> (and tDynamic) when a key is absent from the catalog.
  • Counter react_i18n.suspense.fallback.total — labels: namespace, locale. Incremented every time <I18nSuspense> renders its default fallback.
  • Histogram react_i18n.render.duration_ms — labels: locale, namespace, componentName?. Recorded around the <Trans> formatChunks render.
  • Counter i18n.detector.match_total{detector} — recorded by every detector when it returns a non-null supported locale.
  • Histogram i18n.detector.duration_ms{detector} — recorded for each detectAsync call.
  • Span i18n.detect — wraps the entire chain run; carries winning_detector and resolved_locale attributes.

The tDynamic call's per-use warn log is NOT OTel — it goes to the @suaveplan/logger sink and surfaces in centralised log aggregation.

E2E sweep

The Playwright suite at e2e/react-i18n.spec.ts runs against a tiny Vite + React test app at e2e/test-app/ and covers the five user-visible behaviours:

  1. Live language switch through all five locales (en-GB, ar-AE, zh-CN, cy-GB, ru-RU) with localStorage persistence.
  2. RTL flip on Arabic — <html dir="rtl"> updates and the layout reflows.
  3. Lazy-namespace Suspense — the <I18nSuspense> fallback resolves to translated content via the async test backend.
  4. Missing-key overlay (dev mode) — caps at 50 entries with aria-live="polite".
  5. SSR hydration round-trip — the server-rendered HTML already contains the translated string (no flash) and the client hydrates without a React warning.

Per CLAUDE.md "Web App & Page Verification": every scenario also asserts (a) zero browser-console errors, (b) zero failed network requests (4xx/5xx), and (c) the rendered DOM contains the expected content (not merely a 200 response).

bunx turbo lint typecheck test:coverage build --filter=@suaveplan/react-i18n
bun --cwd packages/react/react-i18n test:e2e

Testing

bunx turbo lint typecheck test --filter=@suaveplan/react-i18n
bunx turbo test:coverage --filter=@suaveplan/react-i18n

Coverage targets: 100/100/100/100 (statements / branches / functions / lines) per CLAUDE.md §"Quality". The thresholds are wired into vitest.config.ts. The pre-publish gate also runs the full §13 verification sequence — see AGENTS.md for the canonical checklist.

License

MIT.