From e7ff8d1e21807158188f3d7098c06539a5107d60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 13:55:41 +0000 Subject: [PATCH] =?UTF-8?q?test(console):=20record:*=20=E5=AE=B6=E6=97=8F?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=E6=AC=A1=E6=9C=89=E4=BA=86=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=EF=BC=8C=E6=8A=93=E5=88=B0=20record:activity?= =?UTF-8?q?=20=E6=81=92=E7=A9=BA=E4=B8=8E=20useMetadata=20=E7=9A=84?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E6=AD=BB=E5=BE=AA=E7=8E=AF=20(#3149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 Claude-Session: https://claude.ai/code/session_01S3cP1eY1novcNhQEDBrSZD --- .changeset/metadata-fallback-render-loop.md | 28 + .changeset/record-block-record-reach.md | 62 ++ .../record-block-record-reach.test.tsx | 608 ++++++++++++++++++ .../react/src/context/AppShellContext.tsx | 76 ++- 4 files changed, 751 insertions(+), 23 deletions(-) create mode 100644 .changeset/metadata-fallback-render-loop.md create mode 100644 .changeset/record-block-record-reach.md create mode 100644 apps/console/src/__tests__/record-block-record-reach.test.tsx diff --git a/.changeset/metadata-fallback-render-loop.md b/.changeset/metadata-fallback-render-loop.md new file mode 100644 index 0000000000..8b496fff18 --- /dev/null +++ b/.changeset/metadata-fallback-render-loop.md @@ -0,0 +1,28 @@ +--- +"@object-ui/react": patch +--- + +`useMetadataItem` no longer spins forever outside a `` — the "graceful fallback" was the thing that made those consumers impossible to mount. + +`useMetadata()` built its no-provider fallback **inline on every call**, so outside a provider +every render produced a new `getItem`. `useMetadataItem` lists `getItem` in its effect deps and, +on the no-name path, called `setState({ item: null, loading: false, error: null })` with a fresh +object each run. New identity → effect re-runs → new state object → re-render → new identity: +an unbreakable loop, synchronous enough to hang inside `render()` rather than fail. + +So the fallback documented as the graceful path for consumers mounted outside a provider — +"common in unit tests that only need to assert on rendering" — was precisely what made them +unmountable. `record:alert` and `record:quick_actions` both call `useMetadataItem` +unconditionally; each pinned a core and grew unbounded (8.6 GB before the first kill) on a +`render()` that never returned. + +Two changes, at the cause and one layer in: + +- The fallback is a frozen module-level singleton, so its identity is stable across renders. +- The clear-state path bails out when the state is already cleared, instead of installing an + equal-but-new object. That covers the same loop arriving by another route — any caller whose + context value is rebuilt per render, which this interface explicitly invites ("hand-rolled + context values in tests keep working"). + +Found by `apps/console/src/__tests__/record-block-record-reach.test.tsx` (objectui#3149), which +could not mount either block until this was fixed. diff --git a/.changeset/record-block-record-reach.md b/.changeset/record-block-record-reach.md new file mode 100644 index 0000000000..c045b49892 --- /dev/null +++ b/.changeset/record-block-record-reach.md @@ -0,0 +1,62 @@ +--- +"@object-ui/console": patch +--- + +The `record:*` family now has behavioural evidence — until now "it works under a record page" was an assumption (#3149 layer 3a). + +`public-block-binding-reach.test.tsx` (objectstack#4472) asks whether a declared `objectName` +reaches the data layer when a block is mounted bare. Every `record:*` block is outside that +question by construction: they take their subject from ``, so mounted +bare they correctly do nothing, and "made no data call" says nothing about whether they work. + +That gap is the exact place objectstack#4413 lived — `record:details` / `record:highlights` / +`record:path` / `record:related_list` published props no renderer read, four blocks rendered +blank on a real record page, and every gate stayed green. The framework check compares two +declarations; the binding-reach probe cannot see this family at all. + +`apps/console/src/__tests__/record-block-record-reach.test.tsx` mounts all **11** public +`record:*` blocks under a record context twice, with two different records of the same object, +and asks whether anything changes — in the DOM or in the data calls. Behavioural coverage across +the two probes goes 14 → **24** of the 57 curated blocks (`record:related_list` is in both: +binding-reach ledgers it as unable to fetch without a parent, and this probe is what finally +shows it fetching once one is bound — turning that ledger entry's stated reason from a claim +into a checked one). + +- **A differential, not "renders non-empty".** #3149 records the decision *not* to cover the + display primitives precisely because "renders something" is also true of a block that ignores + every input it declares. Adding a gate that reports green without checking anything is what + objectstack#4472 exists to eliminate. A block rendering the same fixed shell for two different + records scores zero. +- **Two records, not bound-vs-unbound.** An unbound control differs in tree shape, so `useId` + values shift and everything "differs" for reasons unrelated to the record. +- **The instrument is checked, not trusted.** Each block also renders record A a *second* time; + that mount must be byte-identical to the first. If it ever isn't, "A differs from B" stops + meaning "the record reached the output", and the file says so instead of staying green. +- **A crash fails as a crash.** SchemaRenderer paints an error card on a throw, and a crashed + block renders the *same* card for both records — which would land in the "no difference" + bucket and read as a finding about its binding. Asserted separately. +- **Hermetic.** Blocks in this family call bare `fetch` (`/api/v1/security/explain`); under + happy-dom that resolves to `localhost:3000`, so the probe used to "work" only because the + connection was refused — 24 ECONNREFUSED lines per run, and different behaviour for anyone + with a dev server on that port. `fetch` now rejects immediately and its URL joins the same + call log, so a block binding through bare fetch is credited rather than reported unbound. + +Eight blocks respond to the bound record. Three are ledgered with the reason and the host path +NAMED, because "host-fed" is only a reason while a host actually feeds it: +`record:discussion` (DiscussionContext, mounted by RecordDetailView) and `record:reference_rail` +(`entries` injected by `buildDefaultPageSchema`) check out — **`record:activity` does not**, and +that is #3165: it renders `items={[]}` hard-coded, nothing supplies items on any path, and its +eleven declared inputs are filters over a feed that is always empty. Ledgered rather than fixed +here because the fix is a feature, not the missing-bridge one-liner #3144 turned out to be; the +ledger's both-directions assertion forces the entry out the day it starts working. + +**#3149 layer 2 lands with it, in the only form the codebase offers.** The slice the issue +proposed — `recordId` on `object-form` / `object-master-detail-form` / `embeddable-form` — does +not exist: no public block declares a `recordId` input at all (the only `recordId`/`resourceId` +inputs in the repo are on `view:detail` and `detail-view`, neither of them public). Same result +objectstack#4472's direction (d) hit — a slice proposed from the declarations, unavailable once +you look at what is actually declared. What is available is stronger: `record:related_list` and +`record:line_items` both bind a **required** `relationshipField` + `childObject` that must land +in the child query, checkable only under a record context. Both do, scoped to the bound parent. +The "same mechanism, different assertion" hypothesis #3149 wanted tested before anyone widens +the sweep holds, and it cost one assertion on mounts that were already happening. diff --git a/apps/console/src/__tests__/record-block-record-reach.test.tsx b/apps/console/src/__tests__/record-block-record-reach.test.tsx new file mode 100644 index 0000000000..056e55539c --- /dev/null +++ b/apps/console/src/__tests__/record-block-record-reach.test.tsx @@ -0,0 +1,608 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * `record:*` blocks — the BOUND RECORD must reach the output + * (objectui#3149 layer 3a; objectstack#4413's original shape, stated + * behaviourally). + * + * ## Why a second probe rather than an extension of the first + * + * `public-block-binding-reach.test.tsx` asks one question: a block that + * declares `objectName`, mounted bare — does any data call carry that name? + * Every `record:*` block is outside that question by construction. They take + * their subject from ``, not from a schema prop, so + * mounted bare they correctly do nothing, and "made no data call" says nothing + * about whether they work. + * + * That is not a hypothetical gap. It is the exact place objectstack#4413 lived: + * `record:details` / `record:highlights` / `record:path` / `record:related_list` + * published `objectName` + `recordId` that no renderer read, four blocks + * rendered blank on a real record page, and every gate stayed green — because + * the framework check compares two declarations (objectstack#4472) and the + * binding-reach probe cannot see this family at all. Until this file, the claim + * "this family works under a record page" was an ASSUMPTION with no behavioural + * evidence behind it. + * + * ## The question this asks + * + * Mount the block under a record context with record A, then with record B — + * two different records of the SAME object, identical trees. Does anything + * about its behaviour change? + * + * Evidence is a difference between the two mounts: in the rendered DOM, or in + * the data calls made. Either one proves the bound record reached the renderer + * and came out the other side. + * + * Two records rather than bound-vs-unbound, deliberately. An unbound control + * differs in TREE SHAPE (one less provider), so `React.useId` values shift and + * blocks "differ" for a reason that has nothing to do with the record — the + * probe would manufacture its own green. Two records under an identical tree + * hold everything constant except the one variable under test. + * + * And a differential rather than "renders something": objectui#3149 records the + * decision NOT to cover the display primitives precisely because "renders + * non-empty" is also true of a block that ignores every input it declares. A + * gate that reports green without checking anything is what objectstack#4472 + * exists to eliminate, and adding one here would be self-defeating. A block + * that renders the same fixed shell for two different records scores zero + * evidence — the correct reading of a block whose subject never arrives. + * + * The differential is itself checked, not assumed: {@link mountThrice} also + * renders record A a second time and asserts that mount is byte-identical to + * the first. If that control ever fails, "A differs from B" stops meaning + * "the record reached the output" and this whole file's green becomes noise — + * so it is asserted per block rather than trusted. + * + * ## Scope, stated so this is not over-read in turn + * + * A difference proves the record REACHES the output. It does not prove the + * output is RIGHT — nothing on this chain looks at rendered results (ADR-0082 + * addendum). And "no difference" has legitimate causes: a block whose data is + * injected by the page host rather than declared by the author. Those are + * ledgered below with the host path NAMED and a file:line to check it against, + * because "host-fed" is only a reason while a host actually feeds it — the day + * no host does, "host-fed" is the same excuse objectstack#4413 shipped behind. + * One entry in the ledger is exactly that case today. + */ + +import { describe, it, expect } from 'vitest'; +import { render, act } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { + SchemaRenderer, + SchemaRendererProvider, + RecordContextProvider, + MetadataCtx, +} from '@object-ui/react'; +// The real registration graphs, same posture as public-contract.test.ts and the +// binding-reach probe: a hand-copied list would agree with itself. +import '@object-ui/components'; +import '../register-plugins'; + +/** The object every probed block is mounted against. */ +const PROBE_OBJECT = 'probe_object__c'; +/** The related/child object a `record:*` block fans out to. */ +const PROBE_CHILD_OBJECT = 'probe_child__c'; +/** The lookup field on the child pointing back at the parent record. */ +const RELATIONSHIP_FIELD = 'parent_probe_id'; +/** The action name `record:quick_actions` is asked to resolve from metadata. */ +const PROBE_ACTION = 'probe_action'; + +/** + * Two records of the SAME object, differing in every field. + * + * Same object so that object-driven behaviour (metadata lookups, permission + * gates, the related-object name) is held constant and the record is the only + * variable. Every field differs so a block that surfaces any ONE of them + * registers a difference — a block that happens to render only `stage` must not + * read as unbound because the probe varied only `name`. + * + * `stage` differs across the qualified/draft boundary specifically: it is what + * `record:path` highlights and what the `record:alert` / quick-action + * predicates below are written against. + */ +const RECORD_A = { + id: 'probe-record-aaaa', + name: 'Probe Alpha', + description: 'first probe record', + amount: 4413, + stage: 'qualified', + updated_at: '2026-08-01T00:00:00.000Z', +}; +const RECORD_B = { + id: 'probe-record-bbbb', + name: 'Probe Bravo', + description: 'second probe record', + amount: 4472, + stage: 'draft', + updated_at: '2026-08-02T00:00:00.000Z', +}; + +/** + * Object metadata the record blocks resolve field labels, picklists and + * ACTIONS through. + * + * The action carries `visible` over the record because that is the only way + * `record:quick_actions` depends on the bound record at all: it renders the + * OBJECT's declared actions, and the record decides which of them survive the + * predicate filter. Gating a quick action on record state is the ordinary + * Lightning-style configuration, not a contrivance for the probe. + */ +const PROBE_OBJECT_SCHEMA = { + name: PROBE_OBJECT, + label: 'Probe Object', + primaryField: 'name', + fields: [ + { name: 'id', label: 'ID', type: 'text' }, + { name: 'name', label: 'Name', type: 'text' }, + { name: 'description', label: 'Description', type: 'textarea' }, + { name: 'amount', label: 'Amount', type: 'number' }, + { + name: 'stage', + label: 'Stage', + type: 'select', + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Qualified', value: 'qualified' }, + { label: 'Won', value: 'won' }, + ], + }, + ], + actions: [ + { + name: PROBE_ACTION, + label: 'Probe Action', + type: 'url', + locations: ['record_header'], + visible: "record.stage === 'qualified'", + }, + ], +}; + +/** + * `DataSource` methods carried as real own properties so a block that derives a + * source by spreading still gets them — the artifact the binding-reach probe + * paid for once already (`{...dataSource}` over a bare Proxy copies nothing). + */ +const DATA_SOURCE_METHODS = [ + 'find', 'findOne', 'create', 'update', 'delete', 'aggregate', 'count', + 'getObjectSchema', 'getObjects', 'getView', 'listViews', 'listViewOverrides', + 'updateViewConfig', 'onMutation', +] as const; + +/** + * A plausible value for each declared input, keyed by input NAME. + * + * Keyed by name rather than by type because on this family the type carries + * almost no information: `statusField`, `relationshipField` and `emptyText` are + * all `type: 'string'`, and only one of them can be filled with `'x'` without + * making the block short-circuit. Every value points at something that exists + * in {@link RECORD_A} / {@link PROBE_OBJECT_SCHEMA}, so a block that reads its + * config and then reads the record finds real referents on both sides. + * + * `sections` is here because the generic `array` sample (`['name']`) is not + * merely uninformative, it is malformed: `record:details` spreads each section + * entry, so a bare string became `{0:'n',1:'a',…}` and the block died inside + * `DetailView` with "Cannot read properties of undefined (reading 'name')". + * That crash is the fourth instance of the one lesson this pair of probes keeps + * re-learning: a plausible value for every input is not a plausible + * CONFIGURATION. It is also why {@link assertRendered} exists — a crash must + * fail loudly rather than land in the "no difference" bucket and read as a + * finding about the block. + */ +const SAMPLE_BY_INPUT: Readonly> = { + // On `record:*` this names the RELATED object, not the page's object — + // `record:related_list` fans out from the bound parent to its children + // (@objectstack/spec react-blocks.ts spells this out after objectstack#4340). + objectName: PROBE_CHILD_OBJECT, + childObject: PROBE_CHILD_OBJECT, + relationshipField: RELATIONSHIP_FIELD, + columns: ['name', 'amount'], + fields: ['name', 'description', 'amount'], + sections: [{ name: 'main', title: 'Main', fields: ['description', 'amount'] }], + statusField: 'stage', + stages: [ + { value: 'draft', label: 'Draft' }, + { value: 'qualified', label: 'Qualified' }, + { value: 'won', label: 'Won' }, + ], + actionNames: [PROBE_ACTION], + // `record:alert` is a banner whose COPY is authored; the only thing the bound + // record decides is whether it shows. Filling `visible` with a predicate over + // the record is therefore the only configuration under which "does the record + // reach this block" is even askable — an alert with no predicate is + // unconditionally visible and identical for every record, which would be the + // probe answering its own question with a shrug. + visible: "record.stage === 'qualified'", + title: 'Probe Alert', + body: 'Probe alert body', +}; + +/** Fill one declared input. */ +const sampleFor = (input: any): unknown => { + if (input.name in SAMPLE_BY_INPUT) return SAMPLE_BY_INPUT[input.name]; + if (input.defaultValue !== undefined) return input.defaultValue; + switch (input.type) { + case 'number': return 1; + case 'boolean': return true; + case 'array': return ['name']; + case 'object': return {}; + case 'enum': { + const first = input.enum?.[0]; + return typeof first === 'object' && first !== null ? first.value : (first ?? 'x'); + } + default: return 'x'; + } +}; + +/** Minimal metadata context: enough for `getItem('object', PROBE_OBJECT)`. */ +const METADATA: any = { + apps: [], objects: [PROBE_OBJECT_SCHEMA], dashboards: [], reports: [], pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async (type: string, name: string) => + type === 'object' && name === PROBE_OBJECT ? PROBE_OBJECT_SCHEMA : null, + getItemsByType: () => [], + getTypeStatus: () => 'ready', +}; + +/** + * The call log of the mount currently in flight — see + * {@link installHermeticFetch}. + */ +let currentCalls: string[] | null = null; + +/** + * Make the probe hermetic, and treat a bare `fetch` as data access like any + * other. + * + * Some blocks in this family call `fetch` directly rather than going through + * the injected data source (`/api/v1/security/explain` is the one this family + * reaches for today). Under happy-dom a relative `/api/...` resolves against + * `localhost:3000`, so left alone the probe depends on nothing listening there + * — it "worked" only because the connection was refused, printed two dozen + * ECONNREFUSED lines doing it, and would behave differently for a developer + * with a dev server on that port. Reject immediately instead: the same answer + * as "no backend", with no socket and no ambient dependency. + * + * The URL goes into the same call log as the data-source calls, so a block that + * binds the record through a bare fetch is credited for it rather than reported + * as unbound — the log is meant to be every data access, not every access + * through one API. + */ +function installHermeticFetch(): void { + (globalThis as any).fetch = (input: any) => { + const url = String(typeof input === 'string' ? input : input?.url ?? input); + currentCalls?.push(`fetch(${url})`); + return Promise.reject(new Error('probe: network disabled')); + }; +} +installHermeticFetch(); + +interface Mount { + html: string; + calls: string[]; +} + +/** Mount one block under a record context and report DOM + data calls. */ +async function mountWithRecord(cfg: any, record: Record): Promise { + const calls: string[] = []; + const stub = (key: string) => + (...args: unknown[]) => { + // Capped: a stub with a stable identity cannot spin, but a renderer that + // polls on an interval still can, and an uncapped log turns that into an + // OOM instead of a readable result. + if (calls.length < 200) { + calls.push(`${key}(${args.map((a) => JSON.stringify(a) ?? 'undefined').join(', ')})`); + } + // Subscription methods hand back an UNSUBSCRIBE function; a promise there + // crashes teardown with `unsub is not a function`, a failure that says + // nothing about the block. + return /^on[A-Z]/.test(key) || key === 'subscribe' ? () => {} : Promise.resolve([]); + }; + const seeded: Record = {}; + for (const m of DATA_SOURCE_METHODS) seeded[m] = stub(m); + // The Proxy MEMOIZES: one stub per key, for the life of this mount. + // + // Not a nicety — a fresh function per access is a live infinite-render loop. + // These blocks hand `ctx.dataSource` to components deep enough to put data + // source methods in `useEffect` / `useMemo` dependency arrays; an unmemoized + // `get` changes every one of those deps on every render, so the component + // re-renders forever. The first run of this probe pinned a core and grew to + // 8.6 GB without ever reaching an assertion. The binding-reach probe never + // hit it because a bare-mounted block is a much shallower tree. + const stubs = new Map(); + const dataSource: any = new Proxy(seeded, { + get: (target, key: string | symbol) => { + if (key in target) return (target as any)[key]; + // Two accesses must answer "absent" rather than "here is a stub": + // - symbols (`Symbol.iterator`, `Symbol.toPrimitive`, React internals) + // — a stub would be called in a protocol position it cannot satisfy, + // and `/^on[A-Z]/.test(symbol)` throws before that anyway; + // - `then` — a stub there makes this object a THENABLE, so anything + // that awaits it or wraps it in `Promise.resolve` hangs forever on a + // resolve callback that is only ever recorded. + if (typeof key === 'symbol' || key === 'then') return undefined; + if (!stubs.has(key)) stubs.set(key, stub(key)); + return stubs.get(key); + }, + }); + + const schema: Record = { type: cfg.type }; + for (const input of cfg.inputs ?? []) schema[input.name] = sampleFor(input); + + currentCalls = calls; + const view = render( + + + {/* + `dataSource` is typed `string` on RecordContextValue (a datasource id) + while `record:history`, `record:reference_rail` and + `record:line_items` all call `.find` on it — the host passes the + object and the renderers cast. Passing the object is what matches the + runtime (app-shell RecordDetailView), not the declaration. + */} + + + + + , + ); + // Settle: a block may fetch from an effect, after a lazy renderer resolves, + // or in a second pass once the object schema lands. + for (let i = 0; i < 10; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + } + const html = view.container.innerHTML; + try { + view.unmount(); + } catch { + /* teardown is not the subject — same posture as the binding-reach probe */ + } + currentCalls = null; + return { html, calls }; +} + +/** + * Mount A, mount A again (the control), then mount B. + * + * The control is what keeps "A differs from B" meaningful. If two mounts of the + * SAME record ever diverge — an unstable id, a timestamp, a random key — then a + * difference no longer traces to the record and every "reaches" verdict in this + * file becomes a coin flip that happens to land green. + */ +async function mountThrice(cfg: any): Promise<{ a: Mount; control: Mount; b: Mount }> { + const a = await mountWithRecord(cfg, RECORD_A); + const control = await mountWithRecord(cfg, RECORD_A); + const b = await mountWithRecord(cfg, RECORD_B); + return { a, control, b }; +} + +/** + * SchemaRenderer catches a renderer's throw and paints an error card. A crashed + * block renders the SAME card for both records, so without this it would land + * in the "no difference" bucket and be read as a finding about its binding — + * the probe reporting a broken probe as a broken block. + */ +const assertRendered = (type: string, m: Mount) => { + expect( + m.html.includes('failed to render'), + `<${type}> threw during render — that is a crash, not a binding verdict:\n${m.html.slice(0, 600)}`, + ).toBe(false); +}; + +/** + * Every public `record:*` block, exactly. + * + * Exact rather than a floor, for the reason objectui#3149 layer 1 cost a + * release to learn: the failure mode is a candidate set that quietly gets + * SMALLER, and `toContain` / `length > 0` sail straight past it. + * + * `record:chatter` is absent because it is not public — it is the same renderer + * as `record:discussion` under a Salesforce-familiar alias, and + * `public-contract.test.ts` excludes it on those grounds. + */ +const EXPECTED_CANDIDATES = [ + 'record:details', + 'record:highlights', + 'record:related_list', + 'record:path', + 'record:line_items', + 'record:activity', + 'record:discussion', + 'record:history', + 'record:quick_actions', + 'record:reference_rail', + 'record:alert', +]; + +/** + * Blocks where the bound record changes nothing observable, each with the + * reason and the place to check it. Entries are DEBT, not acceptance — an entry + * whose block starts responding to the record fails this test until deleted. + * + * Two of these three are host-fed by design and the host really does feed them. + * The third is the one this probe was written to find. + */ +const NO_RECORD_REACH: Readonly> = { + // ── objectstack#4413's shape, alive today ────────────────────────────────── + // `record:activity` renders an activity feed that structurally cannot have + // activities. `RecordActivityRenderer` calls `useRecordContext()` and + // DISCARDS the result, then renders `` + // with the empty array hard-coded; `RecordActivityTimeline` takes `items` as + // a prop and never fetches. Its own file header says "Real data wiring + // (sys_activity / sys_comment polling) lives inside that component" — it does + // not, and that sentence is the whole defect in one line. + // + // Nor is a host feeding it: `buildDefaultPageSchema` emits the node with NO + // props at all (`{ type: 'record:activity' }`), and the `showActivity` option + // that would emit it is never set true anywhere outside that builder's own + // tests. Meanwhile the registration declares ELEVEN inputs — types, + // filterMode, limit, showCompleted, unifiedTimeline, enableReactions, + // enableThreading, … — every one of them a filter or affordance over a feed + // that is always empty. Declared, published to `sdui.manifest.json`, nothing + // behind it: objectstack#4413 exactly, three blocks over. + // + // Ledgered rather than fixed here because the fix is a feature (a scoped + // `sys_activity` read, mirroring the one `record:history` already has) rather + // than the missing-bridge one-liner objectui#3144 turned out to be. Tracked + // in objectui#3165. + 'record:activity': + 'renders `items={[]}` hard-coded and no host supplies items — the feed is always empty on every path (objectui#3165)', + + // ── Host-fed, and the host really does feed them ────────────────────────── + // Feed items come from DiscussionContext, which the page host mounts + // (app-shell RecordDetailView wraps the record body in + // ). The block is a presenter, not a fetcher, so + // mounted without that provider it correctly shows an empty feed. Unlike + // `record:activity` above, the provider and its data are real — which is the + // difference between a reason and an excuse, and why entries here have to + // name the path instead of just saying "host-fed". + 'record:discussion': + 'feed items come from DiscussionContext, mounted by the page host (app-shell RecordDetailView); presenter, not fetcher', + + // The rail's `entries` are not an author input at all — the registration + // declares only `hideEmpty`, and `buildDefaultPageSchema` supplies `entries` + // from the object's related-list definitions. With no entries the renderer + // returns null before it can fetch, which is why both mounts are empty. Its + // per-entry fetch IS scoped to the parent record (`dataSource.find(entry + // .objectName, …parentId)`), so it reaches — just not from anything an + // author can declare, and therefore not from anything this probe can vary. + 'record:reference_rail': + 'its `entries` are injected by the page host (plugin-detail buildDefaultPageSchema), not declared by the author; with none it returns null before fetching', +}; + +/** + * Blocks that fan out from the parent record to a CHILD object, and the + * declared binding that must appear in that fetch (objectui#3149 layer 2). + * + * This is the narrow slice layer 2 asked for, in the only form the codebase + * offers it. The slice #3149 originally proposed — `recordId` on `object-form` + * / `object-master-detail-form` / `embeddable-form` — does not exist: NO public + * block declares a `recordId` input (the only `recordId`/`resourceId` inputs in + * the repo are on `view:detail` and `detail-view`, neither of which is in the + * curated public set). That is the same result objectstack#4472's direction (d) + * hit — a slice proposed from the declarations, unavailable once you look at + * what is actually declared. + * + * What IS available is stronger anyway: two blocks whose REQUIRED + * `relationshipField` / `childObject` must land in the query, mechanically + * checkable, and only observable under a record context — i.e. only from this + * probe. It settles the "same mechanism, different assertion" hypothesis #3149 + * wanted tested before anyone decides to widen: it holds, and it cost one + * assertion on mounts that were already happening. + */ +const CHILD_QUERY_BINDINGS: Readonly> = { + 'record:related_list': [PROBE_CHILD_OBJECT, RELATIONSHIP_FIELD], + 'record:line_items': [PROBE_CHILD_OBJECT, RELATIONSHIP_FIELD], +}; + +const candidates = ComponentRegistry.getPublicConfigs().filter((c) => + c.type.startsWith('record:'), +); + +describe('record:* blocks — the bound record reaches the output (objectui#3149)', () => { + it('probes exactly the public record:* blocks', () => { + expect([...candidates.map((c) => c.type)].sort()).toEqual([...EXPECTED_CANDIDATES].sort()); + }); + + for (const cfg of candidates) { + const ledgered = cfg.type in NO_RECORD_REACH; + it(`${cfg.type} ${ledgered ? 'is unmoved by the bound record (ledgered)' : 'responds to the bound record'}`, async () => { + const { a, control, b } = await mountThrice(cfg); + + assertRendered(cfg.type, a); + assertRendered(cfg.type, b); + + // The instrument, checked before it is read from. + expect( + control.html === a.html && control.calls.join('|') === a.calls.join('|'), + `<${cfg.type}> rendered differently for the SAME record twice, so a difference ` + + 'between two records proves nothing here. Find the unstable output (an id, a ' + + 'timestamp, a random key) before trusting any verdict in this file.', + ).toBe(true); + + const domDiffers = a.html !== b.html; + const callsDiffer = a.calls.join('|') !== b.calls.join('|'); + + if (ledgered) { + // Asserted, not skipped: the day this block starts responding to the + // record, this fails and the ledger entry has to go. A ledger nobody is + // FORCED to update decays into the accepted baseline that let + // objectstack#4413 ship. + expect( + domDiffers || callsDiffer, + `<${cfg.type}> now responds to the bound record — delete its NO_RECORD_REACH entry`, + ).toBe(false); + } else { + expect( + domDiffers || callsDiffer, + `<${cfg.type}> rendered identically and made identical data calls for two ` + + `DIFFERENT records of ${PROBE_OBJECT}. The bound record does not reach its ` + + 'output — objectstack#4413\'s shape.\n' + + `Calls: ${a.calls.join(' | ') || '(none)'}\n` + + 'Either the record does not reach the renderer (fix the wiring), or the block ' + + 'is legitimately fed by the page host: add it to NO_RECORD_REACH naming the ' + + 'host path, so the next reader can check the claim instead of trusting it.', + ).toBe(true); + } + + // objectui#3149 layer 2 — the child fetch must carry the declared + // bindings, not just SOME binding. + const expected = CHILD_QUERY_BINDINGS[cfg.type]; + if (expected) { + const fetches = a.calls.filter((c) => c.startsWith('find(')); + expect(fetches.length, `<${cfg.type}> made no find() call to check bindings against`) + .toBeGreaterThan(0); + for (const token of expected) { + expect( + fetches.some((c) => c.includes(token)), + `<${cfg.type}> declares "${token}" but no find() call carries it.\n` + + `Calls: ${a.calls.join(' | ')}`, + ).toBe(true); + } + // And the parent it scopes to must be the BOUND record, not a constant. + expect( + fetches.some((c) => c.includes(RECORD_A.id)), + `<${cfg.type}> fetches children but not scoped to the bound parent record`, + ).toBe(true); + } + }, 60_000); + } + + it('the ledger and the layer-2 slice name only blocks that exist — no stale entries', () => { + const stale = (types: string[]) => types.filter((t) => !candidates.some((c) => c.type === t)); + + expect( + stale(Object.keys(NO_RECORD_REACH)), + 'NO_RECORD_REACH lists blocks that are no longer public — delete them', + ).toEqual([]); + for (const [type, reason] of Object.entries(NO_RECORD_REACH)) { + expect(reason.length, `${type} needs a written reason, not an empty one`).toBeGreaterThan(20); + } + + // Same check for the layer-2 slice, for the same reason: a binding + // assertion keyed on a block that stopped existing stops running, silently. + // That is how a gate's real scope drifts below its stated one — objectui#3149 + // layer 1 in miniature. + expect( + stale(Object.keys(CHILD_QUERY_BINDINGS)), + 'CHILD_QUERY_BINDINGS names blocks that are no longer public — its assertions are dead', + ).toEqual([]); + }); +}); diff --git a/packages/react/src/context/AppShellContext.tsx b/packages/react/src/context/AppShellContext.tsx index 9894ae9a8e..829f9adf00 100644 --- a/packages/react/src/context/AppShellContext.tsx +++ b/packages/react/src/context/AppShellContext.tsx @@ -46,30 +46,52 @@ export interface MetadataContextValue extends MetadataState { export const MetadataCtx = createContext(null); +/** Shared frozen empty list for the fallback below — see {@link NO_METADATA_PROVIDER}. */ +const NO_ITEMS = Object.freeze([]) as unknown as any[]; + +/** + * The no-provider fallback, as a MODULE-LEVEL SINGLETON. + * + * Identity matters here, not just shape. `useMetadata()` used to build this + * object inline on every call, so outside a `` every render + * produced a new `getItem` — and `useMetadataItem` lists `getItem` in its + * effect deps and calls `setState({...})` with a fresh object on every run. New + * identity → effect re-runs → new state object → re-render → new identity: an + * unbreakable render loop, synchronous enough to hang inside `render()`. + * + * So the fallback documented as the graceful path for consumers mounted outside + * a provider was the one thing that made those consumers impossible to mount — + * `record:alert` and `record:quick_actions` both call `useMetadataItem` + * unconditionally, and both spin. Found by + * `apps/console/src/__tests__/record-block-record-reach.test.tsx` + * (objectui#3149), which could not mount them at all until this was frozen. + * + * Frozen so a consumer cannot mutate the shared fallback out from under every + * other consumer of it. + */ +const NO_METADATA_PROVIDER: MetadataContextValue = Object.freeze({ + apps: NO_ITEMS, + objects: NO_ITEMS, + dashboards: NO_ITEMS, + reports: NO_ITEMS, + pages: NO_ITEMS, + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, +}); + export function useMetadata(): MetadataContextValue { const ctx = useContext(MetadataCtx); - if (!ctx) { - // Graceful fallback: when a consumer is rendered outside a MetadataProvider - // (common in unit tests that only need to assert on rendering), return an - // empty no-op implementation rather than crash. Production code paths - // should always wrap in . - return { - apps: [], - objects: [], - dashboards: [], - reports: [], - pages: [], - loading: false, - error: null, - refresh: async () => {}, - invalidate: () => {}, - ensureType: async () => [], - getItem: async () => null, - getItemsByType: () => [], - getTypeStatus: () => 'ready', - }; - } - return ctx; + // Graceful fallback: a consumer rendered outside a MetadataProvider (unit + // tests that only assert on rendering, standalone previews) gets an empty + // no-op implementation rather than a crash. Production code paths should + // always wrap in . + return ctx ?? NO_METADATA_PROVIDER; } export function useMetadataItem( @@ -85,7 +107,15 @@ export function useMetadataItem( useEffect(() => { if (!name) { - setState({ item: null, loading: false, error: null }); + // Bail out when already cleared instead of installing a fresh object. + // Belt-and-braces against the loop the frozen fallback above fixes at the + // source: `getItem` is an effect dep, so ANY caller whose context value + // is rebuilt per render — a hand-rolled one in a test, which this + // interface explicitly invites — would otherwise re-run this effect, + // re-set an equal-but-new state object, and re-render forever. + setState((s) => (s.item === null && !s.loading && s.error === null + ? s + : { item: null, loading: false, error: null })); return; } let cancelled = false;