Skip to content

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

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions