Uh oh!
There was an error while loading. Please reload this page.
fix(i18n): fallback interpolation replaces all placeholder occurrences - #3510
Merged
Conversation
`createSafeTranslation`'s no-provider fallback used
`value.replace('{{k}}', String(v))`, which substitutes only the FIRST
occurrence of each placeholder. i18next — which serves the provider path —
substitutes every one, so a default string repeating a placeholder leaked
literal braces to users on hosts with no `I18nProvider` mounted.
Switches to `value.split(needle).join(String(v))`. Not `replaceAll`: both
`replace` and `replaceAll` interpret `$&`, `` $` ``, `$'` and `$$` in the
*replacement* string, which i18next does not — and interpolated values are
runtime data (record labels, search terms), so that divergence was already
reachable. split/join is literal on both sides and needs no regex escaping.
Key resolution (`defaults[key] || key`), the `String(v)` coercion and the
leave-it-literal behaviour for an unmatched placeholder are unchanged.
Fixes objectui#3418.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRtThe latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
AGENTS.md commandment #-1: code comments are English. The repeated-placeholder example carried a CJK sentence to illustrate the case; describe the word-order motivation instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
yinlianghui
marked this pull request as ready for review
August 7, 2026 02:11
Uh oh!
There was an error while loading. Please reload this page.
akarma-synetal pushed a commit
to akarma-synetal/objectui
that referenced
this pull request
Aug 10, 2026
…t path filtering into the jobs (objectstack-ai#3523 steps 1-2) (objectstack-ai#3722) * ci(step 1): subscribe ci/lint/control-bytes/docs-links to merge_group (objectstack-ai#3523) Step 1 of objectui#3523, on its own commit as the issue's ruling requires: this is a pure addition and changes nothing about which pull requests or pushes run CI. The merge queue is enforced on this repository by a ruleset (objectstack-ai#3243 measured a direct push to `main` returning 405 `Changes must be made through the merge queue`), but not one of the 22 workflows subscribed `merge_group` — repo-wide `event=merge_group` runs stood at total_count = 0. A queue nothing subscribes to can only carry an empty required-check set, so it rebuilt each PR on the current `main` and let it through without validating anything. On 2026-08-07 that was cashed in: objectstack-ai#3503 / objectstack-ai#3510 / objectstack-ai#3516 merged between 02:11Z and 02:15Z with `Type Check` at conclusion=failure, on a `main` poisoned by objectstack-ai#3498, and objectstack-ai#3505 had to hot-fix it. The four workflows named by the issue now carry the trigger. It is spelled `merge_group: types: [checks_requested]`; `checks_requested` is the only activity type GitHub defines for this event today, so this is equivalent to objectstack's bare `merge_group:` and merely refuses to inherit a second type added later. Two `ci.yml` jobs additionally needed to be told the third event exists — both changes are no-ops for `pull_request` and `push`: - `test` moves from `if: github.event_name == 'pull_request'` to `!= 'push'`, so a queue build actually runs the suite instead of skipping every shard. - `docs`'s `should_run` treated anything that is not `push` as a pull request and diffed `github.event.pull_request.*`, which is null on a queue build — an empty revision range, i.e. the site build silently skipped on the last check before `main`. Verified rather than assumed: `concurrency` needs no merge-queue case. On `merge_group` the group expression falls back to `github.ref`, which is the queue's own generation — measured on objectstack, whose 3552 queue runs report head_branch `gh-readonly-queue/main/pr-6594-251e888a…`. That can collide with neither a PR group (a bare number) nor a push group (`refs/heads/main`). Refs objectstack-ai#3523 * ci(step 2): move ci/lint path filtering out of the PR trigger into the jobs (objectstack-ai#3523) Step 2 of objectui#3523, deliberately a separate commit from step 1: step 1 adds runs that did not exist, this one changes which pull requests start CI, and mixing them would make that impossible to review apart. `on.pull_request.paths-ignore` skips the WHOLE workflow when every changed file matches, and GitHub has no per-job path filter. A docs-only or changeset-only PR therefore started neither `ci.yml` nor `lint.yml` — objectstack-ai#3509 measured zero check runs from them. A check that is never *created* does not fail a required-status-check rule, it leaves the pull request pending; inside the merge queue it fails on the ruleset's 60-minute status-check timeout. So none of `Lint`, `Type Check`, `Test (shard N/4)`, `Build & E2E` or `Changeset Fixed Group Check` could be made required while the filter lived on the trigger — which is why the queue's required set was empty to begin with. The filter is not deleted, it moves. `type-check`, `test`, `e2e` (ci.yml) and `lint` (lint.yml) each open with a `Decide whether this change needs a full run` step, and every step after it carries `if: steps.relevant.outputs.should_run == 'true'`. The job always runs and always reports; the paths decide only whether it does any work. That is the shape `ci.yml`'s `docs` job has used since objectstack-ai#3450 — not a new mechanism — and the exclusion lists are byte-for-byte the `paths-ignore` they replace, so which PRs pay for a full run is exactly as before. Two deliberate narrowings, both stated so they can be argued with: - `paths-ignore` stays on the `push` trigger. Branch protection and the merge queue judge pull requests and queue builds, never pushes to `main`, so the push lane gains nothing from losing it and would cost a full CI run on every docs merge. It also gives the ignore list one authored home, which `merge-queue-reporting.test.ts` then pins the in-job copies against. - `changeset-check` is not gated. It is a checkout and one `node` call, so short-circuiting it would cost more in complexity than it saves. The gate fails OPEN: an unresolvable diff runs everything rather than reporting green having built nothing (objectstack#4928's filter contract). Measured both ways against a fixture repository — as shipped an unreachable base sha yields should_run=true; rewritten with the `|| echo ""` spelling `ci.yml`'s older `docs` gate still uses, the same input yields should_run=false, i.e. a silent full skip. That pre-existing `docs` gate is reported separately rather than changed here. `scripts/__tests__/merge-queue-reporting.test.ts` pins both steps; against the pre-change workflows 9 of its 10 assertions go red, each naming its own regression. `lint-workflow.test.ts`'s TypeScript-exclusion tripwire now reads the in-job list as well as the trigger, which is where that list now lives. Refs objectstack-ai#3523 --------- Co-authored-by: Claude <noreply@anthropic.com>
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#3418
问题
createSafeTranslation的无 provider 回退插值用的是value.replace('{{k}}', String(v))。String.prototype.replace传字符串 needle 时只替换第一次出现;而挂了 provider 的路径走 i18next 自己的插值,替换全部出现。于是任何一条在同一句里重复用同一个占位符的默认文案(
Selected {{count}} of {{count}} items—— 在很多语言里都很自然,RTL / 黏着语为了语序还经常必须这么写),在没有挂I18nProvider的宿主(独立 / 嵌入式渲染)上会把第二次及以后的{{k}}原样吐给用户。不抛错、不打日志,专挑我们最看不到的运行环境 —— 一条静默的语义分叉。改法
value.split(needle).join(String(v)),一行。裁定见 issue:修插值语义,不加守卫。为绕开实现 bug 去限制文案作者能写的句式,是把 bug 固化成契约;语义修齐后,重复占位符在两条路径上同样合法。
为什么是 split/join 而不是
replaceAll:replace和replaceAll都会解释替换串里的$&、$'、$$和反引号形式,i18next 不会。这里插进去的是运行时数据(记录 label、搜索词),所以这第二条分叉今天就够得着 —— 一个名字里带$&的记录在回退路径上会被改坏('Hello {{name}}'+'$& raw'旧实现给出'Hello {{name}} raw')。split/join 两侧都是字面量,顺带也省掉了占位符名的正则转义。defaults[key] || key的取值、String(v)强转、以及「没有对应 option 时占位符保持字面量」的行为,全部原样保留。前提核验
replace在origin/main上确实还活着 —— 前提 live。packages/+apps/全部单行字符串字面量,0 条重复使用同一个占位符。所以 (1) 类分叉今天没有触发点,(2) 类($替换模式)有。测试
新增断言的期望值不是猜的 —— 每一条都先在一个按
createI18n同样配置(interpolation: { escapeValue: false })的真实 i18next 实例上跑过,测里写的就是 i18next 自己的输出:selection+{count: 3}Selected 3 of 3 itemsSelected 3 of {{count}} itemspair+{a:'A', b:'B'}A/B — A again, B againA/B — {{a}} again, {{b}} againgreeting+{name:'$& raw'}Hello $& rawHello {{name}} rawgreeting+{other:'x'}Hello {{name}}greeting(无 options)Hello {{name}}反向验证(先定方向再跑):预测「两条重复占位符用例 +
$模式用例 RED,其余 GREEN」。测试先写、源码未动时跑:与预测逐条吻合。改完后
Tests 8 passed (8)。消费半径清扫(这条规则跑在哪就要扫到哪,不是只扫被改的包):
createSafeTranslation有 ~15 个包在用,root vitest 把@object-ui/i18nalias 到 src,所以下游 no-provider 测试直接吃到这次改动。下游那 100 条断言没有一条方向翻转,与前提核验一致:仓库里既没有重复占位符的文案,也没有把
$替换模式当值插进t()的地方。Changeset:
.changeset/safe-translation-fallback-replace-all.md(@object-ui/i18npatch)。Generated by Claude Code