Uh oh!
There was an error while loading. Please reload this page.
fix(i18n): the provider-less not-ready t now interpolates its inline defaultValue (#6219) - #6255
Merged
Merged
Conversation
…ltValue
react-i18next's `notReadyT` — what a bare `useObjectTranslation()` yields when
no i18next instance is initialised — returns `options.defaultValue` verbatim and
does not interpolate. 68 inline defaults across 24 files carried a `{{hole}}`
that reached the user as literal braces on any host embedding an ObjectUI
component without `I18nProvider`.
Fixed at the one seam rather than at the 68 call sites: `useObjectTranslation`
now routes its not-ready result through `interpolateFallback`, extracted from
`createSafeTranslation`'s `fallbackT` so both provider-less renderers share ONE
interpolator instead of growing a second. This widens which BINDINGS
interpolate, never which SPELLINGS resolve — objectui#3512's ruling against
teaching the fallback i18next's other three dialects is kept intact.
The ready path is returned by reference and never wrapped, so a `{{hole}}` that
arrived as runtime DATA cannot be re-expanded. Pre-interpolated template-literal
defaults stay correct, so no call site is rewritten.
Part of #6219
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7LTwo follow-ups on the objectui#6219 pin, both from running the gates: - `t(key, options)` widens to `string | TFunctionDetailedResult` under react-i18next's overloads. The detailed shape only appears under `returnObjects`/`returnDetails`, which nothing here passes, so the probe casts at the boundary and keeps its assertions about text rather than types. - The identity case captured the first render's values through `useRef` and read `.current` while rendering, which `react-hooks/refs` flags (correctly — it is a value React does not expect to be read there). `useState`'s lazy initializer records the same thing and is a legitimate render-phase read. Part of #6219 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
yinlianghui
marked this pull request as ready for review
August 25, 2026 05:18
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#6219
The defect
useObjectTranslation()hands back react-i18next'stdirectly. With no i18nextinstance initialised — i.e. any host that embeds an ObjectUI component without
I18nProvider— that isnotReadyT, which returnsoptions.defaultValueverbatim. Measured in the installed
react-i18next@17.0.11(
dist/es/useTranslation.js):So an inline default written
'Deleted {{count}} rows'reached the user with thebraces intact. That configuration is the one
createSafeTranslationexists for(#3865), so it is supported rather than hypothetical.
The per-hook table — built first, because everything else follows from it
Measured on
f66072d1b(this branch's base). This is the fact the card said didnot exist anywhere yet.
{{name}}?useObjectTranslation(), and anything destructuring itstnotReadyTcreateSafeTranslation(...)product (31 hooks discovered, incl.useDetailTranslationvia thecreateSafeTranslationHookalias)fallbackT{{name}}onlyuseSafeTranslate()(tt(key, fallback))fallbackargument, returned verbatimtreceived as a parameter / propI18nProvidermountedTwo consequences worth stating:
because rows 1 and 2 disagreed. They now agree, so those 14 defaults are
correct whichever
tthe caller passes.does not "fix" them. In particular row 3 cannot carry this defect by
construction.
Census delta vs. the card
The card measured 24 files / 65 defaults on row 1 before #6218 landed. On
f66072d1bit is 24 files / 68. Row 2 matches the card exactly (3 files / 4);the parameter row widened from 4 files / 6 to 6 / 14 because the classifier
resolves parameter bindings the earlier sweep read as unknown. No file left the
population; #6218 changed spelling, not count.
The fix: one seam, zero call sites
packages/i18n/src/provider.tsx— when, and only when, react-i18next foundno instance at all,
tis wrapped so the string it produced is interpolated.⭐ No call site is rewritten, deliberately. At a bare
useObjectTranslation()the pre-interpolated template literal (
`Deleted ${n} rows`) is the CORRECTspelling and stays correct — it has no hole left to fill. #4905 specified the
opposite rewrite and it would have introduced this defect at 29 more sites.
After this PR both shapes render correctly and neither is residue.
This does not fork from #3512. That card ruled deliberately against teaching
the provider-less fallback i18next's other three dialects (
{{ name }},{{count, number}},{{- name}},$t(key)) and gated the copy to{{name}}instead. This change widens which bindings interpolate, never which
spellings resolve — enforced structurally, because the not-ready path now runs
the same function
fallbackTruns. That function moved out ofuseSafeTranslation.tsinto a newfallbackInterpolation.tsso there is one copyrather than a second one to keep in step; #3512's own completeness pin
(
NEEDLE_FILES) caught the move and is updated to name the new home, with the setsize unchanged at 4 — it counts copies of the grammar, and this moved one rather
than adding one.
scripts/check-i18n-call-site-keys.mjsis untouched, and needs no extension.Its class 7 already holds every inline default to
{{name}}; before this PR thatrule was only meaningful at
fallbackTsites, and it is now meaningful at everysite. It is green on this branch (977 defaults judged).
Detection: on the instance, not on
readyhasInstancereadstypeof i18n?.t === 'function'rather than thereadyflag.readyis also false for a real instance whose namespace is still loading, andthere
tis i18next's owngetFixedTresult, which interpolates already — keyingon
readywould run a second pass over a string i18next had filled. InuseTranslation.js,getSnapshotreturnsnotReadySnapshotunderif (!i18n)and nothing else, so the instance check is exact.
Verification
pnpm exec vitest run packages/i18n/— 55 files, 910 tests passed.bc929e7bd: 1981 files, 25101 tests passed,1 skipped, 0 failed —
--shard=1/4496 files / 6721,2/4495 / 6177,3/4495 / 6539,4/4495 / 5664. (Shard 3's process exited 137 afterprinting its complete
495 passedsummary — a SIGKILL at teardown under localmemory pressure, with zero
FAILlines in the log. Recorded rather thansmoothed over; CI runs the same farm.)
pnpm exec turbo run type-check --filter=...@object-ui/i18n --force—70/70 tasks successful across the 35 downstream packages (turbo's
...pkgis the dependents direction; the printed scope includes
app-shell,console,componentsand every plugin).pnpm check:i18n-keys,check:i18n-drift,check:phantom-deps,check:self-import,check-control-bytes,check-changeset-presence,check-changeset-no-major,check-lint-coverage,check-type-check-coverage— all exit 0 on
bc929e7bd, each quoting its own verdict line.than repo-wide, and the narrowing is a measurement:
eslint.config.jsdeclaresno
projectService/parserOptions.project/program, so type-awarelinting is off and every file is judged from its own text — a 5-file diff
cannot move a verdict on an untouched file. File count read from
--format json(5 linted).Ablation
Committed first, then the seam alone was neutralised (an early
return boundTguarded by a constant), under
trap … EXIT INT TERM. Mutation proven on disk bygrepping the injected marker (2 hits) and printing both
return boundTlines attheir landing site; restore confirmed by
git diff HEAD --statempty and themarker back to 0 hits.
Predicted before running: the four
THE PINcases go red, the rest stay green.Observed: exactly that — 4 failed / 8 passed.
The pin renders provider-less and asserts on DOM
textContent, never on atthe test called itself — a test that mountsI18nProvidercan never see thisdefect, because i18next is ready, the pack value wins and the inline default never
renders at all. Of the 12 cases:
Fails on a revert (4).
a {{hole}} … is filled, not printed;every occurrence, not just the first;`$` sequences in the DATA are literal;a real-shaped console default renders as prose.Still passes on a revert (8), each deliberately:
the positive controlgetI18n()isundefinedand an unknown key renders as itself — proving the probe is really on the no-instance path and not resolving through a leaked global (#4514).a pre-interpolated template literal is left exactly as written{{hole}}form, or teaches the interpolator to touch text with no holes.an i18next-only spelling is still not resolved here{{ count }}must stay literal. Fails if someone widens the spellings while claiming to be following this PR.`defaultValue` is a lookup control, never interpolation datawith no options there is nothing to fill fromnotReadyT's own contract is preserved.with a provider, a hole that came from the DATA is not re-expandedt('form.createTitle', { object: '{{leak}}', leak: 'BOOM' })as'Create {{leak}}'— it does not re-scan its own output. A wrapped ready path would turn that into'BOOM', reinterpreting user data as copy syntax.with a provider, the pack value still winsthe wrapper adds no identity churn of its ownThat last one is a relative bound on purpose, and the reason is a measurement
worth recording: react-i18next's own not-ready
tis only sometimesreferentially stable.
createI18nsetsreact: { useSuspense: false }, andinitReactI18nextwrites that into react-i18next's module-level defaults —which
vitest.setup.i18n-global.tsdoes not reset (it restores the instancepointer, a different piece of state). Under
!ready && !useSuspense,useTranslationre-wrapstin a fresh warn-once arrow every render. So "alwaysstable" would have pinned test-file ordering, not this package's behaviour; the
case asserts both stable-together and moves-together, so it cannot go vacuous.
Scope / collisions
Diff is
packages/i18n/**+ one changeset. Empty collision surface with thesibling agents on
packages/plugin-view,packages/plugin-gantt,packages/plugin-formandpackages/components/.../radio-group.tsx.⛔ Draft on purpose — the PM lands it.
Generated by Claude Code