做 #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 的插值语义不一致,但目前没有输入能把这个差异暴露出来。
为什么仍然值得记一笔
它是静默 的失败:输出的是 {{count}} 这种字面量,不抛错、不打日志,而且专挑没有 provider 的宿主 —— 那正是最不容易被我们自己看到的运行环境。
触发它只需要有人写出一句「已选 {{count}} 项,共 {{count}} 项」这种在多语言里相当自然的句子,或者某个 RTL / 黏着语的包为了语序需要重复插值。TextAreaField 字数统计的 aria-live 区每次击键都整句重播:打一句话读屏被打断 52 次(实测) #3408 新加的 fields.textarea.charactersRemaining 就差点走到这一步(最后用了冒号形式,只插一次)。
回退表和语言包之间已经有 parity 测试互钉字面量,但没有任何测试钉插值语义 两边一致。
可能的修法(未裁定)
换成 split(needle).join(value),或用带 g 标志的正则(占位符名要转义);
或者反过来:加一条守卫,禁止默认表里出现重复占位符,让这条差异在写文案时就被拒掉(「declared = enforced」方向,比让消费端更宽容更符合仓库口味)。
后者更合我们的取向,但它是个契约形状选择,留给 triage。
Generated by Claude Code
做 #3408 时读
createSafeTranslation的回退插值逻辑顺带发现的。观察类 —— 今天没有任何一条文案命中,所以打finding、不排pm:queue。事实
packages/i18n/src/useSafeTranslation.ts:(尖括号写成圆括号,避开 GitHub 正文清洗器。)
String.prototype.replace传字符串 needle 时只替换第一次出现。所以任何一条在同一句里重复用同一个占位符的英文默认文案,在没有挂I18nProvider的渲染路径上(独立/嵌入式宿主,以及packages/fields里绝大多数 bare-render 测试)会把第二次及以后的{{k}}原样吐给用户 —— 屏幕上或读屏里出现一串花括号。挂了 provider 的正常路径不受影响:那是 i18next 自己的插值,它替换全部出现。
为什么今天踩不到
FIELD_DEFAULTS(packages/fields/src/widgets/useFieldTranslation.ts)里现存的每一条,以及其他几个createSafeTranslation调用点的默认表,都没有一条重复使用同一个占位符 —— 逐条看过。也就是说这是一条休眠的分叉:回退路径与 i18next 的插值语义不一致,但目前没有输入能把这个差异暴露出来。为什么仍然值得记一笔
{{count}}这种字面量,不抛错、不打日志,而且专挑没有 provider 的宿主 —— 那正是最不容易被我们自己看到的运行环境。fields.textarea.charactersRemaining就差点走到这一步(最后用了冒号形式,只插一次)。可能的修法(未裁定)
split(needle).join(value),或用带g标志的正则(占位符名要转义);后者更合我们的取向,但它是个契约形状选择,留给 triage。
Generated by Claude Code