Skip to content

fix(i18n): the provider-less not-ready t now interpolates its inline defaultValue (#6219) - #6255

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-6219-notready-t-interpolation
Aug 25, 2026
Merged

fix(i18n): the provider-less not-ready t now interpolates its inline defaultValue (#6219)#6255
yinlianghui merged 2 commits into
mainfrom
claude/issue-6219-notready-t-interpolation

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#6219

The defect

useObjectTranslation() hands back react-i18next's t directly. With no i18next
instance initialised — i.e. any host that embeds an ObjectUI component without
I18nProvider — that is notReadyT, which returns options.defaultValue
verbatim. Measured in the installed react-i18next@17.0.11
(dist/es/useTranslation.js):

constnotReadyT=(k,optsOrDefaultValue)=>{if(isString(optsOrDefaultValue))returnoptsOrDefaultValue;if(isObject(optsOrDefaultValue)&&isString(optsOrDefaultValue.defaultValue))returnoptsOrDefaultValue.defaultValue;
...
};

So an inline default written 'Deleted {{count}} rows' reached the user with the
braces intact. That configuration is the one createSafeTranslation exists 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 did
not exist anywhere yet.

bindingwhat renders it provider-lessinterpolates {{name}}?census on this tree
bare useObjectTranslation(), and anything destructuring its treact-i18next notReadyTno → yes (this PR)24 files / 68 defaults
a createSafeTranslation(...) product (31 hooks discovered, incl. useDetailTranslation via the createSafeTranslationHook alias)fallbackTyes, exact {{name}} only3 files / 4 defaults
useSafeTranslate() (tt(key, fallback))its fallback argument, returned verbatimn/a — the signature carries no options bag, and 0 call sites spell a hole0
t received as a parameter / propwhatever the caller boundinherited from the caller's row6 files / 14 defaults
anything, with I18nProvider mountedi18nextyes (plus three more dialects)the pack value wins; the default never renders

Two consequences worth stating:

  • Row 4 stops being a hazard. "Provenance depends on the caller" only bit
    because rows 1 and 2 disagreed. They now agree, so those 14 defaults are
    correct whichever t the caller passes.
  • The three "already correct" rows are recorded as correct, so the next sweep
    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
f66072d1b it 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 found
no instance at all, t is 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 CORRECT
spelling 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 functionfallbackT runs. That function moved out of
useSafeTranslation.ts into a new fallbackInterpolation.ts so there is one copy
rather 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 set
size unchanged at 4 — it counts copies of the grammar, and this moved one rather
than adding one.

scripts/check-i18n-call-site-keys.mjs is untouched, and needs no extension.
Its class 7 already holds every inline default to {{name}}; before this PR that
rule was only meaningful at fallbackT sites, and it is now meaningful at every
site. It is green on this branch (977 defaults judged).

Detection: on the instance, not on ready

hasInstance reads typeof i18n?.t === 'function' rather than the ready flag.
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 pass over a string i18next had filled. In
useTranslation.js, getSnapshot returns notReadySnapshot under if (!i18n)
and nothing else, so the instance check is exact.

Verification

  • pnpm exec vitest run packages/i18n/55 files, 910 tests passed.
  • Full farm, all four shards on bc929e7bd: 1981 files, 25101 tests passed,
    1 skipped, 0 failed
    --shard=1/4 496 files / 6721, 2/4 495 / 6177,
    3/4 495 / 6539, 4/4 495 / 5664. (Shard 3's process exited 137 after
    printing its complete 495 passed summary — a SIGKILL at teardown under local
    memory pressure, with zero FAIL lines in the log. Recorded rather than
    smoothed 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 ...pkg
    is the dependents direction; the printed scope includes app-shell, console,
    components and 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.
  • ESLint: 0 errors, 0 new warnings on the 5 changed files. Narrowed rather
    than repo-wide, and the narrowing is a measurement: eslint.config.js declares
    no projectService / parserOptions.project / program, so type-aware
    linting 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 boundT
guarded by a constant), under trap … EXIT INT TERM. Mutation proven on disk by
grepping the injected marker (2 hits) and printing both return boundT lines at
their landing site; restore confirmed by git diff HEAD --stat empty and the
marker back to 0 hits.

Predicted before running: the four THE PIN cases go red, the rest stay green.
Observed: exactly that — 4 failed / 8 passed.

⚠️ Which assertions would still pass on a revert, and why each is there

The pin renders provider-less and asserts on DOM textContent, never on a
t the test called itself — a test that mounts I18nProvider can never see this
defect, 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:

casewhy it is here
the positive controlGreen either way by construction. Asserts getI18n() is undefined and 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 writtenThe ⭐ #4905 corollary. Its job is to fail if anyone later "tidies" these 68 defaults into {{hole}} form, or teaches the interpolator to touch text with no holes.
an i18next-only spelling is still not resolved hereHolds #3512's line: {{ count }} must stay literal. Fails if someone widens the spellings while claiming to be following this PR.
`defaultValue` is a lookup control, never interpolation data#3865's reserved-name rule, now asserted on this binding too.
with no options there is nothing to fill fromBoundary: notReadyT's own contract is preserved.
with a provider, a hole that came from the DATA is not re-expandedThe must-not-break, and the behavioural half of "never interpolate twice". Measured: i18next renders t('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 winsMust-not-break on the ready path.
the wrapper adds no identity churn of its ownGreen either way; it fails only if the wrapper is minted per render, which no output assertion would catch.

That last one is a relative bound on purpose, and the reason is a measurement
worth recording: react-i18next's own not-ready t is only sometimes
referentially stable. createI18n sets react: { useSuspense: false }, 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, a different piece of state). Under !ready && !useSuspense,
useTranslation re-wraps t in a fresh warn-once arrow every render. So "always
stable" 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 the
sibling agents on packages/plugin-view, packages/plugin-gantt,
packages/plugin-form and packages/components/.../radio-group.tsx.

⛔ Draft on purpose — the PM lands it.


Generated by Claude Code

…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_01CSoz9uGhaaSgiq3hshtN7L
Two 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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3222.8 KB3266.6 KB
Main entry chunk (gzip)153.8 KB350 KB
Entry fileindex-DCVjGREj.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.63KB114.68KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)171.74KB47.48KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.32KB42.81KB
plugin-detail (index.js)244.13KB61.93KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)126.39KB30.80KB
plugin-gantt (index.js)164.17KB39.89KB
plugin-grid (index.js)201.14KB54.40KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.94KB27.24KB
plugin-map (index.js)20.11KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.55KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghui
yinlianghui marked this pull request as ready for review August 25, 2026 05:18
@yinlianghui
yinlianghui added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit de570ccAug 25, 2026
28 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-6219-notready-t-interpolation branch August 25, 2026 05:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude