diff --git a/.changeset/notready-t-interpolates.md b/.changeset/notready-t-interpolates.md new file mode 100644 index 0000000000..5c806e4ec1 --- /dev/null +++ b/.changeset/notready-t-interpolates.md @@ -0,0 +1,20 @@ +--- +'@object-ui/i18n': patch +--- + +fix(i18n): `useObjectTranslation`'s provider-less `t` now interpolates its inline `defaultValue` + +With no `I18nProvider` mounted, react-i18next hands back its not-ready `t`, which +returns `options.defaultValue` **verbatim** — so an inline default written +`'Deleted {{count}} rows'` reached the user with the braces intact. 68 inline +defaults across 24 files rendered through that path on any host that embeds an +ObjectUI component without a provider, which is the configuration +`createSafeTranslation` exists for. + +`useObjectTranslation` now runs its not-ready result through the same one +interpolator `createSafeTranslation`'s `fallbackT` uses, so both provider-less +renderers fill exactly the `{{name}}` spelling the copy is already gated to. The +ready path is untouched: with a provider, i18next's own `t` is returned by +reference and nothing is interpolated twice. Pre-interpolated template-literal +defaults (`` `Deleted ${n} rows` ``) stay correct — they have no holes left to +fill — so no call site changes. diff --git a/packages/i18n/src/__tests__/fallback-placeholder-spelling-3512.test.ts b/packages/i18n/src/__tests__/fallback-placeholder-spelling-3512.test.ts index 3e1081aabe..6c2c858947 100644 --- a/packages/i18n/src/__tests__/fallback-placeholder-spelling-3512.test.ts +++ b/packages/i18n/src/__tests__/fallback-placeholder-spelling-3512.test.ts @@ -457,9 +457,17 @@ const HAND_ROLLED_TABLES: readonly { readonly file: string; readonly name: strin }, ]; -/** Files that carry the literal needle today — the completeness case's subject. */ +/** + * Files that carry the literal needle today — the completeness case's subject. + * + * The canonical copy moved out of `useSafeTranslation.ts` into + * `fallbackInterpolation.ts` in objectui#6219, so that `useObjectTranslation`'s + * not-ready path could run the SAME interpolator rather than grow a fifth. The + * set is the same size for that reason: this registry counts copies of the + * grammar, and that change moved one rather than adding one. + */ const NEEDLE_FILES = [ - 'packages/i18n/src/useSafeTranslation.ts', + 'packages/i18n/src/fallbackInterpolation.ts', 'packages/plugin-gantt/src/useGanttTranslation.ts', 'packages/plugin-grid/src/ImportWizard.tsx', 'packages/plugin-timeline/src/useTimelineTranslation.ts', diff --git a/packages/i18n/src/__tests__/notready-interpolation-6219.test.tsx b/packages/i18n/src/__tests__/notready-interpolation-6219.test.tsx new file mode 100644 index 0000000000..8f1c0422ef --- /dev/null +++ b/packages/i18n/src/__tests__/notready-interpolation-6219.test.tsx @@ -0,0 +1,238 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * A bare `useObjectTranslation()` fills its inline default's `{{holes}}` even + * with no `I18nProvider` mounted — objectui#6219. + * + * ## What this pins, and why it has to render + * + * react-i18next's not-ready `t` (`notReadyT`, used when no i18next instance is + * initialised) returns `options.defaultValue` VERBATIM. So an inline default + * written `'Deleted {{count}} rows'` used to reach the user with the braces + * intact — 68 of them across 24 files, on any host that embeds an ObjectUI + * component without a provider, which is the configuration + * `createSafeTranslation` exists for (objectui#3865). + * + * ⚠️ **A test that mounts `I18nProvider` can never see that defect.** With a + * provider, i18next is ready, the pack value wins and the inline default never + * renders at all — such an assertion is true before and after the fix. Every + * case below that is doing the actual work therefore renders PROVIDER-LESS and + * reads `textContent` off the DOM, rather than inspecting the return value of a + * `t` this file called itself. + * + * ## Which cases would still pass on a revert, and why they are here + * + * Stated rather than left to be rediscovered: + * + * - `the positive control` — by construction. It proves the probe really is + * on the not-ready path (no global instance, raw key for an unknown key). + * Green either way is what makes it a control. + * - `a pre-interpolated template literal is left exactly as written` — green + * either way, deliberately. It is the ⭐ corollary from objectui#4905: at a + * bare `useObjectTranslation()` the template-literal form is the CORRECT + * spelling, and this fix must not have made it wrong. Its job is to fail if + * someone later "tidies" these defaults into `{{hole}}` form or teaches the + * interpolator to touch text with no holes. + * - `an i18next-only spelling is still not resolved here` — green either way. + * objectui#3512 ruled against teaching the fallback i18next's other + * dialects; this fix widens which BINDINGS interpolate, never which + * SPELLINGS resolve, and this case is what holds that line. + * - `with a provider mounted, the pack value still wins` — green either way. + * A must-not-break, and the structural half of "never interpolate twice". + * + * Everything under `THE PIN` fails on a revert. + */ + +import { describe, it, expect } from 'vitest'; +import React, { useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import { getI18n, useTranslation } from 'react-i18next'; +import { I18nProvider, useObjectTranslation } from '../index'; + +/** A key no locale pack defines, so the inline default is what renders. */ +const MISSING = 'probe.no.such.key.6219'; + +function Probe({ + options, + tKey = MISSING, +}: { + options?: Record; + tKey?: string; +}) { + const { t } = useObjectTranslation(); + // `as string`: with an options object react-i18next's overload widens to + // `string | TFunctionDetailedResult`, and the detailed shape only appears + // under `returnObjects`/`returnDetails`, which nothing here passes. Casting + // at the probe keeps the assertions about text rather than about types. + const value = (options === undefined ? t(tKey) : t(tKey, options as never)) as string; + return {value}; +} + +const out = () => screen.getByTestId('out').textContent; + +describe('the not-ready `t` interpolates its inline default (objectui#6219)', () => { + it('the positive control: this really is the provider-less, no-instance path', () => { + // Green before and after the fix — that is the point. Without it every + // assertion below could be passing through a leaked global instance from + // some earlier file, which is exactly the leak `vitest.setup.i18n-global.ts` + // exists to prevent (objectui#4514). + expect(getI18n()).toBeUndefined(); + render(); + // No instance, no pack: `notReadyT` answers with the key itself. + expect(out()).toBe(MISSING); + }); + + it('THE PIN: a {{hole}} in an inline default is filled, not printed', () => { + render(); + expect(out()).toBe('Deleted 3 rows'); + expect(out()).not.toContain('{{'); + }); + + it('THE PIN: every occurrence, not just the first (objectui#3418 parity)', () => { + render( + , + ); + expect(out()).toBe('Selected 7 of 7 items'); + }); + + it('THE PIN: `$` sequences in the DATA are literal, not replacement syntax', () => { + // `split/join` rather than `replace`/`replaceAll`, so `$&` in a runtime + // value (a record label, a search term) cannot re-expand. objectui#3418's + // second finding, now reachable on this binding too. + render(); + expect(out()).toBe('Searching for $& $` cost'); + }); + + it('THE PIN: a real-shaped console default renders as prose', () => { + // The exact shape measured on this tree — `apps/console/.../AppManagementPage.tsx` + // and `packages/app-shell/src/layout/InboxPopover.tsx` write defaults like + // this at a bare `useObjectTranslation()`. + render( + , + ); + expect(out()).toBe('Signed in as ada@example.com'); + }); + + it('`defaultValue` is a lookup control, never interpolation data (objectui#3865)', () => { + // Same reserved-name rule `createSafeTranslation`'s `fallbackT` follows, + // because it is now literally the same function. + render(); + expect(out()).toBe('x {{defaultValue}} y'); + }); + + it('a pre-interpolated template literal is left exactly as written (⭐ objectui#4905)', () => { + // WOULD STILL PASS ON A REVERT — deliberately. At a bare + // `useObjectTranslation()` this is the CORRECT spelling, not residue, and + // the whole point of fixing the seam instead of the 68 call sites is that + // it stays correct. Fails if anyone rewrites these into `{{hole}}` form + // without the holes' data, or teaches the interpolator to touch text that + // has none. + const n = 3; + render(); + expect(out()).toBe('Deleted 3 rows'); + }); + + it('an i18next-only spelling is still not resolved here (objectui#3512 held)', () => { + // WOULD STILL PASS ON A REVERT. #3512 ruled deliberately AGAINST teaching + // the provider-less fallback i18next's other three dialects, and gated the + // copy to `{{name}}` instead. This fix widens which bindings interpolate, + // never which spellings resolve; this case is the line. + render(); + expect(out()).toBe('Total {{ count }}'); + }); + + it('with no options there is nothing to fill from', () => { + render(); + expect(out()).toBe('Deleted {{count}} rows'); + }); +}); + +describe('the provider path is untouched (objectui#6219 must-not-break)', () => { + it('with a provider mounted, the pack value still wins and interpolates', () => { + // WOULD STILL PASS ON A REVERT. Held because the fix must not reach the + // ready path at all. + render( + + + , + ); + expect(out()).toContain('Contacts'); + expect(out()).not.toContain('IGNORED'); + expect(out()).not.toContain('{{'); + }); + + it('with a provider, a `{{hole}}` that came from the DATA is not re-expanded', () => { + // WOULD STILL PASS ON A REVERT, and it is the behavioural pin for "never + // interpolate twice": the fix must not reach the ready path at all. + // + // Measured on this tree — i18next renders + // `t('form.createTitle', { object: '{{leak}}', leak: 'BOOM' })` as + // `'Create {{leak}}'`: it substitutes from the data ONCE and does not + // re-scan its own output. A second pass by this package's interpolator + // would then see that `{{leak}}` and turn it into `'BOOM'` — user data + // reinterpreted as copy syntax. So this asserts on the brace surviving, + // which is exactly the property that a wrapped ready path would destroy. + render( + + + , + ); + expect(out()).toBe('Create {{leak}}'); + expect(out()).not.toContain('BOOM'); + }); +}); + +describe('the wrapper adds no identity churn of its own (objectui#6219)', () => { + it('THE PIN: provider-less, `t` moves exactly when react-i18next’s own `t` moves', () => { + // Call sites put `t` in `useMemo`/`useCallback` dependency arrays, so a + // wrapper minted fresh on every render would invalidate every one of them + // on every render — a real regression no output assertion above would + // catch, because the wrapper would still return the right string. + // + // The bound is RELATIVE rather than absolute, and that is a measurement, + // not a hedge. react-i18next's not-ready `t` is itself only sometimes + // stable: `getSnapshot` returns the module-level `notReadySnapshot`, but + // `useTranslation`'s final `useMemo` then wraps it in a fresh warn-once + // arrow whenever `!ready && !useSuspense`. `createI18n` sets + // `react: { useSuspense: false }` (packages/i18n/src/i18n.ts) and + // `initReactI18next` writes that into react-i18next's MODULE-LEVEL + // defaults, which `vitest.setup.i18n-global.ts` does not reset — it + // restores the instance pointer, which is a different piece of state. So + // whether `t` is referentially stable provider-less depends on whether a + // provider was ever built in this process, and pinning "always stable" + // would pin the test file's ordering rather than this package's behaviour. + // + // What IS this package's behaviour, in both regimes: the wrapper is + // memoized on the `t` it wraps, so it moves when that moves and not + // otherwise. Both halves are asserted, so the case cannot go vacuous by + // sliding into "everything churns". + function StabilityProbe() { + const { t: ours } = useObjectTranslation(); + const { t: raw } = useTranslation(); + // `useState`'s lazy initializer, not a ref: it runs on the first render + // only, so this records what the first render saw without writing during + // later ones — and unlike `ref.current` it is a value React expects to be + // read while rendering (`react-hooks/refs` flags the ref spelling). + const [first] = useState(() => ({ ours, raw })); + const sameOurs = ours === first.ours; + const sameRaw = raw === first.raw; + return {`${sameRaw ? 'raw-same' : 'raw-moved'}/${sameOurs ? 'ours-same' : 'ours-moved'}`}; + } + const { rerender } = render(); + rerender(); + rerender(); + expect(out()).toMatch(/^(raw-same\/ours-same|raw-moved\/ours-moved)$/); + }); +}); diff --git a/packages/i18n/src/fallbackInterpolation.ts b/packages/i18n/src/fallbackInterpolation.ts new file mode 100644 index 0000000000..5b490ef0ce --- /dev/null +++ b/packages/i18n/src/fallbackInterpolation.ts @@ -0,0 +1,126 @@ +/** + * The ONE interpolator every provider-less translation path in this package + * runs — extracted so there is exactly one of it (objectui#6219). + * + * ## Why this module exists at all + * + * Three things can render a translation in this repo, and each has its own + * answer to "does a `{{hole}}` get filled?": + * + * 1. **i18next, with an `I18nProvider` mounted.** It interpolates. This is + * the reference behaviour every other path has to agree with. + * 2. **`createSafeTranslation`'s `fallbackT`** (`useSafeTranslation.ts`). + * It interpolates — with the exact literal needle below. + * 3. **react-i18next's not-ready `t`**, which is what a bare + * `useObjectTranslation()` yields when no i18next instance is initialised. + * Measured in `react-i18next@17.0.11/dist/es/useTranslation.js`: + * `notReadyT` returns `options.defaultValue` VERBATIM and does not + * interpolate at all. + * + * (3) was objectui#6219: 68 inline defaults across 24 files carried a + * `{{hole}}` that reached the user as literal braces on any host that embeds an + * ObjectUI component without `I18nProvider` — the configuration + * `createSafeTranslation` exists for (objectui#3865), so a supported one. + * `useObjectTranslation` now runs its not-ready result through this function, + * which makes (3) agree with (2), and (2) already agrees with (1) for the one + * spelling this repo is allowed to author. + * + * ## Why one shared function rather than a second copy + * + * The semantics below are pinned in three places at once — + * `useSafeTranslation.test.tsx` (behaviour), `fallback-placeholder-spelling-3512.test.ts` + * (the copy is held to what this can resolve) and + * `scripts/check-i18n-call-site-keys.mjs` class 7 (the same rule over inline + * defaults). A second interpolator with its own drift would have made all three + * of those true of one path and quietly false of the other. There is one + * function, so "the fallback resolves only `{{name}}`" is one fact. + * + * ## The exact spelling, and why only that one + * + * objectui#3512 measured that i18next additionally accepts `{{ name }}`, + * `{{count, number}}`, `{{- name}}` and `$t(key)`, and the ruling was + * deliberately NOT to teach the fallback three more dialects (a second + * interpolator to keep in step with i18next forever) but to hold the copy to + * the one spelling both paths agree on — enforced by the two gates named above. + * This module keeps that ruling: it widens WHICH BINDINGS interpolate, never + * WHICH SPELLINGS resolve. + */ + +/** + * The i18next option that names the string to use when the lookup misses. + * + * It is a LOOKUP CONTROL, not interpolation data (objectui#3865): it chooses + * which string is rendered, so it must never also be spliced into a + * `{{defaultValue}}` hole in the string it chose — or in any other. + */ +export const DEFAULT_VALUE_OPTION = 'defaultValue'; + +/** + * Fill `{{name}}` holes in `value` from `options`, i18next-compatibly. + * + * Returns `value` untouched when there is nothing to fill from, so a caller can + * hand it every result unconditionally. + * + * @param value - The string that was chosen for rendering. + * @param options - The `t()` call's options object, or `undefined`. + */ +export function interpolateFallback( + value: string, + options?: Record, +): string { + if (!options) return value; + let out = value; + for (const [k, v] of Object.entries(options)) { + // Reserved: see DEFAULT_VALUE_OPTION. Skipped whatever its type, so an + // ignored non-string default cannot re-enter through this loop. + // + // This is the one deliberate divergence from i18next on this path, and it + // is unreachable with today's strings: i18next passes the whole options + // object to its interpolator, so it WOULD render + // `'Fallback: {{defaultValue}}'` as `'Fallback: INLINE'` (measured on + // 26.3.6) — but no value in this repo spells that hole (zero hits for + // `{{defaultValue` across packages/apps/examples), and splicing a fallback + // string into a hole named after itself has no sensible reading. The + // alternative — letting a call site's fallback text leak into an unrelated + // table value — is the worse of the two. + if (k === DEFAULT_VALUE_OPTION) continue; + // `split(needle).join(value)` — deliberately not `replace`, and not + // `replaceAll` either (objectui#3418). This path must agree with i18next, + // which serves the *provider* path; any divergence is a silent fork that + // only shows up on provider-less hosts, where we are least likely to see + // it: + // 1. `replace` with a string needle substitutes only the FIRST + // occurrence. i18next substitutes every one, so a sentence that + // repeats a placeholder ("Selected {{count}} of {{count}} items" — + // natural in many locales, and often required by RTL / agglutinative + // word order) leaked literal braces to users. + // 2. `replace` AND `replaceAll` both interpret `$&`, `` $` ``, `$'` and + // `$$` in the *replacement* string. i18next does not. Values here are + // runtime data — record labels, search terms — so this one is + // reachable today, unlike (1). + // split/join is literal on both sides, which is exactly i18next's + // behaviour, and needs no regex escaping of the placeholder name. + out = out.split(`{{${k}}}`).join(String(v)); + } + return out; +} + +/** + * The options object of a `t()` call, or `undefined` when the call passed none. + * + * i18next's `t` accepts both `t(key, options)` and `t(key, defaultValue, + * options)`, so the options object is the LAST argument when it is an object at + * all. A string second argument is a default value, not data — there is nothing + * to interpolate from, which is why `t(key, 'Hi {{name}}')` keeps its braces on + * both paths. + * + * Arrays are excluded deliberately: `t(key, [...])` is not an i18next options + * shape, and `Object.entries` over one would mint `{{0}}`-style needles that no + * copy in this repo spells. + */ +export function optionsOf(args: readonly unknown[]): Record | undefined { + if (args.length < 2) return undefined; + const last = args[args.length - 1]; + if (typeof last !== 'object' || last === null || Array.isArray(last)) return undefined; + return last as Record; +} diff --git a/packages/i18n/src/provider.tsx b/packages/i18n/src/provider.tsx index 7b85527f45..4e8852ba0a 100644 --- a/packages/i18n/src/provider.tsx +++ b/packages/i18n/src/provider.tsx @@ -7,6 +7,7 @@ import React, { createContext, useContext, useEffect, useMemo, useRef, useState import { I18nextProvider, useTranslation } from 'react-i18next'; import type { i18n as I18nInstance } from 'i18next'; import { createI18n, getDirection, type I18nConfig } from './i18n.js'; +import { interpolateFallback, optionsOf } from './fallbackInterpolation.js'; import { builtInLocales } from './locales/index.js'; /** @@ -628,7 +629,66 @@ export function I18nProvider({ */ export function useObjectTranslation(ns?: string) { const context = useContext(ObjectI18nContext); - const { t, i18n } = useTranslation(ns); + const { t: boundT, i18n } = useTranslation(ns); + + // Whether react-i18next found an i18next instance at all — from props, + // from an `I18nextProvider` above, or from the module-level global that + // `createI18n` installs via `initReactI18next`. + // + // Read off the returned instance rather than the `ready` flag, and that is + // the load-bearing part. `useTranslation` returns `i18n || {}`, so a plain + // object with no `t` is exactly and only the no-instance case; `ready` is + // ALSO false for a real instance whose namespace is still loading, and there + // `t` is i18next's own `getFixedT` result, which interpolates already. + // Keying on `ready` would run a second interpolation pass over a string + // i18next had already filled — measured in + // `react-i18next@17.0.11/dist/es/useTranslation.js`, where `getSnapshot` + // returns `notReadySnapshot` under `if (!i18n)` and nothing else. + const hasInstance = typeof (i18n as { t?: unknown } | undefined)?.t === 'function'; + + // objectui#6219. With no instance, react-i18next hands back `notReadyT`, + // which returns `options.defaultValue` **verbatim**: an inline default + // written `'Deleted {{count}} rows'` reached the user with the braces intact + // on every host that embeds an ObjectUI component without `I18nProvider` — + // the configuration `createSafeTranslation` exists for (objectui#3865), so a + // supported one rather than a hypothetical. + // + // This is the ONE seam: 68 inline defaults across 24 files were rendering + // through it, and every one of them is fixed here instead of at the call + // sites. It is deliberately NOT a rewrite of those call sites — at a bare + // `useObjectTranslation()` the pre-interpolated template literal + // (`` `Deleted ${n} rows` ``) is the CORRECT spelling and stays correct + // (there is no hole left to fill), so both shapes now render right and + // neither is residue. objectui#4905 specified the opposite rewrite and it + // would have introduced this defect at 29 more sites. + // + // Scope, stated as narrowly as it is implemented: this widens WHICH BINDINGS + // interpolate on the provider-less path. It does not widen WHICH SPELLINGS + // resolve — that fork is objectui#3512's, which ruled deliberately against + // teaching the fallback i18next's other three dialects, and this change + // keeps that ruling by routing through the same one interpolator + // (`fallbackInterpolation.ts`) that `createSafeTranslation`'s `fallbackT` + // uses. With an instance present, `boundT` is returned untouched: i18next + // does its own interpolation and must never be double-processed. + const t = useMemo(() => { + if (hasInstance) return boundT; + const interpolating = (...args: unknown[]) => { + const rendered = (boundT as unknown as (...a: unknown[]) => unknown)(...args); + // `notReadyT` can also answer with a key array's last member or `''` for + // a function key. Only a string can carry a hole; anything else is + // handed back exactly as react-i18next produced it. + if (typeof rendered !== 'string') return rendered; + return interpolateFallback(rendered, optionsOf(args)); + }; + // The cast restores react-i18next's `TFunction` overloads for the ~700 + // call sites that destructure `t` — the wrapper is argument-transparent by + // construction (it forwards `...args` untouched), so the declared type + // still describes it. + return interpolating as unknown as typeof boundT; + // `notReadyT` is a module constant in react-i18next, so on this path + // `boundT` is referentially stable and so is the wrapper — which matters, + // because call sites put `t` in `useMemo`/`useCallback` dependency arrays. + }, [boundT, hasInstance]); return { /** Translation function */ diff --git a/packages/i18n/src/useSafeTranslation.ts b/packages/i18n/src/useSafeTranslation.ts index 42a30e35b7..788aa09c06 100644 --- a/packages/i18n/src/useSafeTranslation.ts +++ b/packages/i18n/src/useSafeTranslation.ts @@ -13,15 +13,8 @@ * @param testKey - A key to test if i18n is properly configured (must be in defaults) */ import { useObjectTranslation } from './provider.js'; +import { DEFAULT_VALUE_OPTION, interpolateFallback } from './fallbackInterpolation.js'; -/** - * The i18next option that names the string to use when the lookup misses. - * - * It is a LOOKUP CONTROL, not interpolation data (objectui#3865): it chooses - * which string is rendered, so it must never also be spliced into a - * `{{defaultValue}}` hole in the string it chose — or in any other. - */ -const DEFAULT_VALUE_OPTION = 'defaultValue'; export function createSafeTranslation( defaults: Record, @@ -55,40 +48,13 @@ export function createSafeTranslation( const inlineDefault = options?.[DEFAULT_VALUE_OPTION]; let value = defaults[key] || (typeof inlineDefault === 'string' ? inlineDefault : '') || key; - if (options) { - for (const [k, v] of Object.entries(options)) { - // Reserved: see DEFAULT_VALUE_OPTION. Skipped whatever its type, so an - // ignored non-string default cannot re-enter through this loop. - // - // This is the one deliberate divergence from i18next on this path, and - // it is unreachable with today's strings: i18next passes the whole - // options object to its interpolator, so it WOULD render - // `'Fallback: {{defaultValue}}'` as `'Fallback: INLINE'` (measured on - // 26.3.6) — but no value in this repo spells that hole (zero hits for - // `{{defaultValue` across packages/apps/examples), and splicing a - // fallback string into a hole named after itself has no sensible - // reading. The alternative — letting a call site's fallback text leak - // into an unrelated table value — is the worse of the two. - if (k === DEFAULT_VALUE_OPTION) continue; - // `split(needle).join(value)` — deliberately not `replace`, and not - // `replaceAll` either (objectui#3418). This path must agree with - // i18next, which serves the *provider* path; any divergence is a - // silent fork that only shows up on provider-less hosts, where we are - // least likely to see it: - // 1. `replace` with a string needle substitutes only the FIRST - // occurrence. i18next substitutes every one, so a sentence that - // repeats a placeholder ("Selected {{count}} of {{count}} items" - // — natural in many locales, and often required by RTL / - // agglutinative word order) leaked literal braces to users. - // 2. `replace` AND `replaceAll` both interpret `$&`, `` $` ``, `$'` - // and `$$` in the *replacement* string. i18next does not. Values - // here are runtime data — record labels, search terms — so this - // one is reachable today, unlike (1). - // split/join is literal on both sides, which is exactly i18next's - // behaviour, and needs no regex escaping of the placeholder name. - value = value.split(`{{${k}}}`).join(String(v)); - } - } + // The interpolation itself lives in `fallbackInterpolation.ts` — ONE + // function, shared with `useObjectTranslation`'s not-ready path + // (objectui#6219). Both provider-less renderers therefore fill exactly + // the `{{name}}` spelling objectui#3512 holds the copy to, and the + // reserved-name rule for `defaultValue` (objectui#3865) is stated once. + // The behaviour pinned by `useSafeTranslation.test.tsx` is unchanged. + value = interpolateFallback(value, options); return value; };