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
37 changes: 37 additions & 0 deletions .changeset/public-block-binding-reach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@object-ui/sdui-parser": patch
---

A declared `objectName` must reach the data layer — the evidence the framework's spec↔registry check cannot gather (objectstack#4472).

The framework diffs `sdui.manifest.json` against the spec's zod schemas and, while that
check was named `check:react-conformance`, it was read — by its own file header — as
confirming these components "ACTUALLY implement" the spec's props. It never could. Both
sides of that diff are **declarations**, and this repo produces one of them:
`manifestFromConfigs` copies `config.inputs` verbatim and cannot observe whether the
renderer behind a block reads any of them. So a prop both sides declare and nothing
consumes reads there as agreement — which is how objectstack#4413's four `record:*` blocks
published an `objectName`/`recordId` no renderer read, rendered blank, and stayed green.

Evidence about the render path has to be taken from the render path, so it lives here now.
`apps/console/src/__tests__/public-block-binding-reach.test.tsx` mounts every public block
that declares an `objectName` input through `SchemaRenderer` with nothing but that binding,
under a provider whose `dataSource` is a Proxy recording every call, and asserts some call
carried the object name. Deliberately narrow — "is this binding wired", not "is every
declared input consumed", which is not decidable from outside without heuristics. Every
non-reaching block carries a written reason in a ledger asserted to equal the observed set
in **both** directions, so a block that starts binding forces its entry deleted and a block
that stops binding fails; the suite was verified to go red both ways.

First run: five of eight bound blocks reach the data layer, three do not.
`record:related_list` legitimately declines to fetch without the parent record id from
`RecordContext` (already documented in @objectstack/spec's objectstack#4413 ledger).
`list-view` and `embeddable-form` do not, and that is a real defect of the same shape —
neither registration bridges the schema-renderer context onto the component's `dataSource`
prop the way `object-form` / `object-kanban` / `object-calendar` do, and `SchemaRenderer`
never injects it, so on the registry/SDUI path both render an empty shell while declaring
`objectName` **required**. Filed as objectui#3144 rather than fixed here: giving them a
data source changes what they render everywhere they are mounted bare.

`manifestFromConfigs` and `scripts/dump-public-manifest.mjs` now say in their own docs that
what they emit is what a registration *declared*, never what a renderer reads.
269 changes: 269 additions & 0 deletions apps/console/src/__tests__/public-block-binding-reach.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
/**
* 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.
*
* Public blocks — a declared `objectName` binding must REACH THE DATA LAYER
* (objectstack#4472, the detection half of objectstack#4413).
*
* ## Why this exists
*
* The framework's spec↔registry check (`check:react-declaration-parity`)
* compares two DECLARATIONS: the spec zod schema's props on one side, and on
* the other the `inputs` this repo's registry configs declare — copied verbatim
* into `sdui.manifest.json` by `manifestFromConfigs`. Neither side observes a
* renderer. So a prop that both sides declare and NO renderer consumes reads as
* perfect agreement over there, and it did: `record:details` /
* `record:highlights` / `record:related_list` / `record:path` published
* `objectName`+`recordId` that nothing read, four blocks rendered blank, and
* that check stayed green for the whole life of the defect (objectstack#4413).
* It was found by a human reading these renderers.
*
* This test is the evidence that check cannot gather, taken from the only place
* that has it — the render path. It is deliberately narrow: not "is every
* declared input consumed" (undecidable from outside without heuristics) but
* one exact, observable question per block —
*
* mount it through `SchemaRenderer` with a plausible value for every input it
* declares, under a provider whose `dataSource` records every call:
* **did any call carry the object name?**
*
* Every declared input, not just the required ones: a block's read path can be
* gated on an optional one (`embeddable-form` only builds the read-only source
* its inner `ObjectForm` fetches through when `config.fields` is non-empty), and
* omitting it would read here as "does not bind" when the truth is "was never
* asked to". Values are plausible rather than degenerate for the same reason —
* an early `[]` for `columns` makes a list render its empty state without ever
* asking for data.
*
* A block that declares `objectName` and asks the data layer for something else
* — or for nothing at all — is not bound to the object it advertises. That is
* the objectstack#4413 shape, stated behaviourally.
*
* ## Scope, stated so it is not over-read in turn
*
* A reaching call proves the binding is WIRED, not that the block renders
* correctly. And "did not reach" has legitimate causes (a block that needs a
* parent record id first), which is what the ledger below is for — every
* non-reaching block carries a written reason, and the ledger is asserted to
* equal the observed set in BOTH directions, so a block that starts reaching
* must be removed from it and a block that stops reaching fails here.
*/

import { describe, it, expect } from 'vitest';
import { render, act } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
// The two graphs whose registrations this reads — the layout/content primitives
// and the console's own plugin layer, from the module main.tsx boots from. Same
// posture as public-contract.test.ts: read the REAL registration list, because a
// hand-copied one would agree with itself and tell us nothing.
import '@object-ui/components';
import '../register-plugins';

/** The object name every probed block is bound to; must appear in a data call. */
const PROBE_OBJECT = 'probe_object__c';

/**
* `DataSource` methods the recording stub carries as real own properties, so a
* block that derives a source by spreading (`{...dataSource, …}`) still gets
* them. Not exhaustive and does not need to be — anything unlisted is served by
* the Proxy's `get` — it only needs to cover what survives a spread.
*/
const DATA_SOURCE_METHODS = [
'find',
'findOne',
'create',
'update',
'delete',
'aggregate',
'count',
'getObjectSchema',
'getObjects',
'getView',
'listViews',
'listViewOverrides',
'updateViewConfig',
'onMutation',
] as const;

/**
* Blocks that declare an `objectName` input and do NOT reach the data layer
* with it, each with the reason. Entries are debt, not acceptance — an entry
* whose block starts reaching fails this test until it is deleted.
*/
const NO_DATA_REACH: Readonly<Record<string, string>> = {
// Legitimate, and documented on the framework side (@objectstack/spec
// react-blocks.ts, the objectstack#4413 exclusion ledger): this block renders
// a CHILD list scoped to a parent record, and takes that parent from the
// record page's shared record context. Mounted with no record bound there is
// no parent id, so it correctly declines to fetch rather than listing the
// whole child object. `objectName` IS read — it names the related object and
// titles the panel.
'record:related_list':
'needs the parent record id from RecordContext before it may fetch; declines to fetch without one (objectstack#4413 ledger)',

// Both of these are the SAME defect, and it is a real one — debt recorded
// here, not divergence accepted. Neither registration bridges the
// schema-renderer context onto the component's `dataSource` PROP:
// `object-form`, `object-kanban` and `object-calendar` each register a small
// renderer that does exactly that, `list-view` is registered as the bare
// `ListView` (which reads `props.dataSource`), and `embeddable-form`'s
// renderer is `({schema}) => <EmbeddableForm config={schema} />`, which drops
// it. `SchemaRenderer` never injects `dataSource` into props, so on the
// registry/SDUI path both render an empty shell while declaring `objectName`
// **required** — the objectstack#4413 shape, one layer up.
//
// Verified to be the wiring and not this probe's reach: `embeddable-form`
// fetches the moment the bridge exists (its inner `ObjectForm` calls
// `getObjectSchema` through the read-only source it derives), and does not
// without it, on the identical mount.
//
// Filed rather than fixed alongside this suite: giving these two a data source
// changes what they render everywhere they are mounted bare, which wants its
// own review — objectui#3144. When it lands, the assertions below FORCE these
// two entries deleted; a ledger nobody must update is how an accepted baseline
// starts.
'list-view':
'registered bare; ListView reads props.dataSource and SchemaRenderer never injects it — objectui#3144',
'embeddable-form':
'renderer drops the context dataSource (`<EmbeddableForm config={schema} />`) — objectui#3144',
};

/** Does this config declare an `objectName` input? */
const declaresObjectName = (cfg: { inputs?: Array<{ name?: string }> }) =>
(cfg.inputs ?? []).some((i) => i?.name === 'objectName');

/**
* A plausible value for one declared input.
*
* "Plausible", not "present": arrays are non-empty because an empty `columns` is
* a config a block can legitimately short-circuit on, and a block that renders
* its empty state without asking for data would read here as an unbound
* binding.
*/
const sampleFor = (input: any): unknown => {
if (input.name === 'objectName') return PROBE_OBJECT;
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 input.name === 'recordId' ? 'probe-record-1' : 'x';
}
};

/**
* Mount one block bare and report every data-layer call it made.
*
* 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[]> {
const calls: string[] = [];
const record = (key: string) =>
(...args: unknown[]) => {
calls.push(`${key}(${args.map((a) => JSON.stringify(a) ?? 'undefined').join(', ')})`);
// Subscription methods hand back an UNSUBSCRIBE function, which the block
// calls on unmount. Returning a promise for those crashes the teardown
// (`unsub is not a function`) — a failure that says nothing about the
// block.
return /^on[A-Z]/.test(key) || key === 'subscribe' ? () => {} : Promise.resolve([]);
};
// Seeded with real own properties, not a bare Proxy target: a block may hand
// its own children a DERIVED source (`{...dataSource, create: stub}` — that is
// exactly what `embeddable-form` does to neutralise writes on a public form),
// and spreading a Proxy over `{}` copies nothing, silently stripping every
// method. The Proxy still answers anything unseeded, so a block reaching for a
// method not listed here is recorded rather than crashing.
const seeded: Record<string, unknown> = {};
for (const m of DATA_SOURCE_METHODS) seeded[m] = record(m);
const dataSource: any = new Proxy(seeded, {
get: (target, key: string) => (key in target ? (target as any)[key] : record(key)),
});

// Only `objectName` + the inputs the block declares REQUIRED. A bogus value
// for an optional input is a degenerate config that can make a block bail out
// before it ever asks for data — which would read here as a false finding.
// EVERY declared input, not just the required ones — see the file header: a
// read path can be gated on an optional input, and leaving it out would report
// a block as unbound when it was simply never asked to fetch.
const schema: Record<string, unknown> = { type: cfg.type };
for (const input of cfg.inputs ?? []) {
schema[input.name] = sampleFor(input);
}

const view = render(
<SchemaRendererProvider dataSource={dataSource}>
<SchemaRenderer schema={schema as any} />
</SchemaRendererProvider>,
);
// 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));
});
}
view.unmount();
return calls;
}

const candidates = ComponentRegistry.getPublicConfigs().filter(declaresObjectName);

describe('public blocks — a declared objectName reaches the data layer (objectstack#4472)', () => {
it('finds the object-bound blocks to probe (guards against probing nothing)', () => {
// If the registry ever stops exposing these, this suite would pass by
// probing an empty list — the failure mode a coverage gate must not have.
expect(candidates.length).toBeGreaterThan(0);
expect(candidates.map((c) => c.type)).toContain('object-form');
});

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 reached = calls.filter((c) => c.includes(PROBE_OBJECT));
if (ledgered) {
// 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.
expect(reached, `${cfg.type} now reaches the data layer — delete its NO_DATA_REACH entry`).toEqual([]);
} else {
expect(
reached.length,
`<${cfg.type}> declares an \`objectName\` input but made no data call naming "${PROBE_OBJECT}".\n` +
`Calls observed: ${calls.length ? calls.join(' | ') : '(none)'}\n` +
'Either the binding does not reach the renderer (the objectstack#4413 shape — fix the wiring),\n' +
'or the block legitimately cannot fetch yet: add it to NO_DATA_REACH with the reason.',
).toBeGreaterThan(0);
}
}, 30_000);
}

it('the ledger names only blocks that really do not reach — no stale entries', () => {
const unknown = Object.keys(NO_DATA_REACH).filter(
(type) => !candidates.some((c) => c.type === type),
);
expect(
unknown,
'NO_DATA_REACH lists blocks that no longer declare an `objectName` input — delete them',
).toEqual([]);
for (const [type, reason] of Object.entries(NO_DATA_REACH)) {
expect(reason.length, `${type} needs a written reason, not an empty one`).toBeGreaterThan(20);
}
});
});
17 changes: 17 additions & 0 deletions packages/sdui-parser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,23 @@ const INPUT_TYPES = new Set([
'slot',
]);

/**
* Project registry configs into the SDUI manifest.
*
* ⚠️ Every field here is copied from what the registration **declared**. In
* particular `inputs` is `config.inputs` verbatim — this function does not, and
* cannot, observe whether the renderer behind the block reads any of them.
*
* Worth stating because of what consumes the output. The framework's
* `check:react-blocks-declaration-parity` diffs this against the spec's zod
* schemas, and while that check was named `check:react-conformance` it was read
* — by its own file header — as confirming the components "ACTUALLY implement"
* the spec's props. It never could: both sides of that diff are declarations,
* and this is the side this file produces. Four blocks published an `objectName`
* no renderer read and sailed through it green (objectstack#4413; corrected in
* objectstack#4472). Evidence about the render path has to come from the render
* path — see `apps/console/src/__tests__/public-block-binding-reach.test.tsx`.
*/
export function manifestFromConfigs(
configs: RegistryConfigLike[],
opts: { only?: Set<string>; publicOnly?: boolean } = {},
Expand Down
8 changes: 8 additions & 0 deletions scripts/dump-public-manifest.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
// ADR-0080: generate the public-tier SDUI component manifest.
//
// The manifest records what each registration DECLARES (`config.inputs`, copied
// verbatim by `manifestFromConfigs`), never what a renderer reads. The framework
// diffs it against the spec schemas — a comparison of two declarations, so a
// prop both sides declare and nothing consumes reads there as agreement
// (objectstack#4413 shipped through exactly that; objectstack#4472 renamed the
// check to `check:react-declaration-parity` to stop it reading as proof of
// implementation).
//
// The registry is a browser app (plugin-map/charts pull browser-only deps), so
// the reliable way to enumerate it is in a real browser: load the built
// `manifest-dump.html` (which registers everything the console does and exposes
Expand Down
Loading