Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

createSafeTranslation 的无 provider 回退只替换每个占位符的第一次出现 #3418

Closed

Description

@yinlianghui

做 #3408 时读 createSafeTranslation 的回退插值逻辑顺带发现的。观察类 —— 今天没有任何一条文案命中,所以打 finding、不排 pm:queue。

事实

packages/i18n/src/useSafeTranslation.ts:

const fallbackT = (key: string, options?: Record(string, unknown)) => {
  let value = defaults[key] || key;
  if (options) {
    for (const [k, v] of Object.entries(options)) {
      value = value.replace(`{{${k}}}`, String(v));
    }
  }
  return value;
};

(尖括号写成圆括号,避开 GitHub 正文清洗器。)

String.prototype.replace 传字符串 needle 时只替换第一次出现。所以任何一条在同一句里重复用同一个占位符的英文默认文案,在没有挂 I18nProvider 的渲染路径上(独立/嵌入式宿主,以及 packages/fields 里绝大多数 bare-render 测试)会把第二次及以后的 {{k}} 原样吐给用户 —— 屏幕上或读屏里出现一串花括号。

挂了 provider 的正常路径不受影响:那是 i18next 自己的插值,它替换全部出现。

为什么今天踩不到

FIELD_DEFAULTS(packages/fields/src/widgets/useFieldTranslation.ts)里现存的每一条,以及其他几个 createSafeTranslation 调用点的默认表,都没有一条重复使用同一个占位符 —— 逐条看过。也就是说这是一条休眠的分叉:回退路径与 i18next 的插值语义不一致,但目前没有输入能把这个差异暴露出来。

为什么仍然值得记一笔

  1. 它是静默的失败:输出的是 {{count}} 这种字面量,不抛错、不打日志,而且专挑没有 provider 的宿主 —— 那正是最不容易被我们自己看到的运行环境。
  2. 触发它只需要有人写出一句「已选 {{count}} 项,共 {{count}} 项」这种在多语言里相当自然的句子,或者某个 RTL / 黏着语的包为了语序需要重复插值。TextAreaField 字数统计的 aria-live 区每次击键都整句重播:打一句话读屏被打断 52 次(实测) #3408 新加的 fields.textarea.charactersRemaining 就差点走到这一步(最后用了冒号形式,只插一次)。
  3. 回退表和语言包之间已经有 parity 测试互钉字面量,但没有任何测试钉插值语义两边一致。

可能的修法(未裁定)

  • 换成 split(needle).join(value),或用带 g 标志的正则(占位符名要转义);
  • 或者反过来:加一条守卫,禁止默认表里出现重复占位符,让这条差异在写文案时就被拒掉(「declared = enforced」方向,比让消费端更宽容更符合仓库口味)。

后者更合我们的取向,但它是个契约形状选择,留给 triage。


Generated by Claude Code

Activity

  1. yinlianghui commented on Aug 6, 2026

    @yinlianghui
    ContributorAuthor

    发现分诊轮(持有):前提 live(fallback 仍 first-only replace),但已核验休眠——今天没有任何默认串重复占位符;修形(宽容 replaceAll vs 守卫默认表)是契约选择,连同守卫测试留待下轮或顺路。(objectui 分片 PM,会话 session_01GTRjn8xBqp75dk7kFupVRt)


    Generated by Claude Code

  2. self-assigned this
    on Aug 7, 2026
  3. yinlianghui commented on Aug 7, 2026

    @yinlianghui
    ContributorAuthor

    认领(PM 分诊晋级,解除上一轮持有)— 会话 session_01GTRjn8xBqp75dk7kFupVRt,分支 claude/issue-3418-fallback-interpolation。

    契约裁定(上轮悬置的取舍,现在定):修插值语义,不加守卫 —— fallbackT 的占位符替换改为全量替换(split(needle).join(String(v)),避开正则转义),使无 provider 回退与 i18next 插值语义一致;并加测试钉住"回退插值替换所有出现"。守卫方案否决:为绕过实现 bug 去限制默认表作者可写的句式,是把 bug 固化成契约;语义修齐后重复占位符在两条路径上同样合法,守卫无存在必要。

    文件面:packages/i18n/src/useSafeTranslation.ts + 该包测试。越界即停。


    Generated by Claude Code

  4. yinlianghui commented on Aug 7, 2026

    @yinlianghui
    ContributorAuthor

    验收:ACCEPT — PR #3510。

    核过 GitHub 实物:三文件恰如文件面(源一行 + 测试 + changeset)。裁定要点:

    1. split/join 而非 replaceAll 的理由被证据升级了:dev 发现 replace/replaceAll 都会解释替换串里的 $&/$`/$'/$$,而 i18next 不会 —— 且这里的值是运行期数据(记录标签、搜索词),所以这条分叉今天就可达,不像重复占位符是休眠的。裁定时我只裁到"全量替换",dev 把修法钉在了两条分叉都关死的唯一拼写上,并用测试和源注释固化。背书。
    2. 期望值全部来自真实 i18next 实例的输出(按 createI18n 同款配置),不是猜测;逆向验证是真跑(先写测试,旧实现下 3 红 case-for-case 命中预告)。
    3. 消费半径实测:18 个下游 no-provider 回退测试文件全绿(root vitest 把 @object-ui/i18n alias 到 src,下游直接吃到改动)。
    4. Type Check 红 = main 的 Type Check job 全红:#3489 与 #3498 的语义冲突,check-doc-links.test.ts 的 @ts-expect-error 在 allowJs 下变成 TS2578 #3504 既有断裂(dev 拉了 job log 核对坐标),fix(scripts): 删除 check-doc-links 测试中已失效的 @ts-expect-error(修复净 main 上的 TS2578) #3505 落地后 update branch 即绿。

    #3512(回退插值方言面更宽的休眠分叉:{{ name }} 空格形、{{count, number}} 格式符、$t() 嵌套)进分诊池 —— 未查重(配额),下轮分诊时由 PM 复核有无孪生单。

    PR 转 ready + auto-merge(配额恢复后执行)。


    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

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions