Sign In

@sonenta/react-i18next

Package Overview
Dependencies
Maintainers
1
Versions
23
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@sonenta/react-i18next - npm Package Compare versions

Comparing version
2.6.9
to
2.7.0
+126
-0
CHANGELOG.md
# Changelog — @sonenta/react-i18next
## 2.7.0
**Actionable:** EVERY consumer of `@sonenta/feedback` or `@sonenta/in-context`, everyone who was
told this package is a react-i18next drop-in, and anyone who ever passed `fetchImpl` to
`<SonentaProvider>` and assumed it did something. Three things change.
1. **The paid add-ons were showing the wrong screen.** The on-screen key registry has two
producers — a per-render one (our hook, dropped on unmount) and a cumulative one (the
`i18next.t` patch, dropped only at `stop()`) — and **our own hook was feeding BOTH**. Since
`snapshot()` is a union, the cumulative superset always won: `feedback` listed every string
since app start, `in-context` highlighted the whole history. **The more of your app you had
migrated to us, the worse it got.** Fixed by a reentrancy flag. No API change, no config: you
get the current screen after upgrading.
2. **The drop-in now actually compiles.** `useTranslation` returns react-i18next's real shape —
the tuple `[t, i18n, ready]` AND `{ t, i18n, ready }` — and `withTranslation`, `Translation`,
`I18nextProvider`, `useSSR` and friends are exported. `<Trans>` is now react-i18next's own
`<Trans>`, so nesting, `count` plurals, `tOptions` and self-closing tags work.
3. **`fetchImpl` on the React provider was a SILENT NO-OP.** `vue-i18n`, `svelte-i18n` and `next`
have all accepted it and forwarded it to `start()` for months; React called `start()` with no
argument. The key typechecked, looked supported, and the SDK went to the real network anyway.
If you passed it expecting an authenticated fetch or a proxy, **it was never used.** It is now.
**NOT exposed:** an app that imports nothing from us but `SonentaProvider`, renders no add-on, and
never passed `fetchImpl`. **Engine diff vs 2.6.9:** the `i18next.t` patch gained one guard
(`if (isTrackingSuppressed()) return result`) and the provider now forwards `config.fetchImpl` to
`start()`; resolution is byte-for-byte unchanged.
### Fixed — the cumulative key registry (the reason the add-ons were unusable)
`_wrapInstanceT` now skips `_track` for any resolution that a per-render producer already owns,
and for engine-internal overlay re-resolutions (`common.plain_language`, `items_3`) — which were
being reported to the add-ons as **strings on the current screen**. Keys from a screen you have
left now leave the snapshot with it.
`snapshot()` remains a **NAIVE UNION**. It is tempting to make per-render tokens take precedence
now that the registry knows each token's species — and that rule causes a **silent outage**: an
app whose only Sonenta-bound component is a key-less `<I18nGate>`, with every string still
rendered through `react-i18next`, has an EMPTY per-render token and a cumulative one holding the
whole screen. Precedence returns `[]` — a blank panel on a working app. There is a gate for it
(`GATE #4`), and it is mutation-verified.
### Added — the react-i18next compatibility surface
- `useTranslation(ns?, { keyPrefix? })` → `[t, i18n, ready]` **and** `{ t, i18n, ready }`.
Namespace **lists** resolve in order. `useSuspense` / `bindI18n` / `i18n` are accepted and
ignored (this engine owns loading; it never suspends).
- `withTranslation()` (HOC) and `<Translation>` (render prop), built on **our** hook — a bare
re-export would resolve outside it and re-open the bug above for every class component.
- Barrel: `I18nextProvider`, `I18nContext`, `TransWithoutContext`, `initReactI18next`,
`setDefaults`, `getDefaults`, `useSSR`, `withSSR`, `composeInitialProps`, `getInitialProps`.
Every name was checked against **13.0.0, 14, 15, 16 and 17** — a named re-export of a binding
the installed peer lacks is a `SyntaxError` at import, so a 17-only name would hard-crash every
app below it. `getI18n` stays **ours** (it returns the Sonenta engine, as it has since 1.0.0);
reach the raw instance via `getI18n().i18next`.
**The components are react-i18next's; the TYPE DECLARATIONS are ours** (`src/compat.ts`), and
that is deliberate. A `export … from "react-i18next"` puts a type-graph edge in our published
`index.d.ts`, which forces your `tsc` to compile react-i18next's declarations — and
**react-i18next@15.7.4 declares `peer i18next ">= 23.4.0"` while its own `.d.ts` needs i18next
≥ 25 types.** So `i18next@24 + react-i18next@15` — a pair npm installs happily, and a pair **we
advertise** — has upstream types that do not compile. Our declarations are therefore loose where
we cannot be exact, but they never make you compile somebody else's broken `.d.ts`. If you want
react-i18next's exact types, import them from `react-i18next` in your own code, where the
peer-pair constraint is yours to own. A CI guard (`no-upstream-type-edges`) keeps this true.
- `i18n` pass-throughs a migrated file actually calls: `exists`, `on`, `off`, `getFixedT`,
`hasLoadedNamespace`, `loadNamespaces`, `resolvedLanguage`, `languages`, `options`,
`isInitialized`. Bound to the real i18next instance — a listener registered on the snapshot
object would look wired and **fire never**.
### Fixed — `<Trans>` was a silent rendering regression
The old `<Trans>` was a 25-line reimplementation whose entire markup support was one regex
(`/<(\d+)>(.*?)<\/\1>/g`): no nesting, no `count` plurals, no `tOptions`, no `shouldUnescape`,
no `<br/>`. Migrating from react-i18next silently changed what your sentences rendered. It is now
react-i18next's own `<Trans>`, handed **our** `t` (so the registry and the plain-language overlay
still apply). `<Trans t={t}>` is honoured.
### Fixed — `exists()` is guarded against a nullish key
2.7.0 adds `exists()` to the public surface (a drop-in pass-through). On i18next 26 a nullish key
happens to return `false` rather than throw — but our peer range is **four majors wide**, and
"safe on the version we build against" is exactly how `aria`/`alt` shipped throwing while the API
was certified shut. `i18n.exists(item.labelKey)` where the variable is `undefined` now returns
`false` at our door. Our own `every-key-door-guarded` CI check caught this on the release commit.
### Fixed — `fetchImpl` on `<SonentaProvider>` (the React fetch seam)
The provider now forwards `config.fetchImpl` to `start()`, so the injected fetch serves **every**
request for the life of the instance — bundles, key-style, catalog, manifest — including after a
language change (an instance that honoured it only during `start()` would silently lose an
authenticated fetch on the first switch: the request goes out unauthenticated, fails, and the SDK
quietly keeps showing the old language).
Omitting it changes nothing: the global `fetch` is still resolved **at call time**, never captured
at construction (an RN polyfill lands after the class is born).
This also closes a **testing** gap, which is why it belongs in this release and not a later one:
React was the only binding whose behaviour could not be observed offline without monkey-patching
`globalThis.fetch` — so the jsdom harness written for Vue could not be ported to the binding our
largest consumers actually use. The peer-range matrix for this release now drives the SDK through
`fetchImpl` rather than a patched global.
```tsx
<SonentaProvider fetchImpl={myAuthenticatedFetch} …>
```
### What we did NOT do, and why it matters
The settled design had our hook **delegate resolution** to react-i18next's `t`. Measured against
the shipped 2.6.9 bytes, that would have shipped two silent regressions:
- **`plain_language` dies.** react-i18next's `t` resolves straight off the i18next instance and
never applies the a11y overlay. With `plainLanguage: true`, every string silently reverts to
the standard wording — an accessibility feature, failing open.
- **`ready` lies.** i18next has no backend here (WE fetch the bundles), so it reports every
namespace loaded from the FIRST render. `ready` would be `true` while the CDN fetch is still in
flight — exactly inverting the loading gate apps use it for.
So resolution stays on the Sonenta engine and only the SHAPE is react-i18next's. Both regressions
are pinned by tests.
**Verified on an executed app across the whole declared peer range** — i18next 23.0.1 / 23.16.8 /
24.2.3 / 25.10.10 / 26.3.6 against react-i18next 13.0.0 / 13.5.0 / 14.1.3 / 15.7.4 / 16.6.6 /
17.0.8 — not on a type-check, and not only on the versions we build against.
## 2.6.9

@@ -4,0 +130,0 @@

+310
-25
import * as react_jsx_runtime from 'react/jsx-runtime';
import { ReactNode } from 'react';
import { ReactNode, ComponentType, Context } from 'react';
import { i18n } from 'i18next';

@@ -225,2 +225,21 @@

/**
* The `fetch` the SDK uses for EVERY request — bundles, key-style, language
* catalog, published-languages manifest — for the whole life of the instance,
* not just the first load (a language change re-fetches with this same one).
*
* Use it to inject an authenticated fetch, a proxy, or a stub. It is the seam
* that makes the SDK testable OFFLINE, without monkey-patching
* `globalThis.fetch`:
*
* ```tsx
* <SonentaProvider fetchImpl={async (url) => new Response(JSON.stringify(bundles[url]))} …>
* ```
*
* Added in 2.7.0. `vue-i18n` / `svelte-i18n` / `next` have accepted it for
* months; on React the key was SILENTLY IGNORED — it typechecked, looked
* supported, and the SDK hit the network anyway. Omit it and the global
* `fetch` is resolved at call time, exactly as before.
*/
fetchImpl?: typeof fetch;
/**
* Optional override for missing-key delivery (in-app inspector,

@@ -480,2 +499,23 @@ * Storybook, Cypress mocks). When set, replaces the default POST.

i18next?: i18n;
/** Does the key resolve in the active language chain? */
exists: (key: string, options?: Record<string, unknown>) => boolean;
/** Subscribe to an i18next event (`languageChanged`, `loaded`, …). */
on: (event: string, listener: (...args: unknown[]) => void) => void;
/** Unsubscribe from an i18next event. */
off: (event: string, listener?: (...args: unknown[]) => void) => void;
/** i18next's fixed-`t` factory (bound language / namespace / keyPrefix). */
getFixedT: i18n["getFixedT"];
/** The language i18next actually resolved to (after the fallback chain). */
resolvedLanguage: string | undefined;
/** The active resolution chain, e.g. `["fr-CA", "fr", "en"]`. */
languages: readonly string[];
/** The live i18next options (keySeparator, nsSeparator, fallbackLng, …). */
options: i18n["options"];
/** Has i18next got the namespace in its store? */
hasLoadedNamespace: (ns: string | string[]) => boolean;
/** i18next's namespace loader. The Sonenta engine fetches bundles itself;
* this is here for source compatibility with a migrated call site. */
loadNamespaces: (ns: string | string[]) => Promise<void>;
/** Always true once the provider has constructed the engine. */
isInitialized: boolean;
}

@@ -517,15 +557,61 @@ type TranslationOptions = Record<string, unknown> & {

interface UseTranslationResult {
/**
* Options accepted by {@link useTranslation}, mirroring react-i18next's so a
* find-and-replace of the import path compiles unchanged.
*
* `useSuspense` / `bindI18n` / `i18n` are ACCEPTED AND IGNORED, deliberately:
* the Sonenta engine owns bundle loading and re-render (it is not an i18next
* backend), so it never suspends and never needs an event binding. Accepting
* them keeps a migrated call site compiling; silently ignoring them is honest
* because there is nothing for them to change here. `ready` tells the truth
* either way — see below.
*/
interface UseTranslationOptions {
/** Prefix every key with `keyPrefix + keySeparator` (react-i18next parity). */
keyPrefix?: string;
/** Accepted for source compatibility. The engine never suspends. */
useSuspense?: boolean;
/** Accepted for source compatibility. The provider owns the instance. */
i18n?: unknown;
/** Accepted for source compatibility. Re-render is driven by our store. */
bindI18n?: string | false;
}
/**
* The react-i18next return shape, exactly: an ARRAY `[t, i18n, ready]` that
* ALSO carries `.t` / `.i18n` / `.ready`. Both destructurings work —
*
* const [t] = useTranslation();
* const { t, i18n, ready } = useTranslation();
*
* — which is the whole point: the drop-in has to compile against 123 call
* sites written for react-i18next, and the tuple form is the one its own
* README teaches.
*/
type UseTranslationResult = [A11yTranslationFunction, I18nInstance, boolean] & {
t: A11yTranslationFunction;
i18n: I18nInstance;
}
/** React hook — returns `{ t, i18n }`. Optional `defaultNamespace` lets you
* drop the `ns:` prefix on every call.
ready: boolean;
};
/** React hook — `[t, i18n, ready]` and `{ t, i18n, ready }`. `namespaces` may
* be a single namespace or an ordered list (i18next resolves them in order).
*
* Every key this hook resolves during a render is recorded into the
* on-screen key registry (so a mounted `@sonenta/feedback` widget lists
* only the strings rendered on the current view — spec ltm 373). The
* contribution is keyed to THIS hook instance and dropped on unmount, so
* navigating away removes its keys automatically. */
declare function useTranslation(defaultNamespace?: string): UseTranslationResult;
* RESOLUTION GOES THROUGH THE SONENTA ENGINE, NOT through react-i18next's
* `t`. That is not an accident of history, it is measured: react-i18next's
* `t` resolves straight off the i18next instance, which does NOT apply the
* plain-language a11y overlay (`i18n.setPlainLanguage(true)` silently renders
* the standard text), and its `ready` reports TRUE while our bundles are
* still in flight (i18next has no backend here — WE are the backend, so it
* believes every namespace is loaded). A hook that delegated resolution to it
* would have compiled, passed a type-check, and shipped both regressions.
* See tests/dropin_gates.test.tsx.
*
* Every key this hook resolves during a render is recorded into the on-screen
* key registry (so a mounted `@sonenta/feedback` widget lists only the strings
* rendered on the current view). The contribution is keyed to THIS hook
* instance and dropped on unmount, so navigating away removes its keys
* automatically — and the resolution runs inside {@link suppressTracking}, so
* it does NOT also land in the cumulative instance-level token (which never
* unmounts). That double-feed was the bug that made both paid add-ons show the
* app's whole history instead of the current screen. */
declare function useTranslation(namespaces?: string | string[], options?: UseTranslationOptions): UseTranslationResult;
/**

@@ -551,21 +637,189 @@ * React hook — the languages PUBLISHED for the active version, enriched with

/**
* `<Trans>` — react-i18next's REAL Trans, resolving through the Sonenta engine.
*
* WHY NOT OUR OWN. Until 2.7.0 this was a 25-line reimplementation whose entire
* markup support was one regex, `/<(\d+)>(.*?)<\/\1>/g`. It could not do nested
* elements, self-closing tags (`<br/>`), `count` plurals, `tOptions`,
* `shouldUnescape`, or components keyed by tag name. Every one of those is
* ordinary react-i18next usage, so a customer running the README's
* find-and-replace swapped a working `<Trans>` for a silently WRONG one — the
* markup just vanished from the sentence. A drop-in that renders different
* output is not a drop-in.
*
* WHY NOT A BARE RE-EXPORT. react-i18next's `<Trans>` resolves its key through
* whatever `t` it can find, and by default that is `i18n.t` on the instance —
* i.e. OUTSIDE our hooks. Its keys would then land in the cumulative registry
* token, which never unmounts, so a 100%-migrated app would still leak the
* whole history into `@sonenta/feedback` and `@sonenta/in-context`. The
* reentrancy flag would be correctly wired and the symptom would persist, with
* `<Trans>` as the culprit.
*
* So: pass react-i18next OUR `t` (the one from our own hook — per-render
* registry + reentrancy flag + engine resolution, which is also what keeps the
* plain-language a11y overlay alive here) and let IT do the node-walking.
* VERIFIED AT THE BYTES across the whole declared peer range (react-i18next
* 13.0.0 / 14 / 15 / 17): `TransWithoutContext` honours the `t` prop
* (`const t = tFromProps || i18n.t.bind(i18n)`) and reads `t.ns` to pick the
* namespace — which is why our `t` carries `.ns`.
*/
/**
* OUR OWN PROPS TYPE — deliberately NOT `ComponentProps<typeof TransWithoutContext>`.
*
* Deriving it from react-i18next's component put a REAL TYPE-GRAPH EDGE into our
* public `.d.ts` (`import { TransWithoutContext } from 'react-i18next'`), which
* forces every consumer's `tsc` to compile react-i18next's declarations. That
* sounds harmless. It is not:
*
* react-i18next@15.7.4 declares `peerDependencies: { i18next: ">= 23.4.0" }`,
* but its `TransWithoutContext.d.ts` imports `ApplyTarget`, `ConstrainTarget`,
* `GetSource`, `SelectorFn` — types that only exist in i18next >= 25.
*
* So `i18next@24 + react-i18next@15` is a pair npm installs happily and whose
* TYPES DO NOT COMPILE. It is an upstream packaging bug. It became OUR bug the
* moment our `.d.ts` referenced theirs: a customer on that pair, importing only
* from `@sonenta/react-i18next`, had a GREEN build on 2.6.9 (whose public types
* never mention react-i18next) and a BROKEN one on 2.7.0 — through no fault of
* their own, in a range WE declare supported.
*
* A loose-but-true type of our own beats a precise type that will not compile.
* The runtime still delegates to the real `<Trans>`; only the DECLARATION is ours.
*/
interface TransProps {
/** The translation key (optionally `ns:key`). */
i18nKey: string;
/** Default value if the key is missing — used as the fallback string. */
i18nKey?: string;
/** Namespace(s) to resolve in. */
ns?: string | string[];
/** @deprecated Sonenta alias for `ns`. Kept so pre-2.7.0 call sites keep
* compiling; prefer `ns`. */
namespace?: string;
/** Fallback string when the key is missing. */
defaults?: string;
/** Variables interpolated into `{{var}}` placeholders. */
/** Interpolation values (`{{name}}`). */
values?: Record<string, unknown>;
/** JSX components mapped by 0-based numeric index — `<0>bold</0>` etc. */
components?: ReactNode[];
/** Optional namespace shortcut. */
namespace?: string;
/** JSX to splice into the sentence — indexed (`<0>`) or keyed by tag (`<b>`). */
components?: readonly ReactNode[] | Record<string, ReactNode>;
/** Plural selector. */
count?: number;
/** Context suffix (`key_male`). */
context?: string;
/** Extra options forwarded to `t`. */
tOptions?: Record<string, unknown>;
/** Unescape HTML entities in the resolved value. */
shouldUnescape?: boolean;
/** Wrapper element, or `null` for a fragment. */
parent?: unknown;
/** Resolve through THIS `t` — e.g. the one from `useTranslation(ns)`, so the
* sentence resolves in that hook's namespace. */
t?: (key: string, options?: never) => string;
children?: ReactNode;
}
/** Bare-bones Trans component: resolves the key, interpolates values, and
* swaps `<0>...</0>` placeholders into the supplied React components.
* Keeps the surface minimal — full Trans semantics (nested keys, plural
* trees, gender) land in V1.1. */
declare function Trans({ i18nKey, defaults, values, components, namespace, }: TransProps): react_jsx_runtime.JSX.Element;
declare function Trans({ namespace, ns, t: tFromProps, ...props }: TransProps): react_jsx_runtime.JSX.Element;
/**
* `withTranslation()` (HOC) and `<Translation>` (render prop) — the two
* class-component / non-hook entry points react-i18next ships. A migrated app
* that has ANY class component uses them, so a drop-in without them does not
* compile, and the find-and-replace stops at the first `withTranslation` import.
*
* They are built on OUR `useTranslation`, not re-exported from react-i18next.
* A bare re-export would resolve through react-i18next's own `t` — outside our
* hooks — so every key rendered by a class component would land in the
* cumulative registry token that never unmounts. The app would be 100% migrated
* by import path and STILL leak its whole history into the paid add-ons. The
* props they inject are the same three: `t`, `i18n`, `tReady`.
*/
interface WithTranslationProps {
t: A11yTranslationFunction;
i18n: I18nInstance;
tReady: boolean;
}
declare function withTranslation(namespaces?: string | string[], options?: UseTranslationOptions): <P extends WithTranslationProps>(Component: ComponentType<P>) => ComponentType<Omit<P, keyof WithTranslationProps>>;
interface TranslationProps {
ns?: string | string[];
keyPrefix?: string;
children: (t: A11yTranslationFunction, extra: {
i18n: I18nInstance;
lng: string;
}, ready: boolean) => ReactNode;
}
declare function Translation({ ns, keyPrefix, children }: TranslationProps): react_jsx_runtime.JSX.Element;
/**
* The react-i18next COMPATIBILITY BARREL — real components, OUR declarations.
*
* ─────────────────────────────────────────────────────────────────────────────
* WHY EVERY ONE OF THESE IS RE-TYPED BY HAND INSTEAD OF `export … from
* "react-i18next"`. Read this before "simplifying" it back.
*
* A re-export puts a REAL TYPE-GRAPH EDGE in our published `index.d.ts`:
*
* export { I18nextProvider, … } from 'react-i18next'; // <- the edge
*
* and that edge forces EVERY consumer's `tsc` to compile react-i18next's own
* declarations. Which would be fine, except:
*
* react-i18next@15.7.4 declares peer i18next ">= 23.4.0"
* but its TransWithoutContext.d.ts imports ApplyTarget, ConstrainTarget,
* GetSource, SelectorFn — types that exist only in i18next >= 25.
*
* So `i18next@24 + react-i18next@15` is a tree npm installs happily and whose
* TYPES DO NOT COMPILE. That is an upstream packaging bug, and it is not ours —
* until our `.d.ts` points at theirs. Then it is very much ours:
*
* - `@sonenta/react-i18next@2.6.9` public types NEVER mention react-i18next
* (checked: zero import/export edges in the published `index.d.ts`), so a
* customer on that pair, importing only from us, had a GREEN build.
* - The first cut of 2.7.0 added the edge. The SAME customer, upgrading a
* MINOR, got a broken typecheck inside a file they have never opened, in a
* peer pair WE declare supported. Our own consumer fixture caught it; my
* six-pair runtime matrix could not — it EXECUTES the app, and no runtime
* ever loads a `.d.ts`.
*
* So: the VALUES below are the real react-i18next ones (identical behaviour,
* nothing reimplemented), and the TYPES are ours — loose where we cannot be
* exact, but never wrong. A loose-but-true declaration that compiles on every
* pair we advertise beats a precise one that breaks a customer's build.
*
* If you need react-i18next's exact types, import them from `react-i18next`
* yourself: you will then own the peer-pair constraint, explicitly, in YOUR code.
* ─────────────────────────────────────────────────────────────────────────────
*/
/** react-i18next's provider. Prefer `<SonentaProvider>`; this exists so a
* migrated file that still imports it keeps compiling. */
declare const I18nextProvider: ComponentType<{
i18n: unknown;
defaultNS?: string | string[];
children?: ReactNode;
}>;
/** react-i18next's context object. */
declare const I18nContext: Context<{
i18n?: unknown;
defaultNS?: string | string[];
}>;
/** The context-free `<Trans>`. Our `<Trans>` already wraps it (and feeds it the
* Sonenta engine's `t`); this is the raw escape hatch. */
declare const TransWithoutContext: ComponentType<TransProps & {
i18n?: unknown;
}>;
/** The i18next plugin that binds an instance to react-i18next. The Sonenta
* engine already applies it to its own instance. */
declare const initReactI18next: {
type: "3rdParty";
init: (instance: unknown) => void;
};
/** Set / read react-i18next's global defaults (`react` options). */
declare const setDefaults: (options: Record<string, unknown>) => void;
declare const getDefaults: () => Record<string, unknown>;
/** SSR helpers (Next.js pages router lineage). */
declare const useSSR: (initialI18nStore: unknown, initialLanguage: string) => void;
declare const withSSR: () => <P extends object>(Component: ComponentType<P>) => ComponentType<P & {
initialI18nStore?: unknown;
initialLanguage?: string;
}>;
declare const composeInitialProps: (forComponent: unknown) => (ctx: unknown) => Promise<Record<string, unknown>>;
declare const getInitialProps: () => Record<string, unknown>;
/**
* Access the active i18n instance OUTSIDE React components — the

@@ -686,4 +940,9 @@ * react-i18next-style standalone singleton (e.g. for `t()`/`changeLanguage()`

}
/** Run `fn` with instance-level (cumulative) key tracking suppressed. */
declare function suppressTracking<T>(fn: () => T): T;
/** True while a `suppressTracking` frame is on the stack. */
declare function isTrackingSuppressed(): boolean;
declare class KeyRegistry {
private _instances;
private _cumulative;
private _providers;

@@ -699,3 +958,17 @@ /** Replace an instance's contributed key set (per-render hook producer). */

_delete(token: symbol): void;
/** Keys rendered by currently-mounted consumers. Stable insertion order. */
/** Keys rendered by currently-mounted consumers. Stable insertion order.
*
* A NAIVE UNION, ON PURPOSE. It is tempting, now that `_cumulative`
* knows the species of each token, to add a precedence rule — "if any
* per-render token exists, ignore the cumulative ones, they're stale".
* That rule causes a SILENT OUTAGE, and there is a test that proves it
* (`gate #4`): an app whose only Sonenta hook is a key-less `<I18nGate>`
* wrapper, with every real string still rendered through react-i18next,
* has a per-render token that is EMPTY and a cumulative token holding
* every key on screen. Precedence would return `[]` — a blank feedback
* panel on a working app. The union returns the keys.
*
* Over-reporting during a partial migration is handled by telling the
* developer (see {@link hasMixedImports}), NOT by silently dropping the
* only producer that knows anything. */
snapshot(): DeclaredKey[];

@@ -706,2 +979,14 @@ /** True when ANY producer has contributed ≥1 key. Cheap O(producers)

isPopulated(): boolean;
/** True when BOTH producers are contributing keys: at least one migrated
* component (per-render token) AND at least one key resolved outside our
* hooks (cumulative token). That is the signature of a HALF-MIGRATED app
* — some files import `@sonenta/react-i18next`, some still import
* `react-i18next` — and it is the only state in which the on-screen
* snapshot over-reports (the cumulative half never unmounts).
*
* It is NOT an error: it is the normal state of a migration in progress,
* and the union is still complete. The dev warning exists so the
* developer knows WHY their feedback panel lists more than the current
* screen, instead of filing it as a bug. */
hasMixedImports(): boolean;
/** Escape hatch (router integrations / tests). Mount-tracking already

@@ -720,2 +1005,2 @@ * handles navigation, so this is rarely needed. */

export { A11Y_SURFACES, A11Y_TREATMENTS_FOR, type A11ySurface, type A11yTranslationFunction, type AssetRef, type AvailableLanguage, type BaseRole, DEFAULT_SURFACE_BREAKPOINTS, type DeclaredKey, type I18nInstance, type KeyType, type LanguageMeta, type Locale, type MissingHandlerMode, type MissingKeyEvent, type Namespace, type SonentaConfig, type SonentaPlugin, type SonentaPluginContext, SonentaProvider, type Surface, type SurfaceBreakpoints, Trans, type TranslationFunction, type TranslationOptions, type Transport, type SonentaConfig as VerbumiaConfig, type SonentaPlugin as VerbumiaPlugin, type SonentaPluginContext as VerbumiaPluginContext, SonentaProvider as VerbumiaProvider, baseRoleFor, defaultTransport, getI18n, getI18nSafe, keyRegistry, logTransport, surfaceForWidth, t, treatmentsFor, useAvailableLanguages, useTranslation };
export { A11Y_SURFACES, A11Y_TREATMENTS_FOR, type A11ySurface, type A11yTranslationFunction, type AssetRef, type AvailableLanguage, type BaseRole, DEFAULT_SURFACE_BREAKPOINTS, type DeclaredKey, I18nContext, type I18nInstance, I18nextProvider, type KeyType, type LanguageMeta, type Locale, type MissingHandlerMode, type MissingKeyEvent, type Namespace, type SonentaConfig, type SonentaPlugin, type SonentaPluginContext, SonentaProvider, type Surface, type SurfaceBreakpoints, Trans, type TransProps, TransWithoutContext, Translation, type TranslationFunction, type TranslationOptions, type TranslationProps, type Transport, type UseTranslationOptions, type UseTranslationResult, type SonentaConfig as VerbumiaConfig, type SonentaPlugin as VerbumiaPlugin, type SonentaPluginContext as VerbumiaPluginContext, SonentaProvider as VerbumiaProvider, type WithTranslationProps, baseRoleFor, composeInitialProps, defaultTransport, getDefaults, getI18n, getI18nSafe, getInitialProps, initReactI18next, isTrackingSuppressed, keyRegistry, logTransport, setDefaults, suppressTracking, surfaceForWidth, t, treatmentsFor, useAvailableLanguages, useSSR, useTranslation, withSSR, withTranslation };
import * as react_jsx_runtime from 'react/jsx-runtime';
import { ReactNode } from 'react';
import { ReactNode, ComponentType, Context } from 'react';
import { i18n } from 'i18next';

@@ -225,2 +225,21 @@

/**
* The `fetch` the SDK uses for EVERY request — bundles, key-style, language
* catalog, published-languages manifest — for the whole life of the instance,
* not just the first load (a language change re-fetches with this same one).
*
* Use it to inject an authenticated fetch, a proxy, or a stub. It is the seam
* that makes the SDK testable OFFLINE, without monkey-patching
* `globalThis.fetch`:
*
* ```tsx
* <SonentaProvider fetchImpl={async (url) => new Response(JSON.stringify(bundles[url]))} …>
* ```
*
* Added in 2.7.0. `vue-i18n` / `svelte-i18n` / `next` have accepted it for
* months; on React the key was SILENTLY IGNORED — it typechecked, looked
* supported, and the SDK hit the network anyway. Omit it and the global
* `fetch` is resolved at call time, exactly as before.
*/
fetchImpl?: typeof fetch;
/**
* Optional override for missing-key delivery (in-app inspector,

@@ -480,2 +499,23 @@ * Storybook, Cypress mocks). When set, replaces the default POST.

i18next?: i18n;
/** Does the key resolve in the active language chain? */
exists: (key: string, options?: Record<string, unknown>) => boolean;
/** Subscribe to an i18next event (`languageChanged`, `loaded`, …). */
on: (event: string, listener: (...args: unknown[]) => void) => void;
/** Unsubscribe from an i18next event. */
off: (event: string, listener?: (...args: unknown[]) => void) => void;
/** i18next's fixed-`t` factory (bound language / namespace / keyPrefix). */
getFixedT: i18n["getFixedT"];
/** The language i18next actually resolved to (after the fallback chain). */
resolvedLanguage: string | undefined;
/** The active resolution chain, e.g. `["fr-CA", "fr", "en"]`. */
languages: readonly string[];
/** The live i18next options (keySeparator, nsSeparator, fallbackLng, …). */
options: i18n["options"];
/** Has i18next got the namespace in its store? */
hasLoadedNamespace: (ns: string | string[]) => boolean;
/** i18next's namespace loader. The Sonenta engine fetches bundles itself;
* this is here for source compatibility with a migrated call site. */
loadNamespaces: (ns: string | string[]) => Promise<void>;
/** Always true once the provider has constructed the engine. */
isInitialized: boolean;
}

@@ -517,15 +557,61 @@ type TranslationOptions = Record<string, unknown> & {

interface UseTranslationResult {
/**
* Options accepted by {@link useTranslation}, mirroring react-i18next's so a
* find-and-replace of the import path compiles unchanged.
*
* `useSuspense` / `bindI18n` / `i18n` are ACCEPTED AND IGNORED, deliberately:
* the Sonenta engine owns bundle loading and re-render (it is not an i18next
* backend), so it never suspends and never needs an event binding. Accepting
* them keeps a migrated call site compiling; silently ignoring them is honest
* because there is nothing for them to change here. `ready` tells the truth
* either way — see below.
*/
interface UseTranslationOptions {
/** Prefix every key with `keyPrefix + keySeparator` (react-i18next parity). */
keyPrefix?: string;
/** Accepted for source compatibility. The engine never suspends. */
useSuspense?: boolean;
/** Accepted for source compatibility. The provider owns the instance. */
i18n?: unknown;
/** Accepted for source compatibility. Re-render is driven by our store. */
bindI18n?: string | false;
}
/**
* The react-i18next return shape, exactly: an ARRAY `[t, i18n, ready]` that
* ALSO carries `.t` / `.i18n` / `.ready`. Both destructurings work —
*
* const [t] = useTranslation();
* const { t, i18n, ready } = useTranslation();
*
* — which is the whole point: the drop-in has to compile against 123 call
* sites written for react-i18next, and the tuple form is the one its own
* README teaches.
*/
type UseTranslationResult = [A11yTranslationFunction, I18nInstance, boolean] & {
t: A11yTranslationFunction;
i18n: I18nInstance;
}
/** React hook — returns `{ t, i18n }`. Optional `defaultNamespace` lets you
* drop the `ns:` prefix on every call.
ready: boolean;
};
/** React hook — `[t, i18n, ready]` and `{ t, i18n, ready }`. `namespaces` may
* be a single namespace or an ordered list (i18next resolves them in order).
*
* Every key this hook resolves during a render is recorded into the
* on-screen key registry (so a mounted `@sonenta/feedback` widget lists
* only the strings rendered on the current view — spec ltm 373). The
* contribution is keyed to THIS hook instance and dropped on unmount, so
* navigating away removes its keys automatically. */
declare function useTranslation(defaultNamespace?: string): UseTranslationResult;
* RESOLUTION GOES THROUGH THE SONENTA ENGINE, NOT through react-i18next's
* `t`. That is not an accident of history, it is measured: react-i18next's
* `t` resolves straight off the i18next instance, which does NOT apply the
* plain-language a11y overlay (`i18n.setPlainLanguage(true)` silently renders
* the standard text), and its `ready` reports TRUE while our bundles are
* still in flight (i18next has no backend here — WE are the backend, so it
* believes every namespace is loaded). A hook that delegated resolution to it
* would have compiled, passed a type-check, and shipped both regressions.
* See tests/dropin_gates.test.tsx.
*
* Every key this hook resolves during a render is recorded into the on-screen
* key registry (so a mounted `@sonenta/feedback` widget lists only the strings
* rendered on the current view). The contribution is keyed to THIS hook
* instance and dropped on unmount, so navigating away removes its keys
* automatically — and the resolution runs inside {@link suppressTracking}, so
* it does NOT also land in the cumulative instance-level token (which never
* unmounts). That double-feed was the bug that made both paid add-ons show the
* app's whole history instead of the current screen. */
declare function useTranslation(namespaces?: string | string[], options?: UseTranslationOptions): UseTranslationResult;
/**

@@ -551,21 +637,189 @@ * React hook — the languages PUBLISHED for the active version, enriched with

/**
* `<Trans>` — react-i18next's REAL Trans, resolving through the Sonenta engine.
*
* WHY NOT OUR OWN. Until 2.7.0 this was a 25-line reimplementation whose entire
* markup support was one regex, `/<(\d+)>(.*?)<\/\1>/g`. It could not do nested
* elements, self-closing tags (`<br/>`), `count` plurals, `tOptions`,
* `shouldUnescape`, or components keyed by tag name. Every one of those is
* ordinary react-i18next usage, so a customer running the README's
* find-and-replace swapped a working `<Trans>` for a silently WRONG one — the
* markup just vanished from the sentence. A drop-in that renders different
* output is not a drop-in.
*
* WHY NOT A BARE RE-EXPORT. react-i18next's `<Trans>` resolves its key through
* whatever `t` it can find, and by default that is `i18n.t` on the instance —
* i.e. OUTSIDE our hooks. Its keys would then land in the cumulative registry
* token, which never unmounts, so a 100%-migrated app would still leak the
* whole history into `@sonenta/feedback` and `@sonenta/in-context`. The
* reentrancy flag would be correctly wired and the symptom would persist, with
* `<Trans>` as the culprit.
*
* So: pass react-i18next OUR `t` (the one from our own hook — per-render
* registry + reentrancy flag + engine resolution, which is also what keeps the
* plain-language a11y overlay alive here) and let IT do the node-walking.
* VERIFIED AT THE BYTES across the whole declared peer range (react-i18next
* 13.0.0 / 14 / 15 / 17): `TransWithoutContext` honours the `t` prop
* (`const t = tFromProps || i18n.t.bind(i18n)`) and reads `t.ns` to pick the
* namespace — which is why our `t` carries `.ns`.
*/
/**
* OUR OWN PROPS TYPE — deliberately NOT `ComponentProps<typeof TransWithoutContext>`.
*
* Deriving it from react-i18next's component put a REAL TYPE-GRAPH EDGE into our
* public `.d.ts` (`import { TransWithoutContext } from 'react-i18next'`), which
* forces every consumer's `tsc` to compile react-i18next's declarations. That
* sounds harmless. It is not:
*
* react-i18next@15.7.4 declares `peerDependencies: { i18next: ">= 23.4.0" }`,
* but its `TransWithoutContext.d.ts` imports `ApplyTarget`, `ConstrainTarget`,
* `GetSource`, `SelectorFn` — types that only exist in i18next >= 25.
*
* So `i18next@24 + react-i18next@15` is a pair npm installs happily and whose
* TYPES DO NOT COMPILE. It is an upstream packaging bug. It became OUR bug the
* moment our `.d.ts` referenced theirs: a customer on that pair, importing only
* from `@sonenta/react-i18next`, had a GREEN build on 2.6.9 (whose public types
* never mention react-i18next) and a BROKEN one on 2.7.0 — through no fault of
* their own, in a range WE declare supported.
*
* A loose-but-true type of our own beats a precise type that will not compile.
* The runtime still delegates to the real `<Trans>`; only the DECLARATION is ours.
*/
interface TransProps {
/** The translation key (optionally `ns:key`). */
i18nKey: string;
/** Default value if the key is missing — used as the fallback string. */
i18nKey?: string;
/** Namespace(s) to resolve in. */
ns?: string | string[];
/** @deprecated Sonenta alias for `ns`. Kept so pre-2.7.0 call sites keep
* compiling; prefer `ns`. */
namespace?: string;
/** Fallback string when the key is missing. */
defaults?: string;
/** Variables interpolated into `{{var}}` placeholders. */
/** Interpolation values (`{{name}}`). */
values?: Record<string, unknown>;
/** JSX components mapped by 0-based numeric index — `<0>bold</0>` etc. */
components?: ReactNode[];
/** Optional namespace shortcut. */
namespace?: string;
/** JSX to splice into the sentence — indexed (`<0>`) or keyed by tag (`<b>`). */
components?: readonly ReactNode[] | Record<string, ReactNode>;
/** Plural selector. */
count?: number;
/** Context suffix (`key_male`). */
context?: string;
/** Extra options forwarded to `t`. */
tOptions?: Record<string, unknown>;
/** Unescape HTML entities in the resolved value. */
shouldUnescape?: boolean;
/** Wrapper element, or `null` for a fragment. */
parent?: unknown;
/** Resolve through THIS `t` — e.g. the one from `useTranslation(ns)`, so the
* sentence resolves in that hook's namespace. */
t?: (key: string, options?: never) => string;
children?: ReactNode;
}
/** Bare-bones Trans component: resolves the key, interpolates values, and
* swaps `<0>...</0>` placeholders into the supplied React components.
* Keeps the surface minimal — full Trans semantics (nested keys, plural
* trees, gender) land in V1.1. */
declare function Trans({ i18nKey, defaults, values, components, namespace, }: TransProps): react_jsx_runtime.JSX.Element;
declare function Trans({ namespace, ns, t: tFromProps, ...props }: TransProps): react_jsx_runtime.JSX.Element;
/**
* `withTranslation()` (HOC) and `<Translation>` (render prop) — the two
* class-component / non-hook entry points react-i18next ships. A migrated app
* that has ANY class component uses them, so a drop-in without them does not
* compile, and the find-and-replace stops at the first `withTranslation` import.
*
* They are built on OUR `useTranslation`, not re-exported from react-i18next.
* A bare re-export would resolve through react-i18next's own `t` — outside our
* hooks — so every key rendered by a class component would land in the
* cumulative registry token that never unmounts. The app would be 100% migrated
* by import path and STILL leak its whole history into the paid add-ons. The
* props they inject are the same three: `t`, `i18n`, `tReady`.
*/
interface WithTranslationProps {
t: A11yTranslationFunction;
i18n: I18nInstance;
tReady: boolean;
}
declare function withTranslation(namespaces?: string | string[], options?: UseTranslationOptions): <P extends WithTranslationProps>(Component: ComponentType<P>) => ComponentType<Omit<P, keyof WithTranslationProps>>;
interface TranslationProps {
ns?: string | string[];
keyPrefix?: string;
children: (t: A11yTranslationFunction, extra: {
i18n: I18nInstance;
lng: string;
}, ready: boolean) => ReactNode;
}
declare function Translation({ ns, keyPrefix, children }: TranslationProps): react_jsx_runtime.JSX.Element;
/**
* The react-i18next COMPATIBILITY BARREL — real components, OUR declarations.
*
* ─────────────────────────────────────────────────────────────────────────────
* WHY EVERY ONE OF THESE IS RE-TYPED BY HAND INSTEAD OF `export … from
* "react-i18next"`. Read this before "simplifying" it back.
*
* A re-export puts a REAL TYPE-GRAPH EDGE in our published `index.d.ts`:
*
* export { I18nextProvider, … } from 'react-i18next'; // <- the edge
*
* and that edge forces EVERY consumer's `tsc` to compile react-i18next's own
* declarations. Which would be fine, except:
*
* react-i18next@15.7.4 declares peer i18next ">= 23.4.0"
* but its TransWithoutContext.d.ts imports ApplyTarget, ConstrainTarget,
* GetSource, SelectorFn — types that exist only in i18next >= 25.
*
* So `i18next@24 + react-i18next@15` is a tree npm installs happily and whose
* TYPES DO NOT COMPILE. That is an upstream packaging bug, and it is not ours —
* until our `.d.ts` points at theirs. Then it is very much ours:
*
* - `@sonenta/react-i18next@2.6.9` public types NEVER mention react-i18next
* (checked: zero import/export edges in the published `index.d.ts`), so a
* customer on that pair, importing only from us, had a GREEN build.
* - The first cut of 2.7.0 added the edge. The SAME customer, upgrading a
* MINOR, got a broken typecheck inside a file they have never opened, in a
* peer pair WE declare supported. Our own consumer fixture caught it; my
* six-pair runtime matrix could not — it EXECUTES the app, and no runtime
* ever loads a `.d.ts`.
*
* So: the VALUES below are the real react-i18next ones (identical behaviour,
* nothing reimplemented), and the TYPES are ours — loose where we cannot be
* exact, but never wrong. A loose-but-true declaration that compiles on every
* pair we advertise beats a precise one that breaks a customer's build.
*
* If you need react-i18next's exact types, import them from `react-i18next`
* yourself: you will then own the peer-pair constraint, explicitly, in YOUR code.
* ─────────────────────────────────────────────────────────────────────────────
*/
/** react-i18next's provider. Prefer `<SonentaProvider>`; this exists so a
* migrated file that still imports it keeps compiling. */
declare const I18nextProvider: ComponentType<{
i18n: unknown;
defaultNS?: string | string[];
children?: ReactNode;
}>;
/** react-i18next's context object. */
declare const I18nContext: Context<{
i18n?: unknown;
defaultNS?: string | string[];
}>;
/** The context-free `<Trans>`. Our `<Trans>` already wraps it (and feeds it the
* Sonenta engine's `t`); this is the raw escape hatch. */
declare const TransWithoutContext: ComponentType<TransProps & {
i18n?: unknown;
}>;
/** The i18next plugin that binds an instance to react-i18next. The Sonenta
* engine already applies it to its own instance. */
declare const initReactI18next: {
type: "3rdParty";
init: (instance: unknown) => void;
};
/** Set / read react-i18next's global defaults (`react` options). */
declare const setDefaults: (options: Record<string, unknown>) => void;
declare const getDefaults: () => Record<string, unknown>;
/** SSR helpers (Next.js pages router lineage). */
declare const useSSR: (initialI18nStore: unknown, initialLanguage: string) => void;
declare const withSSR: () => <P extends object>(Component: ComponentType<P>) => ComponentType<P & {
initialI18nStore?: unknown;
initialLanguage?: string;
}>;
declare const composeInitialProps: (forComponent: unknown) => (ctx: unknown) => Promise<Record<string, unknown>>;
declare const getInitialProps: () => Record<string, unknown>;
/**
* Access the active i18n instance OUTSIDE React components — the

@@ -686,4 +940,9 @@ * react-i18next-style standalone singleton (e.g. for `t()`/`changeLanguage()`

}
/** Run `fn` with instance-level (cumulative) key tracking suppressed. */
declare function suppressTracking<T>(fn: () => T): T;
/** True while a `suppressTracking` frame is on the stack. */
declare function isTrackingSuppressed(): boolean;
declare class KeyRegistry {
private _instances;
private _cumulative;
private _providers;

@@ -699,3 +958,17 @@ /** Replace an instance's contributed key set (per-render hook producer). */

_delete(token: symbol): void;
/** Keys rendered by currently-mounted consumers. Stable insertion order. */
/** Keys rendered by currently-mounted consumers. Stable insertion order.
*
* A NAIVE UNION, ON PURPOSE. It is tempting, now that `_cumulative`
* knows the species of each token, to add a precedence rule — "if any
* per-render token exists, ignore the cumulative ones, they're stale".
* That rule causes a SILENT OUTAGE, and there is a test that proves it
* (`gate #4`): an app whose only Sonenta hook is a key-less `<I18nGate>`
* wrapper, with every real string still rendered through react-i18next,
* has a per-render token that is EMPTY and a cumulative token holding
* every key on screen. Precedence would return `[]` — a blank feedback
* panel on a working app. The union returns the keys.
*
* Over-reporting during a partial migration is handled by telling the
* developer (see {@link hasMixedImports}), NOT by silently dropping the
* only producer that knows anything. */
snapshot(): DeclaredKey[];

@@ -706,2 +979,14 @@ /** True when ANY producer has contributed ≥1 key. Cheap O(producers)

isPopulated(): boolean;
/** True when BOTH producers are contributing keys: at least one migrated
* component (per-render token) AND at least one key resolved outside our
* hooks (cumulative token). That is the signature of a HALF-MIGRATED app
* — some files import `@sonenta/react-i18next`, some still import
* `react-i18next` — and it is the only state in which the on-screen
* snapshot over-reports (the cumulative half never unmounts).
*
* It is NOT an error: it is the normal state of a migration in progress,
* and the union is still complete. The dev warning exists so the
* developer knows WHY their feedback panel lists more than the current
* screen, instead of filing it as a bug. */
hasMixedImports(): boolean;
/** Escape hatch (router integrations / tests). Mount-tracking already

@@ -720,2 +1005,2 @@ * handles navigation, so this is rarely needed. */

export { A11Y_SURFACES, A11Y_TREATMENTS_FOR, type A11ySurface, type A11yTranslationFunction, type AssetRef, type AvailableLanguage, type BaseRole, DEFAULT_SURFACE_BREAKPOINTS, type DeclaredKey, type I18nInstance, type KeyType, type LanguageMeta, type Locale, type MissingHandlerMode, type MissingKeyEvent, type Namespace, type SonentaConfig, type SonentaPlugin, type SonentaPluginContext, SonentaProvider, type Surface, type SurfaceBreakpoints, Trans, type TranslationFunction, type TranslationOptions, type Transport, type SonentaConfig as VerbumiaConfig, type SonentaPlugin as VerbumiaPlugin, type SonentaPluginContext as VerbumiaPluginContext, SonentaProvider as VerbumiaProvider, baseRoleFor, defaultTransport, getI18n, getI18nSafe, keyRegistry, logTransport, surfaceForWidth, t, treatmentsFor, useAvailableLanguages, useTranslation };
export { A11Y_SURFACES, A11Y_TREATMENTS_FOR, type A11ySurface, type A11yTranslationFunction, type AssetRef, type AvailableLanguage, type BaseRole, DEFAULT_SURFACE_BREAKPOINTS, type DeclaredKey, I18nContext, type I18nInstance, I18nextProvider, type KeyType, type LanguageMeta, type Locale, type MissingHandlerMode, type MissingKeyEvent, type Namespace, type SonentaConfig, type SonentaPlugin, type SonentaPluginContext, SonentaProvider, type Surface, type SurfaceBreakpoints, Trans, type TransProps, TransWithoutContext, Translation, type TranslationFunction, type TranslationOptions, type TranslationProps, type Transport, type UseTranslationOptions, type UseTranslationResult, type SonentaConfig as VerbumiaConfig, type SonentaPlugin as VerbumiaPlugin, type SonentaPluginContext as VerbumiaPluginContext, SonentaProvider as VerbumiaProvider, type WithTranslationProps, baseRoleFor, composeInitialProps, defaultTransport, getDefaults, getI18n, getI18nSafe, getInitialProps, initReactI18next, isTrackingSuppressed, keyRegistry, logTransport, setDefaults, suppressTracking, surfaceForWidth, t, treatmentsFor, useAvailableLanguages, useSSR, useTranslation, withSSR, withTranslation };
+1
-1
{
"name": "@sonenta/react-i18next",
"version": "2.6.9",
"version": "2.7.0",
"description": "React SDK for Sonenta — translations + realtime missing-key handler.",

@@ -5,0 +5,0 @@ "license": "MIT",

+72
-38

@@ -108,2 +108,3 @@ # @sonenta/react-i18next

plugins?: SonentaPlugin[]; // e.g. @sonenta/feedback, @sonenta/realtime
fetchImpl?: typeof fetch; // 2.7.0 — the fetch used for EVERY request, for the instance's life
transport?: (batch: MissingKeyEvent[]) => void | Promise<void>;

@@ -117,2 +118,15 @@ missingHandler?: 'send' | 'log' | 'off'; // default 'send'

**`fetchImpl` (2.7.0)** — inject an authenticated fetch, a proxy, or a stub. It serves every
request for the whole life of the instance (bundles, key-style, catalog, manifest), **including
after a language change**. It is also the seam that lets you test the SDK **offline**, without
monkey-patching `globalThis.fetch`:
```tsx
<SonentaProvider fetchImpl={async (url) => new Response(JSON.stringify(bundles[String(url)]))} …>
```
> Before 2.7.0 this key was **silently ignored on React** — it typechecked, it looked supported,
> and the SDK hit the network anyway. `vue-i18n` / `svelte-i18n` / `next` had honoured it all
> along.
`version` selects which published version's bundles to load

@@ -131,6 +145,21 @@ (`/p/<project>/<version>/latest/...`); it defaults to `'main'` and is part

### `useTranslation(defaultNamespace?)`
### `useTranslation(namespaces?, { keyPrefix? })`
Returns `{ t, i18n }`.
Returns react-i18next's exact shape — the **tuple** `[t, i18n, ready]`, which also carries
`.t` / `.i18n` / `.ready`. Both destructurings work, so a migrated call site compiles unchanged:
```tsx
const [t, i18n, ready] = useTranslation("common"); // tuple form
const { t, i18n, ready } = useTranslation("common"); // object form
const { t } = useTranslation(["checkout", "common"]); // ns list, resolved in order
const { t } = useTranslation("common", { keyPrefix: "cart" }); // t("total") -> cart.total
```
`ready` is **ours**, and it tells the truth: it flips when the CDN bundles are in. (react-i18next's
own `ready` would report `true` from the first render here — i18next has no backend in this SDK,
*we* fetch the bundles, so it believes every namespace is already loaded.)
`useSuspense`, `bindI18n` and `i18n` are accepted and ignored — this engine owns loading and never
suspends.
```ts

@@ -216,3 +245,6 @@ // Two call shapes — the native object form AND the react-i18next-style

Inline translation with JSX slots:
**Since 2.7.0 this IS react-i18next's `<Trans>`** — we hand it our `t`, so it resolves through the
Sonenta engine (registry, a11y overlays) while doing its own node-walking. That means nesting,
`count` plurals, `tOptions`, `shouldUnescape`, self-closing tags and components keyed by tag name
all work, and a `<Trans>` copied from a react-i18next codebase renders identically.

@@ -225,7 +257,12 @@ ```tsx

/>
// …and everything react-i18next's Trans can do:
<Trans i18nKey="inbox" count={n} values={{ name }} components={{ b: <b />, br: <br /> }} />
<Trans t={t} i18nKey="title" /> {/* resolves in the enclosing hook's namespace */}
```
The `<0>...</0>` slots are 0-indexed into `components`. The bundle string
should follow the same shape so that translators see `I accept the <0>terms</0>...`
and the SDK swaps the elements at render time.
> Before 2.7.0 `<Trans>` was our own 25-line reimplementation whose entire markup support was one
> regex. It could not nest, could not do `count` plurals, and dropped `<br/>`. If you migrated from
> react-i18next before 2.7.0, your `<Trans>` sentences may have been rendering **differently** —
> upgrading restores them.

@@ -261,34 +298,26 @@ ---

### Not yet supported (planned for V1.1)
### Plurals and context
Plurals and context are **not** resolved yet: `t('key', { count })` performs
interpolation only — it does **not** select plural keys (`key_one` /
`key_other`) or context keys (`key_male`). Handle these manually until V1.1.
Resolved by i18next: `t('key', { count })` selects the CLDR plural form (`key_one` / `key_other` /
…) and context keys (`key_male`) work. Exact numeric forms (`key_0`, `key_1`) win over the category
form when the bundle carries them.
### Drop-in imports + `@sonenta/feedback` (1.0.3+)
### Drop-in imports + the on-screen key registry (2.7.0)
The drop-in path lets hosts keep their `from 'react-i18next'` imports
verbatim — the shared i18next instance resolves them correctly. From
**1.0.3** onwards, those native calls also feed the on-screen key registry
that `@sonenta/feedback` reads, so the widget lists the strings rendered
on the current view in either import shape:
`@sonenta/feedback` and `@sonenta/in-context` show **the strings on the current screen**. They read
an on-screen key registry that this SDK fills, and it has two producers:
```ts
// works (sonenta hook — strict per-view drop semantics)
import { useTranslation } from "@sonenta/react-i18next";
| your import | producer | drops on unmount? |
|---|---|---|
| `from "@sonenta/react-i18next"` | per-render (our hook) | **yes** — leaves with the component |
| `from "react-i18next"` | cumulative (the `i18next.t` patch) | **no** — accumulates for the app's lifetime |
// also works (native hook — registry fed via the instance-level wrap)
import { useTranslation } from "react-i18next";
```
Both are fed, and `snapshot()` is their **union**, so the add-ons are never empty on a populated
screen. But the cumulative half has no unmount signal: on a half-migrated app the panel lists keys
from screens you have already left. **Finish the migration and the panel is exact.**
The trade-off for the native path: the registry **accumulates** for the
i18n instance's lifetime (no per-component unmount signal to drop from),
so after several view changes the widget may list a few extra keys from
prior views. A strictly empty widget on a populated view is no longer
possible. Hosts that want strict per-view scoping can either:
> **This is the bug 2.7.0 fixes.** Until 2.7.0 *our own hook fed both producers*, so the cumulative
> superset always won and the add-ons showed the app's entire history **no matter how completely you
> had migrated**. If your feedback panel lists strings from screens you left, upgrade to `>= 2.7.0`.
- adopt `@sonenta/react-i18next`'s `useTranslation` (mount-tracked, drops
on unmount), via the codemod below;
- or call `keyRegistry.reset()` from their router on navigation.
**Codemod** — switch all imports in one shot (re-run as needed):

@@ -301,16 +330,21 @@

**Dev-time assertion** — confirm wiring at boot:
Everything react-i18next exports that a migrated file imports — `useTranslation`, `Trans`,
`withTranslation`, `Translation`, `I18nextProvider`, `I18nContext`, `TransWithoutContext`,
`useSSR`, `withSSR`, `initReactI18next`, `setDefaults`, `getDefaults` — is exported here, so the
find-and-replace compiles. (`getI18n` is **ours**: it returns the Sonenta engine. Reach the raw
i18next instance via `getI18n().i18next`.)
**Dev-time check** — is the migration actually finished?
```ts
import { keyRegistry } from "@sonenta/react-i18next";
if (__DEV__ && !keyRegistry.isPopulated()) {
console.warn(
"@sonenta: no on-screen keys yet — render a screen with t() first, " +
"or check that your useTranslation imports resolve to @sonenta/react-i18next " +
"(or to the native react-i18next bound to the exposed i18next instance).",
);
if (__DEV__ && keyRegistry.hasMixedImports()) {
console.warn("some components still import react-i18next directly — the on-screen key list will over-report");
}
```
`hasMixedImports()` is true only when BOTH producers hold keys — i.e. a migration in progress. The
SDK already logs this once in dev. (The old `isPopulated()` answered "is anything wired?", which
said *yes* on exactly the broken app it was meant to catch.)
---

@@ -317,0 +351,0 @@

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display