Uh oh!
There was an error while loading. Please reload this page.
fix(i18n): createSafeTranslation's provider-less fallback honours an inline defaultValue - #4372
Merged
yinlianghui merged 4 commits intoAug 11, 2026
Merged
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
…e-translation-defaultvalue
…ed the quoted line
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
yinlianghui
commented
Aug 11, 2026
CollaboratorAuthor
ACCEPT — PM 复核 (session
Flipping ready + arming auto-merge. Generated by Claude Code |
yinlianghui
marked this pull request as ready for review
August 11, 2026 23:05
Uh oh!
There was an error while loading. Please reload this page.
yinlianghui
deleted the
claude/issue-3865-safe-translation-defaultvalue
branch
August 11, 2026 23:05
This was referenced Aug 12, 2026
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#3865
createSafeTranslation's provider-less fallback never read the call site's inlinedefaultValue. It looked the key up in the hook's hand-writtendefaultsmap and, on a miss, rendered the raw key to the user — then ran every option,defaultValueincluded, through the interpolation loop as if it were a{{defaultValue}}variable. Sot('perm.facet.none', { defaultValue: 'None' })showedperm.facet.noneon a host with noI18nProvider, which is a supported scenario: standalone embedding and tests are the entire reason this factory exists (its own doc comment says so).Ruling on the card: option A. Implemented exactly.
The change
packages/i18n/src/useSafeTranslation.ts— plus one test re-pin described under "Collision with #3863" below.defaults[key]-> a stringdefaultValue-> the key. The defaults map is the pack value's stand-in on this path, so it keeps the pack's winning position. Non-stringdefaultValueis ignored (this function returns a string).defaultValueis excluded from the interpolation loop as a reserved name — it selects the string, it does not fill holes in one. Excluded whatever its type, so an ignored non-string default cannot re-enter through the loop.The provider path is untouched: once the probe succeeds the helper hands over to i18next's own
t, exactly as before.The order is i18next's — measured, not assumed
Probed against a real i18next 26.3.6 instance configured the way
createI18nconfigures it (interpolation: { escapeValue: false },returnNull: false), withanchorin the pack andmissingabsent:t('anchor', { defaultValue: 'INLINE' })'Anchor value't('missing', { defaultValue: 'INLINE' })'INLINE''INLINE't('missing')'missing't('missing', { defaultValue: 'Hi {{name}}', name: 'Ada' })'Hi Ada''Hi Ada't('missing', { defaultValue: null })'missing'That first row is why the table beats the inline default rather than the other way round: inverting it would make a provider-less host disagree with a provider-mounted one for every key that has both.
One deliberate divergence, and it is unreachable today
The card's ruling says i18next reserves
defaultValuefrom interpolation likewise. It does not — measured on the same instance: i18next hands the whole options object to its interpolator, so a value spelling'Fallback: {{defaultValue}}'renders as'Fallback: INLINE'there, while this fallback now leaves the hole alone. Recording it rather than restating the premise, because the surrounding comment in this file (from #3418) makes "agree with i18next" a load-bearing invariant.It costs nothing today: zero strings in
packages/,apps/orexamples/spell{{defaultValue. And splicing a fallback string into a hole named after itself has no sensible reading, whereas the alternative lets a call site's fallback text leak into an unrelated table value. Both directions are pinned by tests, so whichever way this is later settled, the change is visible.Census (the card's own named first step)
AST census, same method as the card: resolve every
createSafeTranslationbinding, follow re-exports to the files that consume each hook, collect literalt()keys, compare against that hook's table. At this branch's original base (d2e2caf40) it reproduces the card's plugin-detail figure exactly — 146 entries, 16 keys outside.The numbers below are after merging current
main, which includes #4367 (#3863) addingdetail.showEmptyRelatedto plugin-detail's table: that moves plugin-detail from 146/16 to 147/15 and the repo total from 27/21 to 26/20.26 hooks, 568 resolved literal call sites, 458 distinct keys. 26 keys sit outside their hook's table; 20 of those carry an inline
defaultValue. Those 20 stop rendering raw keys with this PR.defaultValueplugin-detailuseDetailTranslationplugin-listuseListViewTranslationplugin-designeruseDesignerTranslationplugin-kanbanuseKanbanTThe PM's three named targets, answered:
useGanttTranslationdeliberately avoidscreateSafeTranslation(per-key fallback, comment atuseGanttTranslation.ts:123), so this fix does not reach it. Its hand-rolled interpolator has a separate defect, filed as [i18n] The two hand-rolled provider-less fallback interpolators still use String.replace — a gantt task titledA$&Brenders{{title}}back into the delete dialog #4370.useKanbanThas a 3-entry table and 1 key outside it (kanban.columns, with an inline default) — the whole hook is fixed by this change.useTableTranslationinpackages/components(20 entries, 17 keys, 0 outside).The 6 keys with no inline default are not fixed by this PR and still render raw on provider-less hosts — they need a map or pack entry, not a helper change:
detail.saving,list.resetSortToDefault,appDesigner.widgetProperties,appDesigner.addWidget,appDesigner.modeEdit,common.delete(all four ofplugin-designer's outside-table keys carry no inline default at all).Method caveats, stated so the numbers can be re-derived: literal keys only (62 dynamic-key call sites repo-wide are report-only, matching
check:i18n-call-site-keys's own treatment); membership is an exact table lookup, because that is whatdefaults[key]does — no plural-suffix resolution.Collision with #3863, and how it was resolved
#4367 (#3863) merged while this was in flight. The two cards were scoped as zero-overlap on source files and that held — but #4367 added a source-text pin in
packages/i18n/src/__tests__/showEmptyRelated-plural-base-3863.test.tsxquoting the exact line this card was ruled to change:Its stated invariant is that
fallbackTindexes the table with the key as given and never appends a plural suffix — which is why #3863 had to add a base row at all. That invariant is untouched here: this change alters what answers on a miss, not how the table is indexed. The pin was therefore re-expressed rather than deleted, in two halves that bite on intent instead of on formatting:defaults[key] ||is still present, and the fallback's body must contain no plural machinery (_one/_other/… ,PluralRules, or a constructeddefaults[key + …]index). Reverse-verified: teachingfallbackTadefaults[key + '_other']lookup turns that case red again, which the old quote-pin would also have done.This is the only file outside
packages/i18n/src/useSafeTranslation.tsand its own tests that this PR touches.Tests
Red-first, in
packages/i18n/src/__tests__/useSafeTranslation.test.tsx(7 new cases; 4 were red before the fix, 13 pre-existing/control cases green throughout):{ defaultValue: 'None' }rendersNone(wasperm.facet.none)42,null,undefined, an object) ignored{{defaultValue}}survives verbatim, including when the default is non-string, and a default containing that hole does not substitute into itselfdefaultValue, and createSafeTranslation 的无 provider 回退只替换每个占位符的第一次出现 #3418's every-occurrence rule still holds{name}still untouched (createSafeTranslation 的无 provider 回退插值只认{{name}}一种写法,i18next 还认带空格 / 带格式化 / 嵌套三种 #3512's territory, deliberately unchanged here)I18nProvidermounted the pack value is served and an inline default already worked, and still doesReverse verification (commit-then-revert; predicted before running)
git checkout origin/main -- useSafeTranslation.ts)continuedefaultValuebefore the table)fallbackT(against the re-pin above)Commands
The consumer sweep is every package owning a
createSafeTranslationhook — that set is the census's own output, so the list is derived rather than guessed.Changeset:
.changeset/safe-translation-inline-default-3865.md, patch@object-ui/i18n(user-visible behaviour change in a published helper; nevermajor, per AGENTS.md's version-alignment rule).Relationships
perm.facet.*keys are the overlap: 258 个 t() 调用点引用的 key 在任何语言包里都不存在(#3530 守卫首跑实测),其中 8 处直接把 raw key 渲染给用户 #3546 fixed them on the provider path, this fixes them on the other one.createSafeTranslation, an inlinedefaultValuewas dead on both paths (provider: the pack value always wins; no provider: it was never read) until this change made the second path read it. Recorded as a comment on that card, which is closed and shipped option B (fix(i18n): fail on an inline defaultValue that contradicts its en value, and align the 43 that do (#3810) #4119) — the comment says so and states what changes for option C.{{name}}一种写法,i18next 还认带空格 / 带格式化 / 嵌套三种 #3512 — a different defect of the same fallback (single-brace placeholders). Deliberately not touched; pinned as a control so this change cannot drift into it.A$&Brenders{{title}}back into the delete dialog #4370 — filed from this work: the two hand-rolled copies of this interpolator (plugin-gantt,plugin-grid'sImportWizard) never got createSafeTranslation 的无 provider 回退只替换每个占位符的第一次出现 #3418'ssplit/joinfix, and a gantt task titledA$&Brenders the placeholder back into its delete dialog. Out of scope here.Option C (retiring the defaults tables) is recorded as the long-term direction and is not in this PR; option B is not being done. With A landed the tables become redundant-but-harmless, which is what makes C a later dead-surface sweep rather than a prerequisite.
Generated by Claude Code