Skip to content

[finding] marketplace.action.updateTo 传了 version 插值参数但值里没有对应的洞 —— i18next 静默丢弃,主按钮只写 Update 而同文件的姐妹键渲染 Update → v1.2.3(全仓 1/1055) #3845

Description

@yinlianghui

在 #3546 切片五(回填 marketplace + preview 命名空间)顺手量出,不在该 PR 范围内。观察级:传进去的参数是休眠的,按钮上的 Update 本身是个正常标签,今天没有任何东西坏掉。

现场

packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx:555(云端安装分支的主按钮):

label: installing
  ? t('marketplace.action.installing')
  : cloudInstalledVersion
    ? (cloudUpdateAvailable
        ? t('marketplace.action.updateTo', { defaultValue: 'Update', version: latestVersion })
        : t('marketplace.action.installed', { defaultValue: 'Installed' }))
    : t('marketplace.action.installToCloud'),

version: latestVersion 被传进去,但 defaultValue 是 Update —— 没有 {{version}} 的洞。i18next 对没有对应洞的插值参数静默丢弃,所以这个参数从来没有渲染过任何东西。

对照:同一个文件的 :852(环境下拉里每个环境后面的版本提示)确实渲染版本:

t('marketplace.install.updateTo', { defaultValue: 'Update → v{{version}}', version: latestVersion })

两个 key 名字都叫 updateTo、都传 version、都在同一个包详情页上,只有一个有洞。作者意图看着是"主按钮也该显示要升到哪个版本",但那只是推测。

全仓规模:1/1055,就是这一处

对全仓所有 t('key', { … }) 形态的调用点(key 在 en 能解析的,共 1055 个)做 AST 抽取,取出顶层参数名(剔除 i18next 保留名 defaultValue/count/context/ns/lng/… 与嵌套调用的参数),与 en 值里的 {{hole}} 名字集合比对:

checked 1055 pack-backed t(key, {…}) call sites whose key resolves in en
call sites passing an option with no matching {{hole}}: 1
distinct keys: 1
  packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx:555
      marketplace.action.updateTo  inert=[version]
      en = "Update"

这个类是单例。第一遍扫描曾报 2 处,第二处 packages/fields/src/widgets/ImageField.tsx:105 是误报 —— 它的外层 t('fields.image.enlarge', { name: … }) 的参数对象里嵌了另一个 t() 调用(t('fields.image.imageAlt', { index: idx + 1 })),我的正则把内层的 index: 当成了外层参数名。剔除嵌套括号跨度后复测为 1。记在这里是因为任何将来实现这道门禁的人会踩同一个坑:参数名提取必须先消掉嵌套调用。

#3546 切片五做了什么(以及为什么不修)

切片五把 marketplace.action.updateTo 补进十个语言包。硬纪律是 en 值与调用点内联 defaultValue 逐字节相同(36/36),所以 en 落的是 Update,九包落各自的"更新 / Aktualisieren / Mettre à jour / …"(复用 form.update 既有译文行)。

没有顺手给 en 加 {{version}},两个理由:

  1. 那会改变用户今天看到的字串(Update → Update → v2.1.0),而这条纪律的整个意义就是回填不改变任何英文渲染;
  2. 该改不改是产品裁量 —— 主按钮要不要带版本号是设计问题,不是 i18n 问题。按钮宽度、与 Installed / Installing… 三态的视觉一致性都在里面。

切片五的 PR 里有一条断言把这个选择钉住,免得下一个读者以为是漏了:

expect(src).toContain("t('marketplace.action.updateTo', { defaultValue: 'Update', version: latestVersion })");
for (const lang of LANGS) expect(at(builtInLocales[lang], 'marketplace.action.updateTo')).not.toContain('{{');
expect(at(builtInLocales.en, 'marketplace.install.updateTo')).toContain('{{version}}');

也就是说:要么删掉那个休眠参数,要么给十包都加上洞 —— 无论哪条,这条断言都会红,迫使做决定的人正面处理。

三道 i18n 门禁为什么看不见

反向的错(值里有洞、调用点不传参数 → 用户直接看到 {{name}} 花括号)同样没人管。本次扫描顺带确认了这一支目前为 0,但那是运气,不是保证。

收口方向(未裁决)

倾向 C 落门禁 + A 清存量(A 而非 B,因为 B 是产品决定,不该由一道门禁的存量清理顺手带上)。若维护者认为主按钮该显示版本,那就是 B,并且门禁照样成立。

关联:#3546(切片五量出本条并钉住选择)、#3810(同一位置的另一道门禁提案:内联 defaultValue 必须等于 en 值;这两条判据应当同批实现)、#3530(守卫本体,已有现成 AST)、#3650(en-drift 门禁)。

Activity

  1. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Findings triage: promoted finding → pm:queue.

    Basis: both anchors re-verified live on origin/main @ 993336f — MarketplacePackagePage.tsx:555 (param passed, defaultValue: 'Update', no hole) vs :852 (sister key renders Update → v{{version}}). The census is done (1/1055, single instance, false-positive already excluded with the nested-call caveat recorded), the #3546 slice-5 pin forces whoever touches this to decide deliberately — the remaining work is scoped.

    Direction recorded: C (gate) + A (delete the inert param) as the default, per the card's own lean — A changes no English rendering today, so no product semantics move; the gate is the same AST location as #3810's option-B gate (already pm:queue), and the two criteria should land as one batch (its zero-baseline measurement is on this card). B (add {{version}} to the button) is a product/design call — not queued; if the maintainer wants the version on the main button, that's a one-line follow-up and the gate still stands either way.

    Dedup: family is #3810/#3530/#3650, all cross-linked, faces disjoint (this is the param-vs-hole criterion; #3810 is defaultValue-vs-en).

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. yinlianghui commented on Aug 8, 2026

    @yinlianghui
    CollaboratorAuthor

    第二个实例,而且它说明本单的检测判据缺了一半 —— #3546 切片六(perm + home)在普查 home.* 调用点时撞到的:

    packages/app-shell/src/console/home/HomePage.tsx:296
      t('home.welcome', { product: getRuntimeConfig().branding.productName,
                          defaultValue: 'Welcome to {{product}}' })
    
    en 包值(实际渲染): "Build your business system with AI"      ← 没有 {{product}} 洞
    

    product 参数因此是休眠的:白标部署的产品名一个字也不会出现在首屏大标题上,i18next 静默丢弃该参数。

    为什么本单当时只量到 1 处 / 1055: 本单的判据是"参数名 vs defaultValue 里的洞"。这一处的 defaultValue 有那个洞(Welcome to {{product}}),所以按该判据它是合规的;休眠是相对 en 包值发生的 —— 而包值才是 key 存在时真正渲染的东西。marketplace.action.updateTo 之所以被抓到,是因为它连 defaultValue 里都没有洞。

    所以门禁判据应当是两段:

    1. key 在 en 不存在时 → 参数名必须在 defaultValue 的洞里(本单原判据;marketplace.action.updateTo 属此);
    2. key 在 en 存在时 → 参数名必须在 en 包值的洞里(home.welcome 属此),并且十包一致(占位符形状已由 all-locales-key-parity 保证,所以只判 en 足够)。

    第 2 段是全新的一段,而且是更常见的一段:2320 个字面量 key 里绝大多数已存在,所以本单"1 / 1055"的分母只覆盖了第 1 段。建议按两段各自重跑一遍再定级 —— 我这次只扫了 perm.*/home.* 的 153 个站点(第 2 段命中 1 处),没有全仓跑。

    顺带确认本单正文那个陷阱在第 2 段同样成立:参数名提取必须先消掉嵌套的 t(),否则外层的参数对象会把内层调用的参数算进来(本单在 ImageField.tsx:105 上误报过一次)。

    关联:#3546 切片六 PR、#3810(同一处 home.welcome 也是内联 defaultValue 与 en 包值语义分歧的一例,已在该单追评)。


    Generated by Claude Code

  3. yinlianghui commented on Aug 10, 2026

    @yinlianghui
    CollaboratorAuthor

    A second live instance of this class, measured during #3810's repo-wide census (all 2314 literal-key call sites, not a namespace slice):

    packages/app-shell/src/console/home/HomePage.tsx:330

    t('home.welcome', { product: getRuntimeConfig().branding.productName, defaultValue: ... })
    

    home.welcome in packages/i18n/src/locales/en.ts is Build your business system with AI — no {{product}} hole, so the product parameter is silently dropped, exactly like marketplace.action.updateTo's version. The call site's inline defaultValue read Welcome to {{product}}, which is where the parameter came from and what makes the intent legible: the key's value was rewritten at some point and the call site was never revisited.

    So the count for the "argument passed, no hole to receive it" direction is now 2, in two unrelated namespaces — which is a point in favour of the bidirectional criterion this card proposes rather than a one-off fix. #3810's PR aligns that call site's dead defaultValue to the pack value and deliberately leaves the parameter alone: dropping an argument, or adding a hole to the en value (which obliges the nine other packs via #3650), is this card's decision to make, not a rider on a byte-alignment PR.

    Worth pairing with the extractor note already recorded here: the defaultValue rule that landed for #3810 sits at exactly the AST position this check needs (scripts/check-i18n-call-site-keys.mjs, the pack-backed call-site branch, where the options object is already walked for defaultValue / returnObjects / the probe flag), and collectEnKeys now returns leaf values as well as paths — so the "does this value have the hole this call site is filling" comparison needs no new machinery.


    Generated by Claude Code

  4. self-assigned this
    on Aug 10, 2026
  5. yinlianghui commented on Aug 10, 2026

    @yinlianghui
    CollaboratorAuthor

    Claim: PM loop round 5 (rolling slot refill — UNFROZEN: this seat held the card while #3810/PR #4119 was in flight because both amend scripts/check-i18n-call-site-keys.mjs; #4119 is accepted with auto-merge armed, so this now dispatches as its stacked successor)
    Session: session_017Qqyix2QcnpUC9XeYVDzx3 · Branch: claude/issue-3845-i18n-param-hole-parity · Worktree: objectui-issue-3845
    File surface: scripts/check-i18n-call-site-keys.mjs (+ self-test) for the new rule; MarketplacePackagePage.tsx:555 and HomePage.tsx:330 (the second instance attached to this card by #4119's dev) for the stock; the #3546-slice-five pinning assertion that deliberately forces this decision. Serial constraints: stacks on claude/issue-3810-defaultvalue-en-drift-gate if #4119 has not merged at start.
    Container & model: S, mode:subagent, model: opus

    Delegated ruling (maintainer's 2026-08-10 delegation; veto window open): C + A — the card's own leaning, adopted. The gate: for t(key, opts) with literal non-reserved option names and a key resolving in en, the option-name set must equal the en value's {{hole}} set, BOTH directions (param-no-hole red; hole-no-param red — declared=enforced). Stock is 1+1 sites (re-measure; the card's own scan method with the nested-call trap it documents), no baseline. A not B for the stock: delete the inert version params — B (adding {{version}} to ten packs) is a product decision about the main button's three-state copy and button width, exactly what a stock cleanup must not smuggle in; if the maintainer later wants the version shown, that is a one-key ten-pack change the gate will happily accept. The slice-five pinning assertion gets updated to pin the NEW chosen state (param gone) rather than deleted.


    Generated by Claude Code

  6. yinlianghui commented on Aug 10, 2026

    @yinlianghui
    CollaboratorAuthor

    ACCEPT — held for the release window (objectui seat PM, session session_017Qqyix2QcnpUC9XeYVDzx3, review of record) — PR #4136 reviewed and ready; NOT armed for merge per the freeze. Hold-for-release list: #4134, #4136.

    The ruling asked for C + A; the measurement turned up five sites, not two, and the report's centerpiece is the two it REFUSED to fix: both forgotPassword.successDescription sites look like direction-two violations (unfilled {{email}}), but the component fills that hole itself after the fact — "fixing" them would have made i18next consume the hole, blinded the component's guard, and rendered the address twice. The EXTERNALLY_INTERPOLATED_HOLES registry is the right mechanism: reasoned entries that silence only the unfilled direction, still report a passed argument, and are re-verified by the self-test against BOTH the pack and the named source file so an entry cannot outlive the substitution it describes. The third inert argument (resetPackageSetSuccess's label, copied from a neighbor that HAS the hole) was hand-verified against all ten packs. The provider-less nuance was verified against react-i18next's actual dist (notReadyT does no interpolation) rather than assumed — the no-rendered-change changeset claim stands on that. Slice-five's forcing assertion updated to pin the chosen state, sister key untouched, zero pack changes. #4135 (two hole spellings, {{email}} vs {seconds}) well-filed as observation-class with the #3512 cross-link.

    The declared-interpolation contract is now enforced both directions with zero baseline. #4117 (the || 'fallback' spelling this rule structurally cannot see) unfreezes and dispatches next, stacked on this branch.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions