first commit
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
# useTranslation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`useTranslation(namespace?)` is the primary translation hook of
|
||||
`@suaveplan/react-i18n`. It returns a `{ t, i18n, ready }` triple bound
|
||||
to the active `I18nInstance` (from the nearest `<TranslationProvider>`)
|
||||
and orchestrates Suspense-aware namespace loading when a backend is
|
||||
registered. Components subscribe to locale changes through the Provider's
|
||||
internal `useSyncExternalStore`, so a `setLocale('fr-FR')` call triggers
|
||||
re-renders on the same React commit cycle as the bus emission.
|
||||
|
||||
The hook splits cleanly into three control-flow modes — synchronous-ready
|
||||
(no namespace work needed), Suspense-throw (preload in flight), and
|
||||
error-boundary-throw (preload rejected). All three are testable without
|
||||
mocking the i18n core: the tests use a real `I18nInstance` plus a tiny
|
||||
in-test `Backend` that exposes `resolve` / `reject` controls.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns `{ t, i18n, ready }` where `t` is the ergonomic options-bag translator.
|
||||
- Optional `namespace` argument auto-prefixes every `t(key)` call with `<namespace>:`.
|
||||
- Suspense-aware: throws `i18n.catalog.preload([locale], [namespace])` when the namespace is not loaded and a backend is registered.
|
||||
- Error boundary aware: a rejected preload Promise surfaces as `ReactI18nError(REACT_I18N.NAMESPACE_LOAD_FAILED)` with `error.cause` set to the original `I18nError`.
|
||||
- Production no-Provider soft-fail returns the identity translator (`t(key) === key` or `defaultValue` when supplied).
|
||||
- Per-(instance, locale, namespace) preload registry keeps the Suspense throw stable across re-renders so the boundary actually resolves.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { TranslationProvider, useTranslation } from "@suaveplan/react-i18n";
|
||||
import { createI18n } from "@suaveplan/i18n/plugin";
|
||||
|
||||
function LoginButton() {
|
||||
const { t } = useTranslation("auth");
|
||||
return <button type="submit">{t("login")}</button>;
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": { auth: { login: "Sign in" } } },
|
||||
});
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<LoginButton />
|
||||
</TranslationProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
When a backend is registered on the instance and the namespace has not
|
||||
yet been loaded, the hook participates in React Suspense. Wrap the
|
||||
subtree in `<Suspense>` and the boundary handles the loading state.
|
||||
A rejected backend read surfaces through the nearest error boundary as
|
||||
`ReactI18nError(REACT_I18N.NAMESPACE_LOAD_FAILED)` with the underlying
|
||||
`I18nError` available via `.cause`.
|
||||
|
||||
```tsx
|
||||
import { Suspense } from "react";
|
||||
import { TranslationProvider, useTranslation } from "@suaveplan/react-i18n";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
function Profile() {
|
||||
const { t } = useTranslation("profile");
|
||||
return (
|
||||
<section>
|
||||
<h1>{t("title")}</h1>
|
||||
<p>{t("subtitle", { values: { name: "Alice" } })}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfilePage(props: { i18n: import("@suaveplan/i18n/plugin").I18nInstance }) {
|
||||
return (
|
||||
<TranslationProvider i18n={props.i18n}>
|
||||
<ErrorBoundary fallbackRender={({ error }) => <pre>{error.code}</pre>}>
|
||||
<Suspense fallback={<span>Loading…</span>}>
|
||||
<Profile />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TranslationProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `useTranslation(namespace?: string): UseTranslationReturn`
|
||||
|
||||
- **Parameters**:
|
||||
- `namespace?` — optional namespace prefix applied to every `t(key)` call.
|
||||
- **Returns**: `{ t, i18n, ready }`.
|
||||
- `t(key, options?)` — bound translator. `options` accepts `count`, `context`, `defaultValue`, `values`.
|
||||
- `i18n` — the active `I18nInstance`, or `undefined` in the production no-Provider fallback.
|
||||
- `ready` — `true` when the namespace is loaded (or no namespace was requested), `false` only on the production no-Provider path.
|
||||
- **Throws**:
|
||||
- **Promise** when a backend is registered, the namespace is not loaded, and Suspense semantics should kick in. The Promise resolves to `void` once the catalog finishes loading.
|
||||
- **`ReactI18nError(REACT_I18N.NAMESPACE_LOAD_FAILED)`** when the preload Promise rejects. `error.cause` carries the original `I18nError` (e.g. `BACKEND_READ_FAILED`).
|
||||
- **`ReactI18nError(REACT_I18N.NO_PROVIDER)`** when called outside a `<TranslationProvider>` and `process.env.NODE_ENV !== "production"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The Suspense throw uses a per-(instance, locale, namespace) entry stored in a `WeakMap<I18nInstance, Map<string, PreloadEntry>>`. React renders the same component multiple times during a suspension cascade — without this stability the boundary would never resolve.
|
||||
- `applyNamespace` does NOT prepend the namespace when the caller-supplied key already contains `":"`. This matches the upstream catalog convention so a key like `profile:title` always means namespace `profile`, key `title`.
|
||||
- The hook intentionally does NOT call `useSyncExternalStore` itself. The Provider holds the only subscription and re-renders descendants on `languageChanged` / `loaded` so per-render cost stays at one `useContext` read.
|
||||
- The bound translator is rebuilt on every render (via `buildBoundTranslator`) — it captures the current `instance.locale` implicitly through `instance.t`, so a locale change re-renders subscribers AND yields a fresh closure.
|
||||
- Production no-Provider fallback uses a lightweight identity translator built inside the hook (NOT `IDENTITY_TRANSLATION_RETURN.t`) so it understands the `options.defaultValue` precedence exactly the same way the live translator does.
|
||||
Reference in New Issue
Block a user