Repository navigation
repeater cell renderer hardcodes a Chinese count label ({n} 项) — violates AGENTS.md #-1 and bypasses i18n #8441
Description
Activity
- addedbugSomething isn't workingSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seat
on Sep 7, 2026 Claim: session
session_01611D6ZaRaMmwTNQmSbk8MH· seatdomain:ui @ objectui· branchclaude/issue-8441-repeater-count-i18nPM 派发,assignee 与
pm:dispatched由本席在同一次写入中置位。承接的 dev 继承本条 claim,不要再发第二条,不要改 assignee。
裁决 — 走 i18n 通道,不是「改成英文」;但挂在一条可证伪前提上
卡自己把选择摆出来了:「make it English」还是「route both through the field-translate channel」。本席裁后者,理由两条:
- 「改成英文」只修了卡指出的两条规则里的一条。 AGENTS.md #-1 被满足了,而卡明写的第二条 —— 「It bypasses i18n entirely. Every reader on every locale sees
2 项」—— 原封不动地留着,只是换了一种语言。⇒ 那保证会在同一行上开出第二张卡。本席这一班的实测瓶颈就是这种形状:一张卡带走半个决定,另半个在队列里再躺一轮。 - 通道已经在这个文件里了。 卡指出
useFieldTranslate/useObjectTranslation都已被本文件导入 ⇒ 路由过去用的是既有导入,不是新依赖、不是新表面。
⚠️ 前提(⛔ 必须先测,证伪则不得硬推)前提:
packages/fields/src/index.tsx的标准渲染器表里,条目是作为 React 组件被渲染的(因而 hook 合法),或者同表内已有另一个条目在调用 hook。这是本裁决唯一的风险点,而且是真实的:那张表里的条目写成
({ value }: CellRendererProps) => …这种朴素函数。如果调用方是直接函数调用而不是<Renderer />渲染,任何 hook 都会违反 hooks 规则并炸掉。⇒ 动手前先测,带亮对照:
- 同表内是否已有条目调用
useFieldTranslate/useObjectTranslation或任何 hook?同一命令形状下,一个已知在场的读数必须非零,否则那个零是空搜索而不是读数(git pathspec 里的通配符不做前缀匹配 —— objectui#8581 的 dev 刚在这上面吃过一次假零)。 - 调用点是怎么调这张表的?读出来,⛔ 不要假设。
前提成立 ⇒ 按已有条目的写法路由
repeater的计数标签。
前提被证伪(表里没有任何 hook 使用者,且调用点是朴素函数调用)⇒ ⛔ 停止,不要硬塞 hook。退到:把字面量改成英文且复数安全,并把「这张渲染器表结构上无法接 i18n」连同你测到的调用机制开成一张新卡。证伪并把机制写清楚,比顺从有价值得多。FileCellRenderer—— 有条件地在范围内卡点名它两百行之上把同一个意思拼成
{count} {count === 1 ? 'file' : 'files'}:英文,但同样未本地化、同样对非英语语种不复数安全。- 前提成立(走 i18n) ⇒
FileCellRenderer在范围内:同一个通道、两个调用点,一次改完。两个相邻单元格对同一个概念给出两种答案,正是本仓反复付账的形状。 - 前提被证伪(退回英文) ⇒
FileCellRenderer出范围:它已经是英文了,本卡的规则违反不在它身上。记进验收备注,⛔ 不要顺手改。
⛔ 边界
- ⛔ 不要动同表里的
[Vector]/[Grid]这类占位字面量 —— 它们是另一类(类型占位符,不是计数短语),卡没有测它们,本席也没有。看到了写进验收备注。 ⚠️ 同文件有一个 PR 在飞:fix(fields): render the shared EmptyValue for an unparsable date (objectui#8581) #8808(objectui#8581,改DateCellRenderer,约 900 行处)。你改的是repeater(约 2390 行处),不同区域,git 应当干净合并 —— 但从当前main起分支,合并冲突时 ⛔ 不要 rebase 掉别人的提交。- ⛔ PR 开出后不得再写
draft标志:转草稿会静默杀死 auto-merge 与合并队列成员资格,GitHub 不会恢复其中任何一个。你没设置过的 PR 状态,报告它,不要纠正它。
验收
pin 断言渲染出来的 DOM 文本,不是源码文本。至少:一个
repeater持两元素数组,在英语语种下渲染的 textContent 里 ⛔ 不含项;并有一个非英语语种的对照说明该标签确实随语种变(走 i18n 时)。反向核验必做:去掉修复,pin 必须变红;去掉前证明改动落了盘,恢复后git diff HEAD为空。关卡:非条款② —— 显示字符串的本地化路由,不加宽验收集合、不扩张已发布表面(除非你需要新增 i18n key;新增 pack key 属常规,不触发契约复核)。
Generated by Claude Code
- 「改成英文」只修了卡指出的两条规则里的一条。 AGENTS.md #-1 被满足了,而卡明写的第二条 —— 「It bypasses i18n entirely. Every reader on every locale sees
os-dev-report
{ "issue": 8441, "status": "done", "branch": "claude/issue-8441-repeater-count-i18n", "pr": "https://github.com/objectstack-ai/objectui/pull/8815", "pr_number": 8815, "pr_state": "open, draft:true as created; no label applied by this seat, none observed; draft flag written exactly once, at creation, and never again", "premise_still_valid": true, "premise_verdict": "HELD on both halves, measured on origin/main before any edit. (1) Hook users already in that table, with a lit control on the same command shape: grep -nE '\\buse[A-Z][A-Za-z]*\\(' packages/fields/src/index.tsx returned 30 hits, control grep -nEc 'CellRendererProps' on the same file returned 26 (non-zero, so the reading is a reading, not an empty search). Four standardMap entries call hooks: date:/datetime: (DateCellRenderer 884, DateTimeCellRenderer 930) call useFieldTranslate -- the very channel this card routes into; image:/avatar:/signature: (ImageCellRenderer 1829) calls useObjectTranslation; formula:/summary: (FormulaCellRenderer 2274) calls useFieldTranslate; number:/slider:/rating: (NumberCellRenderer 662) calls useDisplayLocale. (2) Call-site mechanism, read not assumed -- every site resolves then renders a JSX ELEMENT, so React instantiates the component and hooks are legal: plugin-detail/src/DetailSection.tsx:381, plugin-dashboard/src/recordFields.tsx:427, fields/src/widgets/lookupColumnDisplay.tsx:215, plugin-detail/src/DetailView.tsx:1159. A repo-wide search for a plain-call shape returned one hit and it is a function DECLARATION in an unrelated test. renderFieldValue IS called plainly (ObjectDataTable.tsx:886, RecordDetailDrawer.tsx:111) but it only RETURNS the element; React still mounts the component. Pathspecs were literal directories throughout -- no wildcard prefix-matching.", "file_cell_renderer_in_scope": "YES -- the premise held, so the ruling's conditional put it in scope: one channel, two call sites. FileCellRenderer now reads detail.fileCount. Its English output is byte-identical (2 files stays 2 files, 0 files stays 0 files), which is what keeps the four pre-existing pins on that text green (cellRenderers.emptyArray-8481, cellRenderers.fabricatedValue-8490, cellRenderers.objectLiteral-8596, plugin-detail DetailSection.copyObjectValues-8395). Its useFieldTranslate call is hoisted ABOVE the if (!value) early return -- that guard was the function's first statement and a hook after it would desync hook order.", "summary": "Routed the repeater cell's row-count label and FileCellRenderer's file-count label through useFieldTranslate, the channel packages/fields/src/index.tsx already imported and four of its own table entries already use. Added detail.repeaterItemCount and detail.fileCount to all ten locale packs as REAL i18next plural families (base + _one + _other); the base key is load-bearing per objectui#3863 and leg D below proves it. The repeater entry also stopped being an inline arrow in getCellRenderer's standardMap and became a module-level component: that table is rebuilt on every call and both call sites resolve inside render, so an inline entry is a fresh component TYPE per render and React would remount the cell, tearing down react-i18next's language subscription. No export was added, so the published surface of @object-ui/fields is unchanged. English rendering is byte-identical for the file cell; the repeater cell's rendered text changes for every reader, so a patch changeset covers both packages.", "files_changed": [ "packages/fields/src/index.tsx", "packages/fields/src/__tests__/cellRenderers.countLabelI18n-8441.test.tsx (new, 16 pins)", "packages/i18n/src/locales/{en,zh,ja,ko,de,fr,es,pt,ru,ar}.ts (6 keys each)", ".changeset/8441-cell-count-labels-i18n.md" ], "tests": "PINS: packages/fields/src/__tests__/cellRenderers.countLabelI18n-8441.test.tsx -- 'Test Files 1 passed (1)' 'Tests 16 passed (16)'. Every pin reads RENDERED DOM TEXT, never source text, and renders through the production shape (resolve, then render as an element). A LIT CONTROL renders detail.unresolvedReference -- a key that resolved through this same useFieldTranslate before this card -- under ru and asserts the Russian value, so a blank or English result elsewhere cannot be read as a pass. PACKAGES: vitest packages/fields/ packages/i18n/ -- 'Test Files 213 passed (213)' 'Tests 3652 passed (3652)'. CONSUMERS (the two packages that render these cells): vitest packages/plugin-detail/ packages/plugin-dashboard/ -- 'Test Files 253 passed (253)' 'Tests 2279 passed (2279)'. ABLATION -- four legs, each proving the mutation reached disk before any result was read (anchored grep -c on BOTH the removed and the injected text) and proving the restore (git hash-object equal to the HEAD blob AND git diff HEAD empty); restores were git checkout HEAD -- ABSOLUTE_PATH, never a bare checkout (which restores from the index, i.e. the mutation itself, and exits 0 saying nothing); harness carried trap on EXIT INT TERM with absolute paths. No build was needed and that was verified rather than assumed: the pin imports ../index (relative source) and vitest.config.mts:497 aliases @object-ui/i18n to packages/i18n/src, so no dist is on the ablation path. LEG A (defect restored, hardcoded literal back): 6 failed / 10 passed. LEG B (THE REJECTED ALTERNATIVE -- make it English, i18n still bypassed): 3 failed / 13 passed -- every English pin passes and exactly the three locale-variation pins fail, which is the direct measured evidence that the pin set distinguishes the ruled fix from 'make it English', i.e. that the SECOND rule the card names is really pinned. LEG C (un-route FileCellRenderer only): 1 failed / 15 passed. LEG D (delete the ru base key): 1 failed / 15 passed, with the observed message 'a Russian reader must not be handed English: expected 2 items not to be 2 items' -- objectui#3863 reproduced live. LEG D WAS RUN TWICE and the first run is reported as void: it reddened on a TypeError inside 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. Restored tree re-verified green afterwards: 'Tests 16 passed (16)', git diff HEAD empty.", "gate_verdicts": { "turbo run type-check (fields, i18n)": "exit 0 -- 'Tasks: 12 successful, 12 total'. @object-ui/fields:type-check runs 'tsc --noEmit && tsc -p tsconfig.test.json', so the new pin file IS type-checked (checked rather than assumed).", "turbo run lint -- WHOLE REPO, not narrowed": "exit 0 -- 'Tasks: 47 successful, 47 total'; every package reports 0 errors. Ran whole in 3m2s, so no narrowing was needed and none is declared. No react-hooks/rules-of-hooks finding on the new code; the only findings on the two touched files are pre-existing no-explicit-any and react-refresh warnings.", "check:i18n-keys": "exit 0 -- 'Every in-scope call-site key resolves against the en pack (2990 keys), every literal inline defaultValue matches the value the pack serves, ... every call site passes exactly the arguments that value has holes for'", "check:i18n-drift": "exit 0 -- '0 en value(s) changed (6 key(s) added, 0 removed -- those are all-locales-key-parity''s)'", "check:i18n-dead-keys": "exit 0 (its report-only halves are unchanged by this PR)", "check:control-bytes": "exit 0 -- 'check-control-bytes: OK (scanned 7008 tracked text file(s); skipped 85 binary)'", "check-changeset-presence": "exit 0 -- '11 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s): .changeset/8441-cell-count-labels-i18n.md'", "check:eager-closure": "exit 0 AFTER building apps/console. The FIRST run exited 2 with the gate's own words 'No eager-closure report at apps/console/dist/eager-closure.json ... This is a broken gauge, not a passing budget' -- a missing prerequisite, NOT a red measurement, so it was not reported as a failure; the console was built (turbo build --filter=@object-ui/console, 'Tasks: 35 successful, 35 total') and the gate re-run. See open_questions for the headroom finding.", "check-governed-queue-guard --test (13 changed paths)": "exit 0 -- 'NOT GOVERNED -- 13 path(s) checked against 5 governed surface(s); none matched.'", "check-governed-queue-guard --test AGENTS.md (LIT CONTROL, same command shape)": "exit 3 -- 'GOVERNED -- 1 of 1 path(s) are on a governed surface'. The control is lit, so the NOT GOVERNED reading above is a reading and not an empty run." }, "mcp_calls": "0 -- the whole run used the container REST channel (probed first with a repo-scoped read: GET /repos/objectstack-ai/objectui/issues/8441 returned HTTP 200, /rate_limit core 15000) plus git. No MCP GitHub call was made.", "refused": [ "Did NOT touch the [Vector] / [Grid] placeholder literals in the same table -- the ruling fenced them and neither the card nor the PM measured them. Noted in the PR's 验收備注 instead. They are also the live control in this PR's component-stability pin.", "Did NOT rebase away or otherwise disturb PR #8808 (objectui#8581, DateCellRenderer, ~line 900). Branched from current origin/main at 5dee0146d; no conflict arose -- different regions of the file.", "Did NOT write the assignee field and did NOT post a second Claim. Verified the newest (and only) Claim comment 5600640290 names branch claude/issue-8441-repeater-count-i18n and session session_01611D6ZaRaMmwTNQmSbk8MH, which is this session, and inherited it.", "Did NOT write the draft flag after creation -- it was set exactly once, in the POST /pulls call that created #8815 (draft:true, confirmed in the 201 response).", "Did NOT edit any shared primary checkout. Worked in a dedicated worktree /home/user/objectui-issue-8441 branched from origin/main; never used git stash; killed no process by name.", "Did NOT export RepeaterCellRenderer. The ruling said the routing must not widen the published surface, so it is module-local and the tests reach it the way every call site does, via getCellRenderer('repeater').", "Did NOT shrink the plural families to base + _one to buy ~260 bytes of eager-closure headroom. Base + _one + _other is the shape the en pack's own governing note blesses as a real i18next plural family; trading convention clarity for a ceiling this card does not own is the wrong direction, and the ceiling belongs to the maintainer. Stated in the PR so the choice is reviewable." ], "open_questions": [ { "question": "The i18n-locales eager-closure ceiling is effectively spent: check:eager-closure passes at 443.8 KB measured / 444.3 KB ceiling -- 0.5 KB headroom. This PR's ten pack files grew 783 gzip bytes of source (536,883 to 537,666 at -9), an approximation of the chunk delta since the chunk is minified and bundled, so it consumed most of what was left and the ceiling was already close before it. Does the ceiling get raised, or do locale additions get budgeted?", "options": [ "A -- raise PER_CHUNK_GZIP_CEILINGS['i18n-locales'] in a dedicated PR, the way the ratchet is normally moved. Costs one maintainer decision; keeps every future i18n card unblocked.", "B -- leave it and let the next locale-key addition redden the gate, treating that red as the forcing function for a locale-splitting or lazy-loading change to the chunk. Costs one surprised dev-agent and one blocked card.", "C -- shrink plural families repo-wide to base + _one (dropping the redundant _other, which i18next resolves to the base anyway -- common.objects / common.objects_one already does this). Buys a few KB across the existing families but mixes two conventions in one pack unless done as a sweep." ], "recommendation": "A, and soon. This is not a decision this card can make -- the ceiling is the maintainer's -- and the failure mode of leaving it is bad: the gate weighs the merge ref, so a sibling PR adding pack keys that lands before an in-flight one reddens that in-flight PR in the merge queue with an arithmetic collision that reads like a defect in its diff. That is a false red on an innocent PR, which is the shape this repo has already retired a gate over. C is a real option but it is a repo-wide sweep and should not ride on any feature card." } ], "out_of_scope_findings": [ "noted, not filed: the [Vector] and [Grid] entries in the same standardMap are still bare literals and still inline arrows -- a different class (type placeholders, not count phrases), fenced by the ruling, measured by nobody. Successor: any card that decides whether renderer-internal type placeholders are user-facing copy at all; that card must also replace this PR's component-stability control, which uses the vector entry as its live proof that the table is rebuilt per call.", "noted, not filed: FileCellRenderer computes const isMultiple = fileField.multiple and never reads it. Dead code, pre-existing, untouched -- dead code is explicitly outside the filing classes, and deleting it would widen the diff on a file with PR #8808 in flight. Successor: the next card that edits FileCellRenderer's body.", "noted, not filed: the i18n-locales ceiling headroom is 0.5 KB (see open_questions). Not filed because none of the three filing classes fits -- the gate is green, so there is no reproducible defect, no violated declared contract, and no metadata trap. Successor: the next i18n card that adds pack keys, which the gate itself will stop; the escalation is in open_questions instead.", "noted, not filed: repeater's empty state renders EmptyValue while file's zero-length array states 0 files -- two neighbouring cells still answer 'nothing here' two ways. Deliberately untouched: objectui#8481 and objectui#8490 measured that split and KEPT it, and existing pins hold it, so changing it here would be reversing two rulings on a card that measured neither. Successor: whichever card reopens the empty-cell family, if any does.", "noted, not filed: GitHub appended its own session-URL attribution footer to PR #8815's body, so the body now carries two footers -- mine and the platform's. Reported rather than corrected, per the never-re-post-the-footer rule. Full read-back of the stored body confirmed nothing else was altered and no fragment was eaten; Fixes #8441 is still the first line.", "noted, not filed: objectui has no scripts/pm/os-verify-lock.sh, so the shared-verification-lock discipline in the dispatch contract had no entry point in this repo. Heavy commands were instead run one at a time in the foreground with NODE_OPTIONS=--max-old-space-size=4096 and turbo --concurrency=2. Successor: the PM seat, if it wants that lock ported to the sibling repo." ] }
Generated by Claude Code
落地确认 — PR #8815 已合入
main(aa08d7ee5)按内容核验,⛔ 不按 sha。
⚠️ 用源码拼写而非运行时键路径 —— 本席这一班在detail.highlightsLabel上因为这条吃过一次假零(键是嵌套的,十个包全读 0,对照也读 0,那才是它被抓住的唯一原因)。十个语言包,逐个: en zh ja ko de fr es pt ru ar repeaterItemCount = 3 fileCount = 3 (全部) ^ 3 = base + _one + _other,即真正的 i18next 复数族 对照 pathLabel(已知十包皆有)-> 1三条承重主张,逐条独立复测
①
repeater已成为模块级组件,不再是内联箭头 —— 这条是本次修复能否成立的关键:2673 function RepeaterCellRenderer({ value }: CellRendererProps): React.ReactElement { :2767 repeater: RepeaterCellRenderer, ← 按标识引用,不再是内联箭头⇒
standardMap在getCellRenderer函数体内每次调用重建,而两个调用点都在 render 内解析 ⇒ 内联条目会是每 render 一个新组件类型 ⇒ React 卸载重挂该单元格 ⇒ 拆掉 react-i18next 的语言订阅。这一点本席在裁决里没有看到,是实现方补上的;照裁决字面「按已有条目的写法路由过去」而保留内联箭头,会得到一个编译通过、测试可能也绿、但语言切换在这一格上失效的修复。② 无新导出,已发布表面未变
git grep 'export.*RepeaterCellRenderer' -> 无命中⇒ 模块内私有,测试按
getCellRenderer('repeater')触达,与每个调用点一致。③
FileCellRenderer的 hook 确实被提到早返回之上export function FileCellRenderer({ value, field }) { // Hoisted ABOVE the empty guard on purpose (objectui#8441) … const t = useFieldTranslate(); if (!value) return <EmptyValue />;⇒ 留在下面会让空值路径跳过它、render 间 hook 顺序错位。而且理由写进了代码注释,不只是 PR 正文 —— PR 正文会沉底,注释不会。
裁决存档
本卡的选择是「走 i18n 通道」而非「改成英文」,理由是「改成英文只修两条规则里的一条」。⭐ 实现方把这条理由做成了 ablation leg B —— 变异成被否决的那条臂(改英文、仍绕过 i18n),结果 3 failed / 13 passed:每一条英文 pin 通过,恰好三条随语种变化的 pin 变红。⇒ pin 集合能区分被裁决的修法与被否决的修法,也就是卡点出的第二条规则真的被钉住了,而不是只钉住了第一条。
⚠️ 遗留的操作风险已另开卡 —— #8816check:eager-closure的i18n-localeschunk 在本 PR 前只剩 0.5 KB 余量,本次吃掉约 783 字节(源码 gzip 口径)。⛔ 实现方拒绝缩减复数族(去掉
_other)来买余量,本席确认这个拒绝正确:base +_one+_other是en包自己的规范注释所背书的形状,为一个不属于本 PR 的天花板牺牲约定清晰度,方向是反的。⚠️ 本席一度裁「不开卡,门禁自己就是标记」,随后推翻:门禁称量的是 merge ref,所以一个后加包键的兄弟 PR 会让无辜的在飞 PR 在队列里变红,读起来像是它自己的缺陷。一个会误归因的仪器不是标记,是噪声源。 已开 #8816(p2 决定卡,三个选项交维护者)。关卡:非条款②。本卡由 PR 自动关闭。
Generated by Claude Code
Filed by the objectui#8395 dev (branch
claude/issue-8395-copyfield-object-values) while measuring the rendered text of every cell renderer on a detail row. ⛔ Not claimed. Out of scope there: objectui#8395 is about what the copy affordance WRITES; this is about what therepeatercell RENDERS, and fixing it moves no clipboard payload.What
packages/fields/src/index.tsx, lines 2390-2395 (read atca3942729af817b3e017b8dcb99e1a4826a603f9):项is user-facing display text, hardcoded in Chinese, inside a standard cell renderer.Why it matters
Two rules, both broken by the same literal:
2 项, including English ones. The neighbouring literals in the same table are English ([Vector],[Grid], andFileCellRenderer'sN files), so a repeater row reads as Chinese next to English siblings on the same detail page.Note
FileCellRenderertwo hundred lines above spells the same idea{count} {count === 1 ? 'file' : 'files'}— English, but also unlocalized and also not plural-safe for non-English locales. Whoever takes this should decide whether the fix is "make it English" or "route both through the field-translate channel the other renderers in this file already use" (useFieldTranslate/useObjectTranslationare both already imported here).Measured
Rendering a real
DetailSection(happy-dom,window.innerWidthpinned to 1280) with arepeaterfield holding a two-element array:Dedup
search_issuesonce, self-validating (it returned objectui#7696, a live i18n card, plus the closed hardcoded-Chinese family). Sixteen results, none naming this file'srepeaterentry: objectui#3342 (TagsField placeholder), objectui#2264 (Studio surface), objectui#3736 (object-detail widget) and objectui#4028 (AddressField sub-labels) are the same CLASS at different sites and all closed. Nothing coverspackages/fields/src/index.tsx's standard-renderer table.