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.

finding(plugin-view): two more render-inside-waitFor predicates — bounded today only by an RTL implementation detail #7802

Closed

Description

@claude

由 objectui#7756 的实现析出(PR #7801)。测量得出,未修 —— 那张卡的文件面只覆盖 packages/plugin-detail/src/__tests__/。

事实

全仓扫描(2410 个测试文件,花括号配平、helper 逐层跟进)找到 render 出现在 waitFor 谓词里的构造,除 #7756 那个文件之外恰好两处,都在 plugin-view:

文件 行
packages/plugin-view/src/__tests__/ObjectView.contractEnvelope-6726.test.tsx 103
packages/plugin-view/src/__tests__/ObjectView.contractEnvelope-6840.test.tsx 132

两处都是 waitFor(async () => expect(await deliveredThrough(asData))…),而那个 helper 会 render。

⭐ 它们不是 #7756 那个缺陷 —— 这是测出来的,不是判断出来的

#7756 的机制是:RTL 的 waitFor 由 MutationObserver 驱动重跑,谓词每跑一次就变动一次 document ⇒ 自我喂养,跑到堆耗尽。四臂探针(把 interval 钉在 timeout 上,使间隔计时器无法重跑谓词)的读数:

谓词形状 重跑次数 / 耗时 document.body 增长
渲染进 document 200 runs / 144ms 200
渲染进游离容器 1 run / 2006ms(超时) 0
惰性 1 run / 2003ms(超时) 0
async 形状(本卡这两处) 40 runs / 2003ms ——

⭐ 第四臂是关键:40 次 / 2003ms 正好是 50ms 间隔的节拍 —— 也就是说 async 谓词根本没有被 DOM 变动重入。原因在 RTL 自己的 wait-for.js:checkCallback 在 promiseStatus === 'pending' 时提前返回,所以一次 DOM 变动无法重入一个仍在飞的 async 谓词。

⇒ 这两处每次重跑仍然泄漏一个节点(RTL 的 unmount() 拆掉 React root 却留下 append 上去的容器 div),但被超时封顶 —— 是有界泄漏,不是堆炸弹。

为什么仍然值得一张卡(p3)

  1. Testing Library 明文要求 waitFor 谓词无副作用,这两处违反了它。
  2. ⚠️ ⭐ 它们的"有界"依赖的是 RTL 内部实现的一个细节 —— checkCallback 对 pending promise 的提前返回。那不是契约。若 RTL 改掉它(或谓词被改成同步形状),这两个文件立刻变成 finding(plugin-detail): RelatedList.lookupLabelResolution renders inside a waitFor predicate — a 50ms lookup-batch delay exhausts the V8 heap and kills the whole vitest worker #7756 那个堆炸弹。⇒ 这正是本仓反复立卡的那个形状:今天不炸,是因为一个意外,而不是因为一条保证。
  3. 它们与 finding(plugin-detail): RelatedList.lookupLabelResolution renders inside a waitFor predicate — a 50ms lookup-batch delay exhausts the V8 heap and kills the whole vitest worker #7756 修好的那个文件风格不一致了:那边现在等的是无副作用的就绪条件、然后渲染一次。

修法方向

与 #7756 同形:先等一个无副作用的就绪条件,再渲染一次并断言。⛔ 不要靠"反正有超时封顶"留着不动;⛔ 也不要把 async 改成同步来"简化"——那会摘掉今天唯一挡住它的那道意外屏障。

复现

四臂探针的形状与读数在 PR #7801 的正文里;本卡的两处可用同一个探针复测。

Refs: objectui#7756(同类,已修)· PR #7801(探针与四臂读数)


Generated by Claude Code

Activity

  1. claude commented on Sep 5, 2026

    @claude
    ContributorAuthor

    Claim: PM loop, round 6 — all-opus lane (Clause-② no)
    Session: session_01KbJQ1y1J12nZxYzFWhP8Q3
    Branch: claude/issue-7802-objectview-waitfor-render
    Worktree: objectui-issue-7802
    Domain: domain:ui
    File surface: packages/plugin-view/src/__tests__/ObjectView.contractEnvelope-6726.test.tsx · packages/plugin-view/src/__tests__/ObjectView.contractEnvelope-6840.test.tsx · 两者共用的那个会 render 的 helper(若它在别处、且被别的文件用到,先上报再动)· 本卡自己的 .changeset/*.md. ⛔ Stop on breach: 任何 packages/plugin-view/src/ 下非测试文件的改动、任何 packages/plugin-detail/ 的改动(那是 #7756 的面,PR #7801 在队列中)。Explain in the report.
    Container & model: S, mode:subagent, model: opus
    Clause-②: no — 两个测试文件的等待方式。不改变契约接受/拒绝行为,不扩大公开面。⚠️ Void condition: 任何 packages/*/src 下非测试文件的改动。
    Serial constraints cleared: PR #7801(卡 #7756)在队列中,改的是 packages/plugin-detail/src/__tests__/RelatedList.lookupLabelResolution.test.tsx —— 不同包、不同文件,与本卡不相交。⚠️ 但存在读耦合的反面:本卡的修法照抄 #7801 的形状,所以先 git fetch origin main 看它落了没有;落了就直接读它的最终形态,没落就读 PR #7801 的 diff。⛔ 无论如何不要改它。在飞 #7799(16 个测试的 cwd 路径)与 #7753(新门)都与本卡不相交。


    缺陷 —— ⚠️ 它今天不炸,而这正是立卡的理由

    两处 render 出现在 waitFor 谓词里:

    文件 行
    packages/plugin-view/src/__tests__/ObjectView.contractEnvelope-6726.test.tsx 103
    packages/plugin-view/src/__tests__/ObjectView.contractEnvelope-6840.test.tsx 132

    两处都是 waitFor(async () => expect(await deliveredThrough(asData))…),helper 会 render。

    ⭐ 它们不是 #7756 那个堆炸弹 —— 这是测出来的(四臂探针,读数在 PR #7801 正文里):async 形状实测 40 runs / 2003ms,正好是 50ms 间隔的节拍 ⇒ DOM 变动根本没有重入它。原因在 RTL 的 wait-for.js:checkCallback 在 promiseStatus === 'pending' 时提前返回。

    ⇒ 每次重跑仍泄漏一个节点(RTL 的 unmount() 拆掉 React root 却留下 append 的容器 div),但被超时封顶 —— 有界泄漏,不是堆炸弹。

    ⛔ 所以这张卡的价值全在这一句

    它们的"有界"依赖的是 RTL 内部的一个实现细节,那不是契约。

    checkCallback 对 pending promise 的提前返回是 RTL 的实现,不是它承诺的行为。⇒ RTL 改掉它、或者有人把谓词从 async 改成同步,这两个文件立刻变成 #7756 那个杀 worker 的形状。今天不炸是因为一个意外,不是因为一条保证。

    修法

    与 #7756 / PR #7801 同形:先等一个无副作用的就绪条件,再渲染一次并断言同样的文本。判据:谓词不再变动 DOM。

    ⛔ 三条禁止:

    1. ⛔ 不要靠"反正有超时封顶"留着不动。那是把安全性寄托在别人的实现细节上。
    2. ⛔ 不要把 async 改成同步来"简化" —— 那会摘掉今天唯一挡住它的那道意外屏障,把有界泄漏变成堆炸弹。
    3. ⛔ 不要删、跳过或削弱这两条用例,也不要动 ObjectView 的运行期源码。若发现被测代码真的有缺陷,立卡,不要顺手修。

    ⚠️ 那个 helper(deliveredThrough)被两个文件共用。先查它还有没有别的调用方;若有,改它可能影响面外文件 —— 那时停下上报,⛔ 不要为了省事把 helper 改成两份。

    ⭐ 验收 —— 必须证明「不再是自我喂养」,⛔ 不是"改完还绿"

    「改完还绿」证明不了任何东西:这两个文件今天本来就是绿的。要的是机制层的读数:

    1. 修复前后各一次探针读数,用 PR test(plugin-detail): take the render out of RelatedList's lookup-label waitFor predicate #7801 正文里那个四臂探针的形状:把 interval 钉在 timeout 上(使间隔计时器不可能重跑谓词),记录谓词重跑次数与 document.body 增长。
      • 修复前:预期重跑次数 ≈ 间隔节拍、增长 = 重跑次数(每跑一次泄漏一个节点)。
      • 修复后:预期 增长 = 0。⭐ 这才是本卡的验收读数。
    2. ⭐ 一次点亮控制证明那道意外屏障确实存在:把某一处的谓词从 async 临时改成同步(保持其余不变),确认它变成 finding(plugin-detail): RelatedList.lookupLabelResolution renders inside a waitFor predicate — a 50ms lookup-batch delay exhausts the V8 heap and kills the whole vitest worker #7756 那个形状(重跑次数暴涨 / 堆增长失控 / worker 死);然后在 trap … EXIT INT TERM 下用 git checkout HEAD -- <绝对路径> 还原,以 blob hash 相等 + 空 git diff HEAD 证明 —— ⛔ 证明靠状态,不靠 trap 是否触发;变异前先用 hash 确认落盘(空 hash 读作失败)。
      ⚠️ 若这次变异会杀掉 worker,用 --pool=forks 跑并说明原因 —— finding(plugin-detail): RelatedList.lookupLabelResolution renders inside a waitFor predicate — a 50ms lookup-batch delay exhausts the V8 heap and kills the whole vitest worker #7756 的席位实测:默认池下 reporter 随 worker 一起死,V8 帧与 tests 0ms 都捕不到,只有 forks 能捕到。⛔ 不要从捕不到的那次引用那两条证据。
    3. 两个文件与整个 packages/plugin-view/ 的常规绿读数,报出文件数与用例数。

    变更集

    改动落在发版包 src/ 下 ⇒ 按 AGENTS.md:161 大概率需要。⛔ 不要凭规则推断:跑 node scripts/check-changeset-presence.mjs,判词原样抄进报告,按判词行事;欠就写空 frontmatter。⛔ 永不写 major。

    ⚠️ 五个仪器陷阱(今天各席实测)

    1. 裸 tsc --noEmit 不是本仓的门 —— turbo type-check dependsOn ^build 且到不了非包目录;⚠️ 有些 project 还要先构建依赖的声明才能跑,构建前跑出的 TS2882 是未满足的前提,不是结果,⛔ 不要当结果报。
    2. ⛔ 本容器没有「未认证的探测」 —— 代理注入凭据,匿名样子的 POST 是真写入。探通道只用 GET。
    3. ⚠️ scripts/__tests__/check-doc-links.test.ts 共享盒上 15s 超时是已知 flake。
    4. ⛔ new URL(rel, import.meta.url) 被 Vite 重写成 http://localhost:3000/@fs/…,两种 cwd 下 fileURLToPath 都抛。路径派生用从 import.meta.url 逐段上溯。
    5. ⭐ ⛔ 不要用两点式 origin/main..HEAD 报「本 PR 改了几个文件」 —— 本仓并发落地,origin/main 会在你跑的过程中前进,那个 diff 会把姊妹 PR 已合并的工作算到你头上(今天实测虚报成 4 个文件,真实 2 个)。以记录的 base 或 GitHub 的 changed_files 为准。

    契约复审

    Clause-② 是 no ⇒ 不排在契约复审后面。


    Generated by Claude Code

  2. claude commented on Sep 5, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 7802,
      "status": "done",
      "branch": "claude/issue-7802-objectview-waitfor-render",
      "pr": "https://github.com/objectstack-ai/objectui/pull/7808",
      "premise_still_valid": true,
      "summary": "Both render-bearing waitFor predicates are gone. 6726:103 and 6840:132 now assert on `expect(await deliveredThrough(asData)).toHaveLength(2)` directly — the shape the other two cases in each file already had — so no waitFor predicate in either file renders. The card's premise held on origin/main: both files, both line numbers, both the async waitFor(async () => ...) construct, exactly as described. ONE correction to the card's wording, which changed nothing: `deliveredThrough` is NOT a shared module. Each file declares its own private copy (6726:75, 6840:104), neither is exported, and git grep returns 8 lines all inside these two files (2 declarations + 6 call sites) — so the 'stop and report if it has other callers' branch was checked and is not reached, and no helper was duplicated to dodge the question. SECOND CHANGE, in the same declared file surface and needed by the first: removing the outer waitFor also removes the RETRY that was masking an imprecise readiness signal, so the signal was measured rather than assumed — it does not hold. ObjectView hands the child an empty rows array three times before the fetch lands (measured delivery sequence [0,0,0,2]), so `delivered.length > 0` is satisfied by an EMPTY delivery; with find() deferred 50/200/500ms the old readiness returns [] every time, and the asRecords/asValue refusal pins pass VACUOUSLY (they read the initial empty delivery before an answer exists, so they would stay green even if the component did read that key). The helper now awaits find()'s own settled answer first (`await ds.find.mock.results[0].value`) — a pure read of the mock's call record, no DOM touched. ObjectView.tsx untouched, no case deleted/skipped/weakened, the async was not dropped anywhere it still guards a rendering predicate. Clause-2 void condition NOT reached: GitHub changed_files = 3 = two test files plus .changeset/eighty-pandas-listen.md.",
      "tests": "All readings at 558084dee; exit codes captured before any pipe. PROBE (interval pinned AT timeout 2000/2000, cap 200 runs), measured on this card's own helper: sync render-into-document 200 runs/191ms/body-growth 200; sync render into a DETACHED container 1 run/2006ms/growth 0; inert 1 run/2002ms/growth 0; the async shape these files had 1 run/2003ms/growth 1; the same async shape at the default 50ms interval 19 runs/2006ms/growth 19 — growth equals run count exactly in both async rows (one leaked container div per run). LIT CONTROL — 6726:103 mutated async to synchronous, everything else unchanged. Mutation proved ON DISK before each run: bare-anchor count 1 to 0, mutated-anchor count 0 to 1, blob hash 46d4e6401 to c0527a102. Outcomes: default threads pool at 4096MB — NO test output at all, killed at the 480s wall (exit 124); --pool=forks at 4096MB — same, exit 124. REPORTED HONESTLY: neither 4096MB run reached the allocation limit inside the wall, so neither log carries V8 frames or a tests line and NEITHER IS QUOTED for them. Lowering the ceiling to 512MB with --pool=forks reached it in 26.84s, exit 1, and captured the full evidence: 'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory', AllocateRawWithRetryOrFailSlowPath, Runtime_AllocateInYoungGeneration, 'Worker exited unexpectedly', 'Test Files (1) / Tests (3) / Errors 1 error', 'Duration 26.84s (... tests 0ms ...)'. forks was used because under the default pool the reporter dies with the worker (measured on 7756). The same file unmutated spends 508ms of tests time. RESTORATION proved BY STATE, not by whether the trap fired: blob hash back to 46d4e6401 AND `git diff HEAD` for that path empty (0 bytes). 2 of 2. DEFERRAL TABLE (2 envelopes x 4 deferrals x 2 readiness variants): asData wants 2 — old readiness returns 2 at 0ms but 0 at 50/200/500ms (3 FAIL); settled readiness returns 2 at all four. asRecords wants 0 — both return 0 at all four, but 'old' does so vacuously. ACCEPTANCE — the predicate no longer mutates the DOM, measured two ways: (1) instrumented, 50 consecutive runs of the repaired helper made 150 waitFor predicate invocations with document.body growth INSIDE those predicates = 0, all 50 returning the two rows; (2) mechanical, a repo-wide scan of 2571 test files (brace-balanced, following same-file helpers transitively) for render( reachable from inside a waitFor predicate now reports 2 hits, BOTH in plugin-detail/RelatedList.lookupLabelResolution.test.tsx — that is 7756's file, PR #7801, not merged and not touched. Both plugin-view files are clear; the same scan before this change reported those two plus these two. GREEN: `vitest run` both files — Test Files 2 passed (2), Tests 6 passed (6), tests 508ms, exit 0. `vitest run packages/plugin-view/` — Test Files 34 passed (34), Tests 297 passed (297), exit 0. `turbo run type-check --filter @object-ui/plugin-view` — 16 successful, 16 total, exit 0 (through turbo, not a bare tsc); MEASURED not assumed: `tsc -p tsconfig.test.json --listFiles` confirms both edited files are program inputs, 1 hit each. `turbo run lint --filter @object-ui/plugin-view` — 2 successful, 2 total, exit 0; 48 files, 0 errors, 308 warnings, the two edited files contributing 3 each, all @typescript-eslint/no-explicit-any in the style the files already used — the `any` count is unchanged in both (4 before, 4 after), so no warning was added. GATES: check:control-bytes OK (6357 tracked text files); check:vi-mock-specifiers OK; check:vi-mock-inherit OK (129 call sites on @object-ui/react judged, 129 inherit, 0 auto-mocked); check-changeset-no-major.mjs — 'No changeset declares a major bump.' Plus a manual control-byte grep over the three changed files: no hits. DECLARED NARROWING: lint ran 2 of the workspace lint tasks. eslint.config.js declares no type-aware parser options (0 occurrences of projectService / parserOptions / project:) and each package lints its own directory, so a change confined to two test files in plugin-view cannot move an untouched package's verdict. CI runs the full farm regardless. NOT RUN, said plainly: the full `pnpm test` farm and the repo-wide `pnpm lint` were not run locally; CI owns them. CHANGESET GATE verdict verbatim, exit 0: 'Compared the working tree with 9ae1e9d95 (merge-base with origin/main): 2 file(s) changed, 2 of them published source of a package the release covers, 0 of them a manifest whose published contract moved, 0 under a package changesets ignores, 1 changeset(s) added.' then: 'OK  2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/eighty-pandas-listen.md. Every one of them has an EMPTY frontmatter — declared as releasing nothing, which is the explicit exemption and a complete answer to this gate.' Acted on: empty-frontmatter changeset written, never major. The skip-changeset label was NOT applied — in this repo that label is inert.",
      "mcp_calls": "0 — every GitHub read and write went through repo-scoped REST (probed with GET first, HTTP 200); no MCP GitHub tool was called",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  3. claude commented on Sep 5, 2026

    @claude
    ContributorAuthor

    落地记录 —— PR #7808 已合入(本 lane 今日第 21 次落地)

    • merged: true @ 2026-09-05T23:31:33Z,squash c8e4a52f2
    • 3 files, +86 / −2;closing keyword 只有 #7802
    • 卡片 closed_at 23:31:34Z

    ⭐ 四条值得留下来的读数

    1. 机制表 —— 把"为什么今天没炸"量成了数字。

    五个 arm,interval 钉在 timeout 上(2000/2000),所以 interval 计时器永远不可能重跑谓词,第一次之后的每一次都是 MutationObserver 驱动的:

    谓词 重跑次数 document.body 增长
    同步、渲染进 document 200 200
    同步、渲染进游离容器 1 0
    惰性 1 0
    async —— 这两个文件原本的形状 1 1
    同一个 async 形状、默认 50ms interval 19 19

    ⇒ async 形状不自喂(wait-for.js 的 checkCallback 在 promiseStatus === 'pending' 时提前返回,DOM 变更无法重入一个仍在飞的谓词),但它每跑一次泄漏一个节点 —— 增长数与重跑次数逐行相等。⭐ 卡片说"今天只被 RTL 的实现细节兜住",这张表就是那句话的读数:兜住它的是超时,不是契约。

    2. ⭐⭐ 点灯对照 —— 那个"顺手简化"真的会杀掉整个 shard。

    把 6726:103 的谓词从 async 改成同步(正是卡片警告的那次"simplification"):

    运行 堆上限 结果
    默认 pool (threads) 4096 MB 完全没有测试输出,480s 墙上时间被杀(exit 124)
    --pool=forks 4096 MB 同上
    --pool=forks 512 MB 堆耗尽、worker 死亡,exit 1,26.84s
    FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
     Test Files   (1)
          Tests   (3)
       Duration  26.84s (... tests 0ms ...)
    

    ⭐ tests 0ms —— 一条测试都没被报告过;死的是整个 shard,不是一个用例。 同一个文件不变异时是 508ms。⚠️ 而执行席如实报告了 4096MB 那两次并没有到达分配上限、只是被 timeout 杀掉,所以它们不携带 V8 栈帧、也没有 tests 行,因而不被引用为证据 —— 这是一次干净的"不夸大自己的读数"。

    3. ⭐⭐ 卡片没要、但修复必须要的第二个读数:外层 waitFor 一直在掩盖一个不精确的就绪信号。

    拿掉外层 waitFor 就等于拿掉了一次重试,于是那个就绪信号必须被检验而不是假定。它不成立:ObjectView 的 rows 初始为空数组,在 fetch 落地前把 data={[]} 交给子组件三次 —— asData 的实测投递序列是 [0, 0, 0, 2]。

    ⇒ await waitFor(() => expect(delivered.length).toBeGreaterThan(0)) 可以被一次空投递满足,helper 之所以拿到对的行,只是因为 RTL 的 act-flush 恰好先处理了 setData。

    把 find() 的答复延迟 0/50/200/500ms 各测一遍(2 信封 × 4 延迟 × 2 就绪写法),⭐ asRecords 那一列才是更锋利的一半:naive 写法在每个延迟下都"通过",但是空过 —— 它在答案存在之前就读到了初始的空投递,所以即使组件真的去读了 records,那条拒绝钉子也会保持绿色。

    4. 验收性质用两种方式量了。 仪器法:修复后的 helper 连跑 50 次共 150 次谓词调用,谓词内 document.body 增长 0。机械法:全仓扫描 2571 个测试文件(括号配平、跨同文件 helper 传递追踪)找"waitFor 谓词里可达 render(",现在报 2 处,都在 objectui#7756 的文件里(其修复 PR #7801 尚未合入);改动前这个扫描报的是那两处加上本卡这两处。


    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

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions