Skip to content

Commit e7ff8d1

Browse files
committed
test(console): record:* 家族第一次有了行为证据,抓到 record:activity 恒空与 useMetadata 的渲染死循环 (#3149)
#3146 的 binding-reach 探针按设计看不见 record:* 家族——它们从 RecordContextProvider 取数,裸挂载什么都不做,"没有数据调用"因此不说明任何 问题。而那恰好是 objectstack#4413 待过的地方:四个 block 声明了没人读的 objectName/recordId,在真实记录页上渲染空白,全程门禁皆绿。 record-block-record-reach.test.tsx 把 11 个 public record:* block 挂在 record context 下,绑两条不同的同对象记录各挂一次,问 DOM 或数据调用有没有 变化。两道探针的行为覆盖 14 → 24 / 57。 几个刻意的设计决定: - 两条记录而不是"绑 vs 不绑":不绑的对照组少一层 provider,useId 整体偏移, 于是每个块都"有差异"——探针自己制造绿。 - 差分而不是"渲染非空":后者对无视全部输入的块也恒为真,正是 #3149 记下 不覆盖展示原语的理由;在这里加一道同样的东西是自毁。 - 仪器本身被断言:每个块用记录 A 再挂一次,要求逐字节相同。这条一旦失败, "A 和 B 不同"就不再等于"记录到达了输出",整个文件的绿就是噪声。 - 崩溃按崩溃报:SchemaRenderer 会把渲染期抛错画成错误卡片,而崩了的块对两条 记录渲染同一张卡片,不单独断言就会落进"无差异"档、被读成关于绑定的结论。 - 无外网:这家族有块直接调 fetch('/api/v1/security/explain'),happy-dom 下 打到 localhost:3000——原本"能跑"只是因为连接被拒。现在立即 reject,URL 进 同一份调用台账。 结果 8 个响应、3 个入 NO_RECORD_REACH 台账。台账条目必须写明宿主路径,因为 "宿主供数"只在真有宿主供数时才是理由:record:discussion(DiscussionContext, RecordDetailView 挂)和 record:reference_rail(entries 由 buildDefaultPageSchema 注入)都核得住,record:activity 核不住——见 #3165。 顺带修掉一个探针挂不起来的缺陷:useMetadata() 的"优雅兜底"每次调用都新建对象, provider 外每渲染一次 getItem 就换身份;useMetadataItem 把它放在 effect deps 里并 setState 一个全新对象 → 无限循环,同步到能把 render() 卡住。注释说它存在 是为了让 provider 外的单测能渲染,它恰恰让那些消费者挂不起来。改成冻结的模块级 单例,并让清空分支在已清空时 bail。 #3149 第 2 层同时落地,但原方案不存在:57 个 public block 没有任何一个声明 recordId 输入(全仓库仅 view:detail / detail-view 两处,都不 public)——和 objectstack#4472 方向 (d) 同一类结果。代之以 record:related_list / record:line_items 的 required relationshipField + childObject 必须进子查询、 且 scope 到绑定的父记录,两个都成立。 Closes #3149 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3cP1eY1novcNhQEDBrSZD
1 parent a889e31 commit e7ff8d1

4 files changed

Lines changed: 751 additions & 23 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@object-ui/react": patch
3+
---
4+
5+
`useMetadataItem` no longer spins forever outside a `<MetadataProvider>` — the "graceful fallback" was the thing that made those consumers impossible to mount.
6+
7+
`useMetadata()` built its no-provider fallback **inline on every call**, so outside a provider
8+
every render produced a new `getItem`. `useMetadataItem` lists `getItem` in its effect deps and,
9+
on the no-name path, called `setState({ item: null, loading: false, error: null })` with a fresh
10+
object each run. New identity → effect re-runs → new state object → re-render → new identity:
11+
an unbreakable loop, synchronous enough to hang inside `render()` rather than fail.
12+
13+
So the fallback documented as the graceful path for consumers mounted outside a provider —
14+
"common in unit tests that only need to assert on rendering" — was precisely what made them
15+
unmountable. `record:alert` and `record:quick_actions` both call `useMetadataItem`
16+
unconditionally; each pinned a core and grew unbounded (8.6 GB before the first kill) on a
17+
`render()` that never returned.
18+
19+
Two changes, at the cause and one layer in:
20+
21+
- The fallback is a frozen module-level singleton, so its identity is stable across renders.
22+
- The clear-state path bails out when the state is already cleared, instead of installing an
23+
equal-but-new object. That covers the same loop arriving by another route — any caller whose
24+
context value is rebuilt per render, which this interface explicitly invites ("hand-rolled
25+
context values in tests keep working").
26+
27+
Found by `apps/console/src/__tests__/record-block-record-reach.test.tsx` (objectui#3149), which
28+
could not mount either block until this was fixed.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
"@object-ui/console": patch
3+
---
4+
5+
The `record:*` family now has behavioural evidence — until now "it works under a record page" was an assumption (#3149 layer 3a).
6+
7+
`public-block-binding-reach.test.tsx` (objectstack#4472) asks whether a declared `objectName`
8+
reaches the data layer when a block is mounted bare. Every `record:*` block is outside that
9+
question by construction: they take their subject from `<RecordContextProvider>`, so mounted
10+
bare they correctly do nothing, and "made no data call" says nothing about whether they work.
11+
12+
That gap is the exact place objectstack#4413 lived — `record:details` / `record:highlights` /
13+
`record:path` / `record:related_list` published props no renderer read, four blocks rendered
14+
blank on a real record page, and every gate stayed green. The framework check compares two
15+
declarations; the binding-reach probe cannot see this family at all.
16+
17+
`apps/console/src/__tests__/record-block-record-reach.test.tsx` mounts all **11** public
18+
`record:*` blocks under a record context twice, with two different records of the same object,
19+
and asks whether anything changes — in the DOM or in the data calls. Behavioural coverage across
20+
the two probes goes 14 → **24** of the 57 curated blocks (`record:related_list` is in both:
21+
binding-reach ledgers it as unable to fetch without a parent, and this probe is what finally
22+
shows it fetching once one is bound — turning that ledger entry's stated reason from a claim
23+
into a checked one).
24+
25+
- **A differential, not "renders non-empty".** #3149 records the decision *not* to cover the
26+
display primitives precisely because "renders something" is also true of a block that ignores
27+
every input it declares. Adding a gate that reports green without checking anything is what
28+
objectstack#4472 exists to eliminate. A block rendering the same fixed shell for two different
29+
records scores zero.
30+
- **Two records, not bound-vs-unbound.** An unbound control differs in tree shape, so `useId`
31+
values shift and everything "differs" for reasons unrelated to the record.
32+
- **The instrument is checked, not trusted.** Each block also renders record A a *second* time;
33+
that mount must be byte-identical to the first. If it ever isn't, "A differs from B" stops
34+
meaning "the record reached the output", and the file says so instead of staying green.
35+
- **A crash fails as a crash.** SchemaRenderer paints an error card on a throw, and a crashed
36+
block renders the *same* card for both records — which would land in the "no difference"
37+
bucket and read as a finding about its binding. Asserted separately.
38+
- **Hermetic.** Blocks in this family call bare `fetch` (`/api/v1/security/explain`); under
39+
happy-dom that resolves to `localhost:3000`, so the probe used to "work" only because the
40+
connection was refused — 24 ECONNREFUSED lines per run, and different behaviour for anyone
41+
with a dev server on that port. `fetch` now rejects immediately and its URL joins the same
42+
call log, so a block binding through bare fetch is credited rather than reported unbound.
43+
44+
Eight blocks respond to the bound record. Three are ledgered with the reason and the host path
45+
NAMED, because "host-fed" is only a reason while a host actually feeds it:
46+
`record:discussion` (DiscussionContext, mounted by RecordDetailView) and `record:reference_rail`
47+
(`entries` injected by `buildDefaultPageSchema`) check out — **`record:activity` does not**, and
48+
that is #3165: it renders `items={[]}` hard-coded, nothing supplies items on any path, and its
49+
eleven declared inputs are filters over a feed that is always empty. Ledgered rather than fixed
50+
here because the fix is a feature, not the missing-bridge one-liner #3144 turned out to be; the
51+
ledger's both-directions assertion forces the entry out the day it starts working.
52+
53+
**#3149 layer 2 lands with it, in the only form the codebase offers.** The slice the issue
54+
proposed — `recordId` on `object-form` / `object-master-detail-form` / `embeddable-form` — does
55+
not exist: no public block declares a `recordId` input at all (the only `recordId`/`resourceId`
56+
inputs in the repo are on `view:detail` and `detail-view`, neither of them public). Same result
57+
objectstack#4472's direction (d) hit — a slice proposed from the declarations, unavailable once
58+
you look at what is actually declared. What is available is stronger: `record:related_list` and
59+
`record:line_items` both bind a **required** `relationshipField` + `childObject` that must land
60+
in the child query, checkable only under a record context. Both do, scoped to the bound parent.
61+
The "same mechanism, different assertion" hypothesis #3149 wanted tested before anyone widens
62+
the sweep holds, and it cost one assertion on mounts that were already happening.

0 commit comments

Comments
 (0)