Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .changeset/inputs-reverse-parity-3808.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
"@object-ui/plugin-detail": patch
"@object-ui/components": patch
---

Four spec keys the renderers already honoured are now discoverable from the published `inputs`

`record:details.hideFields`, `record:related_list.relationshipValueField`,
`record:related_list.add` and `element:text_input.defaultValue` were declared by
`@objectstack/spec` and read by their renderers, while the registry `inputs` —
the surface `gen-manifest.ts` serializes into `sdui.manifest.json` and
`sdui-intrinsics.d.ts` — never mentioned them. Nothing anywhere reported the
mismatch, and every layer that reads a manifest said the opposite of the
runtime: the keys were in no designer panel and no generated `.d.ts`,
`sdui-parser`'s prop walk returned `unknown-prop` for an author who wrote one,
and the renderer honoured it regardless. That is objectui#3407's original
complaint (`readonly` was enforced and honoured, the description just never said
so) on four more keys.

Each description is derived from what the renderer actually does, not from
restating the spec's one-liner, because the two can differ and the published
text is what an AI author reads:

- `hideFields` documents bare field names only — the renderer tolerates
`{name}` / `{field}` entries but the spec is `z.array(z.string())` and rejects
them, so teaching that spelling would publish a dialect the contract refuses;
- `relationshipValueField` publishes the renderer's `'id'` default and says that
the resolved value drives the list filter, the Add-picker link value and the
pre-filled create form together;
- `add` publishes its member shape in prose (`ComponentInput` is flat and has no
member-shape slot) with each default taken from the renderer — including
`picker.labelField`, where the renderer defaults to `name` while the spec's
own wording says "the object title field". It also names `picker.filter` as a
KNOWN GAP rather than documenting it as a restriction: the spec declares it
and nothing reads it, so an author would otherwise believe their picker is
scoped when it offers every record (objectui#3831);
- `defaultValue` distinguishes the two behaviours an author can get — seeding a
bound page variable once while it is still empty, versus the native
uncontrolled initial value with no variable bound.

`element:text_input` is not in the public tier, so its gap was not in
`sdui.manifest.json` at all — it was in the JSX-page compiler's prop whitelist,
which `renderers/layout/page.tsx` builds from `getKnownTypes()` plus these same
`inputs`, making the undeclared `defaultValue` a live `unknown-prop` warning.

The repo-wide parity gate now runs in both directions over one covered set and
one exemption discipline, so neither direction can be forgotten again the way
the reverse half was after PR #3806. Nine spec keys stay deliberately
unpublished, each with a written reason and a tracking issue: two the renderers
do not read at all (objectui#3829), three retired upstream by ADR-0087
tombstones, `page:tabs.type` (a carrier collision, objectstack#6776), two
`targetVariable` declarative hints (objectui#3834), and
`element:record_picker.filter` (objectui#3830).
73 changes: 69 additions & 4 deletions apps/console/src/__tests__/public-block-binding-reach.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,15 @@ const SUPERSEDES_BINDING = new Set(['data']);
*/
const sampleFor = (input: any): unknown => {
if (input.name === 'objectName') return PROBE_OBJECT;
// `record:related_list.add` — the generic `object` sample below is `{}`, and
// `{}` is not a valid `add`: the spec makes `picker` required. An invalid one
// does not merely under-configure this block, it CRASHES it
// (`RelatedList.tsx:1299` dereferences `add.picker.object`, objectui#3838) —
// and a crashed block makes no data calls, which is indistinguishable from the
// "declines to fetch" verdict this block is ledgered for below. That is a green
// for the wrong reason, so the sample is spec-valid at the source instead.
// Arrived with objectui#3808, which is when `add` became a declared input.
if (input.name === 'add') return { picker: { object: PROBE_OBJECT } };
if (input.defaultValue !== undefined) return input.defaultValue;
switch (input.type) {
case 'number':
Expand All @@ -253,13 +262,29 @@ const sampleFor = (input: any): unknown => {
};

/**
* Mount one block bare and report every data-layer call it made.
* What one probe mount observed: every data-layer call the block made, and the
* DOM it produced.
*
* The html half is here because a crash is invisible in `calls` alone —
* `SchemaRenderer` catches a renderer's throw and paints an error card, so a
* crashed block simply makes no calls, which is the pass condition on the
* ledgered branch below. Deliberately the same shape and field names as the
* sibling probe's `Mount` (`record-block-record-reach.test.tsx:310-313`), which
* has captured both halves from the start for the same reason.
*/
interface Mount {
calls: string[];
html: string;
}

/**
* Mount one block bare and report every data-layer call it made, plus the DOM.
*
* The data source is a Proxy so ANY method a block reaches for is recorded
* rather than crashing it — a block that calls `dataSource.aggregate` must not
* fail the probe merely because a hand-written stub didn't anticipate it.
*/
async function dataCallsFor(cfg: any): Promise<string[]> {
async function dataCallsFor(cfg: any): Promise<Mount> {
const calls: string[] = [];
const record = (key: string) =>
(...args: unknown[]) => {
Expand Down Expand Up @@ -309,12 +334,21 @@ async function dataCallsFor(cfg: any): Promise<string[]> {
// its object. Every call it made is already recorded above, so swallow the
// unmount and let the assertion speak to the data reach. Deliberately scoped
// to unmount: an error thrown during RENDER still propagates and fails.
// Read the DOM before unmounting: `SchemaRenderer` CATCHES a renderer's throw
// and paints an error card, so a crash never propagates here — it just makes
// the block produce nothing, including no data calls. For a ledgered block
// ("declines to fetch") that is a green earned by crashing, which is why this
// is captured and asserted rather than left to the calls alone. Same guard the
// sibling probe carries as `assertRendered`
// (`record-block-record-reach.test.tsx`), added here after objectui#3808 made
// an invalid `add` sample able to trigger exactly that.
const html = view.container.innerHTML;
try {
view.unmount();
} catch {
/* see above */
}
return calls;
return { calls, html };
}

const candidates = ComponentRegistry.getPublicConfigs().filter(declaresObjectName);
Expand All @@ -335,9 +369,40 @@ describe('public blocks — a declared objectName reaches the data layer (object
for (const cfg of candidates) {
const ledgered = cfg.type in NO_DATA_REACH;
it(`${cfg.type} ${ledgered ? 'does not reach the data layer (ledgered)' : 'asks the data layer for its objectName'}`, async () => {
const calls = await dataCallsFor(cfg);
const { calls, html } = await dataCallsFor(cfg);
const reached = calls.filter((c) => c.includes(PROBE_OBJECT));
if (ledgered) {
// A crash is not a binding answer, and on THIS branch it is
// indistinguishable from one: "made no data call" is the pass condition,
// and a block that threw during render made none either. `SchemaRenderer`
// catches the throw and paints an error card, so nothing propagates —
// without this the ledger entry would be confirmed by the block being
// broken.
//
// Added with objectui#3808, and DEFENSIVE rather than load-bearing today:
// that change made an invalid `add` sample crash `record:related_list`
// (objectui#3838), which is what it does in the sibling probe, but not
// here — `renderers/record-related-list.tsx:185` passes
// `dataSource={ctx?.dataSource}`, this probe mounts with no RecordContext,
// so `RelatedList`'s `add && dataSource` guard short-circuits before the
// unguarded read. Checked, not assumed: reverting the sample to `{}` keeps
// all 16 green. The predicate itself is known to work — applied to both
// branches it reports the two crashes in objectui#3840 — so this is a
// cheap standing guard on the one branch where a crash IS the pass
// condition, not a claim that it fires today.
//
// Deliberately NOT applied to the other branch: `object-form` and
// `object-master-detail-form` do paint an error card under this fixture
// ("Cannot read properties of undefined (reading 'map')") while still
// making their data calls, so their verdicts are earned rather than
// vacuous. Whether that card is a product bug or this fixture handing
// them an implausible configuration — the lesson the header records four
// instances of — is objectui#3840, not something to decide by widening a
// guard here.
expect(
html.includes('failed to render'),
`<${cfg.type}> threw during render, so "made no data call" proves nothing:\n${html.slice(0, 600)}`,
).toBe(false);
// Asserted, not skipped: the day this block starts binding, this fails
// and the ledger entry has to go — a ledger nobody is forced to update
// decays into the accepted-baseline problem this whole test exists for.
Expand Down
18 changes: 18 additions & 0 deletions apps/console/src/__tests__/record-block-record-reach.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,19 @@ const DATA_SOURCE_METHODS = [
* 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.
*
* `add` is the FIFTH instance, and arrived the moment `record:related_list`
* started publishing it (objectui#3808). The generic `object` sample is `{}`,
* and `{}` is not a valid `add`: the spec makes `picker` required, so the sample
* has to carry `picker.object` or the fixture is exercising metadata no author
* could publish. Filled here rather than by loosening the generic `object`
* sample, which would put an unspecified bag on every future `object` input.
*
* That `{}` did not merely under-exercise the block, it CRASHED it —
* `RelatedList.tsx:1299` dereferences `add.picker.object` where `:378` / `:390`
* optional-chain the same path — and the crash is filed as objectui#3838 rather
* than papered over here: this fixture's job is to be spec-valid, not to steer
* clear of the renderer's unguarded reads.
*/
const SAMPLE_BY_INPUT: Readonly<Record<string, unknown>> = {
// On `record:*` this names the RELATED object, not the page's object —
Expand Down Expand Up @@ -223,6 +236,11 @@ const SAMPLE_BY_INPUT: Readonly<Record<string, unknown>> = {
visible: "record.stage === 'qualified'",
title: 'Probe Alert',
body: 'Probe alert body',
// `record:related_list.add` — spec-valid minimum, i.e. `picker.object` present.
// Points at the same child object the rest of this fixture uses, so the Add
// affordance is configured against something that exists rather than at a
// dangling name.
add: { picker: { object: PROBE_CHILD_OBJECT } },
};

/** Fill one declared input. */
Expand Down
Loading
Loading