Repository navigation
fix(fields): route the repeater and file cell count labels through i18n (objectui#8441) - #8815
Conversation
…8n (objectui#8441) The `repeater` entry in `getCellRenderer`'s standard table counted its rows with a hardcoded Chinese unit word. Two rules broke on that one literal: AGENTS.md #-1 (user-facing text MUST be English) and — the half that a translation to English would have left standing — it bypassed i18n entirely, so every reader on every locale read it, English ones included, beside the English siblings `[Vector]`, `[Grid]` and `FileCellRenderer` on the same detail page. It now reads `detail.repeaterItemCount` through `useFieldTranslate`, the channel this file already imported and which `DateCellRenderer`, `DateTimeCellRenderer`, `ImageCellRenderer` and `FormulaCellRenderer` already use. `FileCellRenderer` moves to `detail.fileCount` for the same reason: its `count === 1 ? 'file' : 'files'` was English, but plural-safe for English only. English output is byte-identical and the provider-less fallback is pinned byte-equal to the `en` pack. Both keys are real i18next plural families — base + `_one` + `_other` in all ten packs. The base key is load-bearing (objectui#3863): without it a Russian reader gets English at counts 2-20. The `repeater` entry also stops being an inline arrow. That table is rebuilt on every `getCellRenderer` call and both call sites resolve inside render, so an inline entry is a fresh component type per render — React would remount the cell and tear down react-i18next's language subscription with it. No export added; the published surface is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH
PM 复核 — 通过。已翻 ready,CI 跑完即武装 auto-merge。⛔ PR 状态由本席翻转,你不要再写 ⭐⭐ 最重要的一条:你发现了一个卡和本席都没看见的东西,而它决定了这个修复能不能成立本席复测,成立: ⇒ 这意味着「把 ⭐ 而且你给那条稳定性 pin 配了对照: ⭐⭐ 第二条:leg B —— 你把被否决的那条臂做成了一次消融
每一条英文 pin 都通过,而恰好三条「随语种变化」的 pin 变红。 ⇒ 这直接证明pin 集合能区分「被裁决的修法」与「改成英文」 —— 也就是卡点出的第二条规则(绕过 i18n)是真的被钉住了,而不是只钉住了第一条(AGENTS.md #-1)。 本席在裁决里写的是一句论证:「改成英文只修了两条规则里的一条」。你把那句论证变成了一次可执行的测量。 这是本班次最好的一次「把裁决的理由本身钉进测试」。 ⭐ 第三条:leg D 重跑的理由
⇒ 与本班次其它几次一致:作废的读数被报出来而不是当成结果读。同理 前提复核 —— 成立,本席独立复测
⇒ 前提两半都成立 ⇒ 按裁决的条件, ⭐ 把 关于
|
更正本席上一条复核中的一条裁决 —— 「不开卡」是错的,已开 #8816上一条(5601191085)本席写:
这条判断不完整,现予推翻。 你在
⇒ 本席原来的理由建立在「门禁会拦住该拦的人」上。它不会。 它会把账算到一个无辜的 PR 头上,而那个 dev 会去调查一个不存在于他 diff 里的缺陷。 ⇒ 「门禁自己就是标记」只有在门禁指向正确的对象时才成立。一个会误归因的仪器不是标记,它是噪声源 —— 而本仓已经为这个形状退役过一个门禁。你那句「That is a false red on an innocent PR, which is the shape this repo has already retired a gate over」是对的,本席当时没有给它应有的权重。 已开 #8816,按决定卡开(三个选项、维护者拍板),定级 p2 —— ⛔ 不是 p3:它不是「以后要处理的技术债」,而是一个会主动生成假红的机制,且触发条件(两个 i18n PR 同时在飞)本班次已经出现过。 本席上一条中其余两条裁决不变,并已写进 #8816:
本 PR 不因此改动,继续按已武装的 auto-merge 落地。 Generated by Claude Code |
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Fixes #8441
The
repeaterentry ingetCellRenderer's standard cell-renderer table counted its rows with a hardcoded Chinese unit word. Two rules broke on that one literal — AGENTS.md #-1 (all user-facing text MUST be English) and, the half a translation to English would have left standing, it bypassed i18n entirely: every reader on every locale read it, English ones included, beside the English siblings[Vector],[Grid]andFileCellRenderer'sN fileson the same detail page.Per the PM ruling on the card, this routes through the i18n channel rather than making the literal English.
FileCellRenderermoves with it.The ruling's premise — measured on
origin/mainbefore a line was writtenThe ruling was conditional: entries in that table are plain functions
({ value }) => …, so if the call site invoked them as plain calls, any hook would violate the rules of hooks and crash. Premise HELD, on both halves:1. Entries in that table already call hooks. Measured with a lit control on the same command shape (
grep -nE '\buse[A-Z][A-Za-z]*\(' packages/fields/src/index.tsx; controlgrep -nEc 'CellRendererProps'= 26, non-zero, so the reading is a reading and not an empty search):date:/datetime:(DateCellRenderer,DateTimeCellRenderer)useFieldTranslate()— the very channel this card routes intoimage:/avatar:/signature:(ImageCellRenderer)useObjectTranslation()formula:/summary:(FormulaCellRenderer)useFieldTranslate()number:/slider:/rating:(NumberCellRenderer)useDisplayLocale()2. The call-site mechanism, read rather than assumed. Every site resolves the component and then renders it as a JSX element — React instantiates it, so hooks are legal:
packages/plugin-detail/src/DetailSection.tsx:381—const CellRenderer = getCellRenderer(resolvedType), then a JSX elementCellRendererwithvalue/fieldprops (angle brackets omitted throughout — the GitHub body sanitizer eats short tag-shaped fragments, backticks included)packages/plugin-dashboard/src/recordFields.tsx:427—const Renderer = getCellRenderer(…), then a JSX elementRendererwithvalue/fieldpropspackages/fields/src/widgets/lookupColumnDisplay.tsx:215—const Renderer = cellRenderer(descriptor.type), then a JSX elementRendererwithvalue/fieldpropspackages/plugin-detail/src/DetailView.tsx:1159— resolved intoChipCellRenderer, rendered as an elementA search for a plain-call shape across
packages/apps/examplesreturned one hit, and it is a function declaration in an unrelated test, not a call.renderFieldValueis called plainly (ObjectDataTable.tsx:886,RecordDetailDrawer.tsx:111) but it only returns a JSXRendererelement; React still mounts the component.What changed
packages/fields/src/index.tsx— therepeaterentry becomes a module-levelRepeaterCellRendererreadingdetail.repeaterItemCount;FileCellRendererreadsdetail.fileCount. Both use the literal-key + English-fallback shapeUnresolvedUserReferencealready uses in this file, socheck:i18n-keyscan judge the key and its interpolation arguments.detail.repeaterItemCountanddetail.fileCount, each a real i18next plural family: base +_one+_other. The base key is load-bearing (objectui#3863) — i18next asksIntl.PluralRulesfor the one suffix a number needs and, finding no slot, walksfallbackLngtoen, so without it a Russian reader gets English at counts 2-20.2 filesstays2 files), but the rendered text of arepeatercell changes for every reader, so this is a released change.Two decisions worth naming:
The
repeaterentry stops being an inline arrow.getCellRendererrebuildsstandardMapon every call and both call sites resolve inside render, so an inline entry is a fresh component type per render: React would unmount and remount the cell each time and tear down react-i18next's language subscription with it. A pin asserts the reference is stable, with a control (vector, still inline and deliberately untouched) proving the table really is rebuilt per call — without it the stability pin would also pass on a memoised resolver and say nothing.The hook in
FileCellRendereris hoisted above itsif (!value)early return. That guard was the function's first statement; a hook after it would be skipped for an empty value and desync hook order between renders.No export was added —
RepeaterCellRendereris module-local, so@object-ui/fields' published surface is unchanged.Tests, and the reverse verification
packages/fields/src/__tests__/cellRenderers.countLabelI18n-8441.test.tsx, 16 pins. Every one reads rendered DOM text, never source text, and renders through the production shape (resolve, then render as an element). A lit control rendersdetail.unresolvedReference— a key that resolved through this sameuseFieldTranslatebefore this card — underruand asserts the Russian value, so a blank or English result anywhere else cannot be read as a pass.Reverse verification ran four legs, each proving the mutation reached disk before any result was read (anchored
grep -con both the removed and the injected text) and proving the restore (git hash-objectequal to theHEADblob andgit diff HEADempty). Restores aregit checkout HEAD -- ABS_PATH, never a bare checkout, which would restore from the index — the mutation itself — and exit 0 saying nothing. The harness carriedtrap … EXIT INT TERMwith absolute paths.FileCellRendereronlyrubase keyexpected '2 items' not to be '2 items'— objectui#3863 reproduced liveLeg D was run twice. The first run reddened on a
TypeErrorin the pin's own oracle (reading the deleted pack key) and said nothing about the DOM, so the render-side assertion was moved ahead of the oracle read and the leg re-run; the quoted verdict is the second run. A pin that dies on its own oracle is red without being evidence.Gates — each quoted from its own verdict line
vitest packages/fields/ packages/i18n/Test Files 213 passed (213)·Tests 3652 passed (3652)vitest packages/plugin-detail/ packages/plugin-dashboard/(the two packages that render these cells)Test Files 253 passed (253)·Tests 2279 passed (2279)turbo run type-check(fields, i18n)Tasks: 12 successful, 12 total—@object-ui/fields:type-checkrunstsc --noEmit && tsc -p tsconfig.test.json, so the new pin file is type-checkedturbo run lint— whole repo, all 47 packages, not narrowedTasks: 47 successful, 47 total, exit 0; every package reports0 errors(warnings are pre-existing, and none of the new code adds one beyond this file's existingno-explicit-anystyle)check:i18n-keysEvery in-scope call-site key resolves against the en pack (2990 keys) …exit 0check:i18n-drift0 en value(s) changed (6 key(s) added, 0 removed …)exit 0check:i18n-dead-keyscheck:control-bytesOK (scanned 7008 tracked text file(s); skipped 85 binary)check-changeset-presence11 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)check:eager-closureapps/consolebuild — see the risk note below. The first run exited 2 withNo eager-closure report … This is a broken gauge, not a passing budget; that is a missing prerequisite, not a red measurement, so the console was built and the gate re-run.check-governed-queue-guard --teston the 13 changed paths✅ NOT GOVERNED — 13 path(s) checked against 5 governed surface(s); none matched.AGENTS.mdas a lit control on the same command shape⛔ GOVERNED — 1 of 1 path(s) are on a governed surface(exit 3) — so the NOT GOVERNED above is a reading, not an empty runi18n-localesceiling is nearly spentcheck:eager-closurepasses, but barely:The ten pack files' own gzipped source grew 783 bytes in this PR (536,883 → 537,666 at
-9; an approximation of the chunk delta, since the chunk is minified and bundled). So this change consumed most of what was left, and the ceiling was already close before it. Two consequences:Shrinking the families to base +
_one(dropping_other, which i18next resolves to the base anyway —common.objects/common.objects_onealready does that) would buy roughly 260 bytes. It was not taken: base +_one+_otheris the shape theenpack's own governing note blesses as "A REAL i18next plural family", and trading convention clarity for a ceiling this PR does not own is the wrong direction. Stated so the choice is reviewable rather than silent.验收备注
Observations that are not filed and not fixed here, each with the successor that will meet it:
[Vector]/[Grid]in the same table are still bare literals, and are still inline arrows. A different class from this card — type placeholders, not count phrases — the card measured neither, and the ruling fenced them off explicitly. Successor: any card that decides whether renderer-internal type placeholders are user-facing copy at all; thevectorentry is also the live control in this PR's stability pin, so a future card converting it must replace that control rather than delete it.FileCellRenderer'sisMultiple(const isMultiple = fileField.multiple) is computed and never read. Dead code, pre-existing, untouched — deleting it is not this card's business and would widen the diff on a file with a PR in flight. Successor: the next card that editsFileCellRenderer's body.i18n-localesceiling headroom is 0.5 KB (see the risk section). Not filed: the gate is green, so there is no reproducible defect, no violated declared contract, and no metadata trap — none of the three filing classes. Successor: the next i18n card that adds pack keys, which the gate itself will stop.repeater's empty state isEmptyValue, andfile's zero-length array states0 files. Two neighbouring cells still answer "nothing here" two ways. Deliberately untouched: objectui#8481 and objectui#8490 measured and kept that split, and existing pins hold it. Successor: whichever card reopens the empty-cell family, if any does.维护者速读(草稿)
改了什么 — 明细页上
repeater单元格原本把行数写成硬编码的中文量词,任何语种的读者(包括英文用户)看到的都是这一串中文,而它旁边的[Vector]、[Grid]、N files都是英文。现在它和FileCellRenderer的文件计数一起,走本文件早已导入、且已有四个单元格在用的 i18n 通道,十个语言包各加两组复数键。英文渲染结果逐字节不变。为什么改 — 卡片同时点出两条规则:AGENTS.md #-1(用户可见文本必须英文)和「完全绕过 i18n」。只把字面量改成英文只修了第一条,第二条会原封不动地留下来,保证在同一行上再开一张卡。PM 裁定走通道。
FileCellRenderer一并处理:它是英文、不违规,但同样未本地化,而且只对英语复数安全 —— 俄语四种复数形态、阿拉伯语六种,一个二选一三元表达式一种都拼不出来。两个相邻单元格对同一个概念给两种答案,本来就是要一次收掉的形状。风险与代价(含回滚) — 风险有两处,都已量化写在上面:①
i18n-locales打包体积上限只剩 0.5 KB 余量,本次吃掉约 783 字节(源码 gzip 口径),下一张加语言包键的卡会撞上限,抬上限归维护者;② 若有兄弟 PR 抢先合入并新增语言包键,本 PR 可能在合并队列里因体积上限变红,那是算术碰撞,不是本 diff 的缺陷。回滚成本很低:整个改动是一个提交,revert 即可;英文渲染本来就逐字节不变,回滚不影响英文用户,只是把非英语用户退回到看中文量词的状态。已发布表面没有扩张(没有新增导出),所以回滚不会留下悬空的导出。席位意见 — (留空,交 PM 席位定稿)
你要做的 — 一是判断
i18n-locales的上限要不要抬、什么时候抬;二是确认十个语言包里的译文用词可接受(俄语基础键按unsavedCount的「属格 + 冒号 + 数字」写法,日/韩/中三种语言的复数形态合一,阿拉伯语基础键采用「单数(复数)」并列写法,都对齐了包内既有先例);三是[Vector]/[Grid]这类类型占位符到底算不算用户可见文案 —— 本次按裁定没有碰,需要一个方向再另开卡。🤖 Generated with Claude Code
https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH
Generated by Claude Code