diff --git a/.changeset/10528-grid-object-table-fetch.md b/.changeset/10528-grid-object-table-fetch.md
index f1c98ba939..9c46e20325 100644
--- a/.changeset/10528-grid-object-table-fetch.md
+++ b/.changeset/10528-grid-object-table-fetch.md
@@ -33,3 +33,5 @@ Later in this same release objectui#10859 batch 8 (phase 2b) unregistered the `d
node key. `DashboardGridLayout` is unchanged and stays exported, so this fix still holds wherever
a host mounts it; it is no longer reachable as a schema `type`. The rest of this entry is kept as
the reading of this change.
+
+⚠️ **Dated note, 2026-10-03 — the grid's static pivot node states its keys — objectui#11466.** At this change, "Widgets bound to inline rows are unchanged, including static-data pivots" held, and the grid's static-data pivot node spread the widget's `options` whole. Now that node states the keys `PivotTableSchema` declares and `PivotTable` draws, read from `options`, and no other option key reaches it. `.changeset/11466-dashboard-metric-node.md` states what ships. The rest of this entry is kept as the reading of this change.
diff --git a/.changeset/11348-dashboard-widget-reads.md b/.changeset/11348-dashboard-widget-reads.md
index c4736caced..ee047cf58a 100644
--- a/.changeset/11348-dashboard-widget-reads.md
+++ b/.changeset/11348-dashboard-widget-reads.md
@@ -32,3 +32,5 @@ literal is unchanged.
⚠️ **Dated note, 2026-10-02 — the component arm now declares `title` — objectui#11467.** "`title` and `colorVariant` are still declared on the widget arm only" in the note above held when that note was written. Later in this same release, objectui#11467 declared `metric-card`'s registered inputs on the component arm, `title` among them as the card's heading, typed as `MetricCard` reads it. So `title` is declared on both arms, and it reads as `string | I18nLabel` straight off a `widgets[]` entry. `colorVariant` is still declared on the widget arm only. `.changeset/11467-metric-card-arm-inputs.md` states what ships; the text above is kept as the reading of this change.
⚠️ **Dated note, 2026-10-03 — the read sites move to the slot's element type — objectui#11514.** At this change, the `layout` / `title` / `colorVariant` sites above read a `widgets[]` entry through `DashboardWidgetSchema`, and the component arm was assignable to it. Now `DashboardWidgetSchema['type']` names no component type, so the component arm is not assignable to it, and the sites in `DashboardGridLayout`, `DashboardRenderer`, `DashboardWithConfig` and the designer's `DashboardEditor` read an entry by the slot's element type, `DashboardComponentSchema['widgets'][number]`. `layout` and `title` read with their declared types off either arm; `colorVariant` is still declared on the widget arm only. `.changeset/11514-dashboard-slot-entry-types.md` states what ships. The rest of this entry is kept as the reading of this change.
+
+⚠️ **Dated note, 2026-10-03 — the broadcast narrows over two types — objectui#11466.** At this change, the dashboard-filter broadcast's type guard ran "over the same three `object-*` types". Now `object-metric` has left that set: the broadcast narrows over `object-chart` and `object-data-table`, the two node schemas that declare `filter`, and an `object-metric` node in a widget's legacy `component` envelope draws the retired-format placeholder instead (`.changeset/11466-envelope-object-metric-retired.md`). The rest of this entry is kept as the reading of this change.
diff --git a/.changeset/11354-plugin-form-literals.md b/.changeset/11354-plugin-form-literals.md
index ab7ba54ca8..2cf4eedb64 100644
--- a/.changeset/11354-plugin-form-literals.md
+++ b/.changeset/11354-plugin-form-literals.md
@@ -4,3 +4,5 @@
No package released. `@object-ui/plugin-form`'s `DrawerForm`, `ModalForm`, `SplitForm`, `TabbedForm` and `WizardForm` each build a child `form` node and hand it to `SchemaRenderer`. Each node is now a `FormSchema` const before it reaches `SchemaRenderer`. Written inline in `SchemaRenderer`'s `schema` slot, which is typed `BaseSchema`, the node was checked against `BaseSchema` rather than against the `FormSchema` the `form` renderer reads (objectui#11354, preparing objectui#8347's removal of `BaseSchema`'s index signature).
No key is added, removed or renamed, and every value is unchanged. No declaration changes: `FormSchema` already declares every key these nodes carry. The rendered output, the props each component accepts, and the published `.d.ts` are unchanged, so there is nothing to release.
+
+⚠️ **Dated note, 2026-10-02 — the prop takes the declared-node union — objectui#11466.** At this change a node written inline in `SchemaRenderer`'s `schema` slot was checked against `BaseSchema`; now, later in this same release, the slot is `DeclaredNode | string | null | undefined`, the union of the declared node types keyed by `type`, so an inline `form` node is checked against `FormSchema`. The rest of this entry is kept as the reading of this change.
diff --git a/.changeset/11364-public-block-ts-faces.md b/.changeset/11364-public-block-ts-faces.md
index 732a9f3d08..8b02ac289c 100644
--- a/.changeset/11364-public-block-ts-faces.md
+++ b/.changeset/11364-public-block-ts-faces.md
@@ -38,3 +38,5 @@ does needs to narrow first. Once objectui#8347 removes the index signature,
a key misspelled inside one of these nodes' bags is refused, and the spec's
spelling compiles. None of these types carries an index signature, and the
prop gains none.
+
+⚠️ **Dated note, 2026-10-02 — the prop takes the declared-node union — objectui#11466.** At this change `SchemaRendererProps.schema` was `BaseSchema | AuthoringNode | string | null | undefined`, and every value the old union accepted was still accepted; now, later in this same release, it is `DeclaredNode | string | null | undefined`. `DeclaredNode` (`@object-ui/types`) is the union, keyed by `type`, of the component schemas `AnySchema` declares (without its `BaseSchema` arm and without the app-level document `AppComponentSchema`), `AuthoringNode`, and the types an application declares in `CustomNodeRegistry`, so a value typed `BaseSchema` or a `type` nothing declares is refused. `PageDocumentNode` admits every page kind but the interface-mode `list`. The rest of this entry is kept as the reading of this change.
diff --git a/.changeset/11466-dashboard-metric-node.md b/.changeset/11466-dashboard-metric-node.md
new file mode 100644
index 0000000000..5efb4b58ea
--- /dev/null
+++ b/.changeset/11466-dashboard-metric-node.md
@@ -0,0 +1,9 @@
+---
+'@object-ui/plugin-dashboard': minor
+---
+
+The dashboards' metric card node, `plugin-dashboard:metric`, is a declared node type, and both dashboard surfaces hand `SchemaRenderer` declared nodes with no cast (objectui#11466).
+
+- **New export `DashboardMetricNodeSchema`:** the node `MetricWidget` renders under `plugin-dashboard:metric`, with the keys it reads off the node, typed by `MetricWidgetProps`. This package enters it in `@object-ui/types`' `CustomNodeRegistry` under that key, so a program that loads this package's typings has the node as a member of `DeclaredNode`: authorable in a node slot and at `SchemaRenderer`'s `schema` prop, with its keys checked.
+- **`DashboardGridLayout`'s static `pivot` node** (a `pivot` widget drawn from inline rows) states the keys `PivotTableSchema` declares and `PivotTable` draws, read from `options`: `title`, `rowField`, `columnField`, `valueField`, `aggregation`, `showRowTotals`, `showColumnTotals`, `format`, `columnColors` and `className`. It used to spread `options` whole, so every option key became a key of the node, and `SchemaRenderer` handed each one on as a React prop. Now no other option key reaches the node: not a host prop of `PivotTable`'s (such as `rowLabels`), and not a `BaseSchema` node key other than `className` (such as `id`, `style` or `hidden`). Each key listed above draws as before.
+- **The two surfaces' casts before `SchemaRenderer` are gone** (`DashboardGridLayout`'s `as BaseSchema | string | null | undefined` and `DashboardRenderer`'s `as BaseSchema`). Each surface's node builder returns `SchemaRenderer`'s own prop type, so every node is checked against its declared type where it is built. No runtime change.
diff --git a/.changeset/11466-envelope-object-metric-retired.md b/.changeset/11466-envelope-object-metric-retired.md
new file mode 100644
index 0000000000..2599f182ed
--- /dev/null
+++ b/.changeset/11466-envelope-object-metric-retired.md
@@ -0,0 +1,20 @@
+---
+'@object-ui/plugin-dashboard': minor
+---
+
+**An `object-metric` node in a dashboard widget's legacy `component` envelope now draws the retired-format prompt instead of its number (objectui#11466).** This follows the maintainer's ruling A on objectui#11466, which extends ruling C on objectui#11525 (a dashboard metric takes its number only through a `dataset`) to this last inline metric form.
+
+**What changes on screen.** The change covers a stored widget written in objectui's legacy envelope format, `{ id, component: { type: 'object-metric', objectName, aggregate, … }, layout }`, which the spec's widget has no member for:
+
+- **Before:** `DashboardRenderer` and `DashboardGridLayout` drew the aggregated number in the tile, and on `DashboardRenderer` the dashboard filter bar's value was merged into the node as a flat `filter`.
+- **Now:** both surfaces draw "This widget uses a retired data format. Edit it to bind a dataset." and send no query. The same holds when the node is written under its registration's full name, `plugin-dashboard:object-metric`. The filter bar's broadcast no longer covers `object-metric`.
+
+Breaking for stored dashboards that still carry this form; `minor` because objectui never declares `major`. No producer of the form was measured in objectui or objectstack outside the pin that covered it (the measurement is on objectui#11466); stored customer dashboards are unmeasured, the gap ruling C accepted.
+
+**The fix.** Replace the envelope with a dashboard widget bound to a dataset: set `dataset` and select its `values` by name, as for any metric widget. A dataset-bound metric draws its number.
+
+**What does not move:**
+
+- Every other node in a `component` envelope draws as before, and the filter bar still scopes an envelope's `object-chart` and `object-data-table`.
+- An `object-metric` block authored on a page (`{ type: 'object-metric', properties: { … } }`) draws as before.
+- The zod and TypeScript authoring faces are unchanged.
diff --git a/.changeset/11466-node-slot-union.md b/.changeset/11466-node-slot-union.md
new file mode 100644
index 0000000000..990885be23
--- /dev/null
+++ b/.changeset/11466-node-slot-union.md
@@ -0,0 +1,44 @@
+---
+'@object-ui/types': minor
+'@object-ui/react': minor
+'@object-ui/core': minor
+'@object-ui/components': patch
+---
+
+A node slot and `SchemaRenderer`'s `schema` prop take the union of the declared node types, `DeclaredNode` (objectui#11466).
+
+**BREAKING (TypeScript authoring face only), shipped as `minor` per this repository's version policy.** `SchemaNode` was `BaseSchema | string | number | boolean | null | undefined`, and the prop was `BaseSchema | AuthoringNode | string | null | undefined`. Both object members are now `DeclaredNode`, a new export of `@object-ui/types`: the discriminated union, keyed by the literal `type`, of
+
+- every component schema `AnySchema` declares, without its `BaseSchema` arm (whose `type` is `string`) and without `AppComponentSchema` (the app-level document, which `AppSchemaRenderer` reads structurally and `ComponentRegistry` never dispatches: as a node, `type: 'app'` is the stored page of that kind);
+- every spec-declared `AuthoringNode`;
+- every type an application declares in the new `CustomNodeRegistry` interface.
+
+There is no `type: string` arm and no index signature. What moves:
+
+- A node whose `type` no declaration names is refused, nested or at the prop: `{ type: 'card', children: [{ type: 'txt' }] }` no longer compiles. So is a value typed `BaseSchema`, which names no declared type.
+- An inline child is checked against its own type's arm wherever it is nested, so a misspelled key on a node type without an index signature (the `AuthoringNode`s, a closed `CustomNodeRegistry` entry) is refused at the slot. Node types that extend `BaseSchema` keep its index signature until objectui#8347 removes it.
+- A node's REQUIRED keys are required (a stored `home` page document needs its `label`, a `data-table` its `columns`), because no `BaseSchema` arm accepts the same literal structurally any more.
+- `app` and `list` are one arm each: `PageDocumentNode` admits every page kind but the interface-mode `list`, which `PageView` renders through `InterfaceListPage` and never hands to `SchemaRenderer`. Narrowing a `DeclaredNode` on `type === 'app'` gives `PageDocumentNode`, and on `'list'` gives `ListSchema`. `SchemaByType` reads `AnySchema` and does not move.
+- `@object-ui/core`'s schema builder takes `DeclaredNode` where it took `BaseSchema`: `.child()` / `.children()` on the grid and flex builders and the card builder's `.content()`.
+- `toRenderableSchema` (`@object-ui/react`) follows `SchemaNode` and the prop by reference.
+
+What does NOT move: every zod face (`AnyComponentSchema`, `SchemaNodeSchema`, the strict authoring face) and every runtime path. `zod/base.zod.ts` writes the node slot's zod type out through type aliases so the declared-node union can name the zod-derived `AuthoringNode`s without a circular reference; the schema objects are unchanged. `FlexBlockNode` is now an interface for the same reason, with the same members. `@object-ui/components`' `kind: 'html'` page crosses its runtime-parsed tree into `DeclaredNode` at one documented boundary, after `validateTree` reports no error; it renders exactly what it rendered.
+
+**Migration.** Annotate a node with its declared type, or with `DeclaredNode` where any node goes. Declare each type you register with `ComponentRegistry` in `CustomNodeRegistry`:
+
+```ts
+import type { BaseSchema } from '@object-ui/types';
+
+interface MyWidgetSchema extends BaseSchema {
+ type: 'my-widget';
+ customProp?: string;
+}
+
+declare module '@object-ui/types' {
+ interface CustomNodeRegistry {
+ 'my-widget': MyWidgetSchema;
+ }
+}
+```
+
+An entry joins the union under its KEY (the arm is the entry intersected with `{ type: KEY }`), so it never adds a `type: string` arm. A value built at runtime whose `type` the compiler cannot know is narrowed to a declared type, or crosses at one validated boundary, as the html-tier page does.
diff --git a/.changeset/11479-producers-declared-types.md b/.changeset/11479-producers-declared-types.md
index f71966f6fe..70dae3423a 100644
--- a/.changeset/11479-producers-declared-types.md
+++ b/.changeset/11479-producers-declared-types.md
@@ -5,3 +5,5 @@
`toRenderableSchema` declares its parameter as `SchemaNode` (objectui#11479).
The parameter used to spell out `SchemaNode`'s members one by one. It now names the union itself, so it follows `SchemaNode` instead of keeping a hand-written copy of it. Nothing it accepts changes today: the two spellings are the same type, and the function's return type and behaviour are untouched.
+
+⚠️ **Dated note, 2026-10-02 — the parameter narrows with `SchemaNode` — objectui#11466.** At this change the parameter's two spellings were the same type and nothing it accepted changed; now, later in this same release, `SchemaNode`'s object arm is `DeclaredNode`, the union of the declared node types, so the parameter follows it and no longer accepts a value typed `BaseSchema`. That is the point of naming the union instead of copying it. The rest of this entry is kept as the reading of this change.
diff --git a/.changeset/11514-dashboard-slot-entry-types.md b/.changeset/11514-dashboard-slot-entry-types.md
index 81562e32b6..c41e3522fc 100644
--- a/.changeset/11514-dashboard-slot-entry-types.md
+++ b/.changeset/11514-dashboard-slot-entry-types.md
@@ -13,3 +13,7 @@
- **What does not move.** Every widget that names a known family draws as before: the dispatch routes the same families, and each `object-chart` node carries the same `chartType` it carried. A legacy `component` envelope with no `type` is not the spec's widget and is not given its default; it draws under its card heading as before, and a number or `true` in its `component` draws the same text, now forwarded through `toRenderableSchema`.
⚠️ **Dated note, 2026-10-03 — a dataset-less `provider: 'object'` metric is retired — objectui#11525.** At this change, "Every widget that names a known family draws as before" held for the single-value family's inline `provider: 'object'` widget, which drew its number through a flat `object-metric` node. Now a `metric`, `gauge`, `solid-gauge`, `kpi` or `bullet` widget, or a typeless one, with no `dataset` and an `options.data` (or widget-level `data`) of `{ provider: 'object', … }` draws the retired-format placeholder on both surfaces, by the maintainer's ruling C on objectui#11525. A typeless widget still draws exactly as the same widget with `type: 'metric'`, so it draws that placeholder too. `.changeset/11525-inline-metric-retired.md` states what ships. The rest of this entry is kept as the reading of this change.
+
+⚠️ **Dated note, 2026-10-03 — the grid's static pivot node states its keys — objectui#11466.** At this change, "Every widget that names a known family draws as before" held for a `pivot` widget drawn from inline rows on `DashboardGridLayout`, whose node spread the widget's `options` whole. Now that node states the keys `PivotTableSchema` declares and `PivotTable` draws, read from `options`, and no other option key reaches it. `.changeset/11466-dashboard-metric-node.md` states what ships. The rest of this entry is kept as the reading of this change.
+
+⚠️ **Dated note, 2026-10-03 — an envelope's `object-metric` draws the placeholder — objectui#11466.** At this change, a legacy `component` envelope "draws under its card heading as before" whatever node it held. Now an `object-metric` node in it draws the retired-format placeholder under that heading and sends no query, by the maintainer's ruling A on objectui#11466; every other envelope node draws as before. `.changeset/11466-envelope-object-metric-retired.md` states what ships. The rest of this entry is kept as the reading of this change.
diff --git a/.changeset/11525-inline-metric-retired.md b/.changeset/11525-inline-metric-retired.md
index b8a7e79cbd..d612c72f6b 100644
--- a/.changeset/11525-inline-metric-retired.md
+++ b/.changeset/11525-inline-metric-retired.md
@@ -38,3 +38,5 @@ measured emitting the form (the measurements are on objectui#11525).
The flat `object-metric` node the two surfaces used to build carried an
ObjectQL-dialect `filter` that no node type declares; it is gone.
+
+⚠️ **Dated note, 2026-10-03 — the envelope's `object-metric` is retired too — objectui#11466.** At this change, "An `object-metric` node an author places in a widget's legacy `component` envelope still receives the dashboard filter bar's values" held, because `object-metric` stayed in the filter broadcast's filterable set for that node. Now, by the maintainer's ruling A on objectui#11466 (extending this change's ruling C), that envelope node draws the same retired-format placeholder on both surfaces and sends no query, and `object-metric` left the filterable set. `.changeset/11466-envelope-object-metric-retired.md` states what ships. The rest of this entry is kept as the reading of this change.
diff --git a/.changeset/7767-collapsible-trigger-union.md b/.changeset/7767-collapsible-trigger-union.md
index 508d6c78c8..7679f285e2 100644
--- a/.changeset/7767-collapsible-trigger-union.md
+++ b/.changeset/7767-collapsible-trigger-union.md
@@ -9,3 +9,5 @@
This is a **widening**, not a replacement: every singular `trigger` keeps type-checking unchanged. The Zod mirror is untouched — `zod/disclosure.zod.ts` already spelled this key `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` — and so is the runtime: `renderers/disclosure/collapsible.tsx` hands `schema.trigger` to `renderChildren`, whose `Array.isArray` branch has served the array form all along, and that same registration's `defaultProps.trigger` ships as an array. What changes is that the TypeScript face stops under-reporting an accept set that already ships: copying the renderer's own default into a typed document is no longer a type error against the type that shipped it. The docs page's `trigger` row follows the declaration.
This is the eighth member of the change objectui#7081 made to the overlay family, carried out under the same ruling (2026-09-03 on that card): the validator's accept set does not move, so this is a declaration catching up with what ships rather than a new capability. Per this repository's version-alignment convention, a widening of a published type surface ships as `minor` with the semantics spelled out here rather than as `major` (see AGENTS.md, "版本号策略").
+
+⚠️ **Dated note, 2026-10-02 — `SchemaNode`'s object arm — objectui#11466.** At this change `SchemaNode` was `BaseSchema | string | number | boolean | null | undefined`; now, later in this same release, its object arm is `DeclaredNode`, the union of the declared node types. `string` is still a member, so `string | SchemaNode` still denotes exactly `SchemaNode`. The rest of this entry is kept as the reading of this change.
diff --git a/.changeset/8310-page-node-body-arity.md b/.changeset/8310-page-node-body-arity.md
index 3523667b1c..f9b9b2b653 100644
--- a/.changeset/8310-page-node-body-arity.md
+++ b/.changeset/8310-page-node-body-arity.md
@@ -34,3 +34,7 @@ arity of one declared key. It does not make the page node a closed surface.
Which channel `PageRenderer` reads — `body` or `children` — is a separate question and is
not touched here (objectui#8284 remains open).
+
+⚠️ **Dated note, 2026-10-02 — the prop takes the declared-node union — objectui#11466.** At this change `SchemaRendererProps.schema` was annotated `BaseSchema`, the wider parent the root README's example type-checked through; now, later in this same release, it is `DeclaredNode | string | null | undefined`, the union of the declared node types keyed by `type`, and the README annotates its example `DeclaredNode`, so a `page` node there is judged against the declared node types its `type` names. The bound paragraph still holds until objectui#8347: `PageNodeSchema` extends `BaseSchema`, whose index signature absorbs a misspelled key on its own arm. The rest of this entry is kept as the reading of this change.
+
+⚠️ **Dated note, 2026-10-03 — the root README's example authors `children`, not `body` — objectui#11466.** At this change the root `README.md` "Basic Usage" example gave `body` one `grid` node; now that example's `page` node carries its one `grid` node under `children`, and no `body` appears in it. The README moved before objectui#11466 (its branch base, `f68e0a08`, already authors `children`), so this note corrects the entry's reading, not that change. The arity this entry widened is still `PageNodeSchema.body`'s. The rest of this entry is kept as the reading of this change.
diff --git a/.changeset/8331-data-table-empty-action-primitive-node.md b/.changeset/8331-data-table-empty-action-primitive-node.md
index 5a8c1b78ce..e232732049 100644
--- a/.changeset/8331-data-table-empty-action-primitive-node.md
+++ b/.changeset/8331-data-table-empty-action-primitive-node.md
@@ -42,3 +42,5 @@ whose `String` mapping would otherwise turn them into the text `"0"` and `"false
**Migration.** Nothing has to change. Metadata that already authored an object node in
this slot is unaffected. Metadata that authored a bare string was rendering nothing and
now renders that string — which is what the declaration always promised.
+
+⚠️ **Dated note, 2026-10-02 — `SchemaNode`'s object arm — objectui#11466.** At this change `SchemaNode` was `BaseSchema | string | number | boolean | null | undefined`; now, later in this same release, its object arm is `DeclaredNode`, the union of the declared node types, so it reads `DeclaredNode | string | number | boolean | null | undefined`. The primitive members this entry is about are unchanged, and so is the slot's behaviour. The rest of this entry is kept as the reading of this change.
diff --git a/.changeset/8572-chatbot-body-retired.md b/.changeset/8572-chatbot-body-retired.md
index 1c12f8db8b..e561965766 100644
--- a/.changeset/8572-chatbot-body-retired.md
+++ b/.changeset/8572-chatbot-body-retired.md
@@ -123,3 +123,5 @@ collision awaiting a ruling" and now records that the ruling landed for one of t
chatbot faces. A third — `7655-chatbot-registration-authoring-faces.md` — says the twins
"do not copy `ChatbotSchema`'s `body` naming collision"; that sentence's claim about the
TWINS is still true and is left alone, and the collision it names is the one retired here.
+
+⚠️ **Dated note, 2026-10-02 — the node-recursion pin reads a measured set — objectui#11466.** At this change `ArmsNotAssignableToSchemaNode` read `never`; now, later in this same release, `SchemaNode`'s object arm is `DeclaredNode`, which has no `type: string` arm, so the pin reads the arms whose zod output no declared node type admits, written out in `node-recursion-point-8344.test.ts` as a measured set with a reason per arm, and the empty-set guard was replaced by a distributive projection. The rest of this entry is kept as the reading of this change.
diff --git a/.changeset/8908-bridge-falsy-primitives.md b/.changeset/8908-bridge-falsy-primitives.md
index 8113b5a31d..4d3606978e 100644
--- a/.changeset/8908-bridge-falsy-primitives.md
+++ b/.changeset/8908-bridge-falsy-primitives.md
@@ -53,3 +53,5 @@ the bridge, and it is why `emptyAction` was never affected by this defect in the
place.
⚠️ **Dated note, 2026-10-01 — the bridge's return type widens with the prop — objectui#11364.** Later in this same release `SchemaRendererProps.schema` becomes `BaseSchema | AuthoringNode | string | null | undefined`. `toRenderableSchema` returns `SchemaRendererProps['schema']` by reference, so its return type widens with it. So "The return type is unchanged: `BaseSchema | string | null | undefined`" no longer describes the release as a whole: the falsy leg still adds no `number` / `boolean`, and the pinned equality with the prop still holds. The rest of this entry is kept as the reading of this change.
+
+⚠️ **Dated note, 2026-10-02 — the prop takes the declared-node union — objectui#11466.** At this change, and at objectui#11364's note above, the bridge's return type spelled its object member `BaseSchema` (with `AuthoringNode` beside it after objectui#11364); now, later in this same release, `SchemaRendererProps.schema` is `DeclaredNode | string | null | undefined`, and the return follows it by reference. The falsy leg still adds no `number` / `boolean`, and the pinned equality with the prop still holds. The rest of this entry is kept as the reading of this change.
diff --git a/.changeset/schema-input-bridge-permanent-4622.md b/.changeset/schema-input-bridge-permanent-4622.md
index 4ac2773a2d..07f6344674 100644
--- a/.changeset/schema-input-bridge-permanent-4622.md
+++ b/.changeset/schema-input-bridge-permanent-4622.md
@@ -38,3 +38,5 @@ forwarding directly when PR #4608 landed, and `Build Docs` was red on `main` for
five hours until PR #4621 routed all five through this function (objectui#4617).
⚠️ **Dated note, 2026-10-01 — the prop's union widens — objectui#11364.** Later in this same release `SchemaRendererProps.schema` becomes `BaseSchema | AuthoringNode | string | null | undefined`, and `toRenderableSchema`'s return widens with it by reference. So the union spelled above (`schema: BaseSchema | string | null | undefined`) no longer describes the release as a whole. What it was quoted for still holds: the prop declares no `number` / `boolean`, so the bridge stays the crossing from `SchemaNode`. The rest of this entry is kept as the reading of this change.
+
+⚠️ **Dated note, 2026-10-02 — the prop takes the declared-node union — objectui#11466.** At this change the prop was `BaseSchema | string | null | undefined` (and `BaseSchema | AuthoringNode | string | null | undefined` after objectui#11364's note above); now, later in this same release, it is `DeclaredNode | string | null | undefined`, and `SchemaNode`'s object arm is `DeclaredNode` too. The prop still declares no `number` / `boolean`, so the bridge stays the crossing from `SchemaNode`. The rest of this entry is kept as the reading of this change.
diff --git a/README.md b/README.md
index 0c6d469f04..9c48a64f71 100644
--- a/README.md
+++ b/README.md
@@ -73,11 +73,12 @@ npm install @object-ui/react @object-ui/components
```tsx
import React from 'react'
import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react'
+import type { DeclaredNode } from '@object-ui/types'
// Importing the package registers every default renderer as a side effect —
// there is no separate registration call.
import '@object-ui/components'
-const schema = {
+const schema: DeclaredNode = {
type: "page",
title: "Dashboard",
children: {
@@ -278,10 +279,10 @@ npm install @object-ui/data-objectstack
```tsx
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
-import type { BaseSchema } from '@object-ui/types';
+import type { DeclaredNode } from '@object-ui/types';
// Your page schema — "Render a schema" above writes one out in full.
-declare const schema: BaseSchema;
+declare const schema: DeclaredNode;
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com',
diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md
index be7577d2c0..062962a7f3 100644
--- a/content/docs/api/schema-reference.md
+++ b/content/docs/api/schema-reference.md
@@ -17,14 +17,17 @@ This reference documents every ObjectUI schema type with annotated JSON examples
## Base Schema
-**"A component node" is named `BaseSchema`.** That is the object half a renderer
-receives: it carries the required `type` — the registry key that selects the renderer —
-plus the shared keys tabled below, and every schema type in this reference extends it.
-Use `BaseSchema` for any position that holds a node object: a slot's declared type, a
-prop, a type annotation in an example.
+**"A component node" is named `DeclaredNode`.** That is the object half a renderer
+receives: a node of a declared type, keyed by the required `type` — the registry key that
+selects the renderer. Every schema type in this reference extends `BaseSchema`, which
+carries `type` and the shared keys tabled below. Use `DeclaredNode` for any position that
+holds a node object: a slot's declared type, a prop, a type annotation in an example. A
+value typed `BaseSchema` names no declared type (its `type` is any string), so it does not
+fit a node slot or `SchemaRenderer`'s `schema` prop.
Reach for `SchemaNode` only where the wider union is genuinely correct. `SchemaNode` is
-`BaseSchema` **plus** the primitive members that render as text, so it is the right word
+`DeclaredNode` (a node of a declared type) **plus** the primitive members that render as
+text, so it is the right word
for a slot that also accepts a bare string (`children`) and the wrong word for a
position that must be an object: a renderer that narrows a slot with
`typeof node === 'object'` before reading its keys drops those primitive members on the
@@ -33,15 +36,27 @@ objectui#7082 had to correct nine times.
### SchemaNode
-The foundational building block of ObjectUI. Every component in the system is described by a `SchemaNode`. It can be a full schema object, or a primitive value rendered as text.
+The foundational building block of ObjectUI. Every component in the system is described by a `SchemaNode`. It can be a node of a declared type, or a primitive value rendered as text.
```typescript
-import type { BaseSchema } from '@object-ui/types';
+import type { DeclaredNode } from '@object-ui/types';
// The definition `@object-ui/types` declares
-type SchemaNode = BaseSchema | string | number | boolean | null | undefined;
+type SchemaNode = DeclaredNode | string | number | boolean | null | undefined;
```
+`DeclaredNode` is the discriminated union, keyed by `type`, of every node type
+`@object-ui/types` declares (the component schemas and the spec-declared
+authoring nodes) and every type your application declares in
+`CustomNodeRegistry`. Every node slot (`children`, `trigger`, `content`, a
+view's `schema`) and `SchemaRenderer`'s `schema` prop take it, so an inline
+child is checked against its own type's keys, and a `type` nothing declares is
+refused. A type you register with `ComponentRegistry` joins it through
+`CustomNodeRegistry`, an interface your application augments with
+`declare module '@object-ui/types'`, one entry per registered type, keyed by
+the type's name. The SchemaRenderer page's "Component Registry" section walks
+through a registration and its entry.
+
### BaseSchema
All schema types extend `BaseSchema`. These shared properties are available on every component.
diff --git a/content/docs/blocks/index.mdx b/content/docs/blocks/index.mdx
index de0a85cc1e..ad56b7c653 100644
--- a/content/docs/blocks/index.mdx
+++ b/content/docs/blocks/index.mdx
@@ -66,9 +66,11 @@ All blocks are defined as JSON schemas. Here's how simple it is:
```tsx
import { SchemaRenderer } from '@object-ui/react';
+import type { DeclaredNode } from '@object-ui/types';
-// Copy this JSON from any block
-const loginBlockSchema = {
+// Copy this JSON from any block. `DeclaredNode` is a node of a declared type,
+// so each key is checked against the type its `type` names.
+const loginBlockSchema: DeclaredNode = {
type: "card",
className: "max-w-md mx-auto",
children: [
diff --git a/content/docs/core/schema-renderer.mdx b/content/docs/core/schema-renderer.mdx
index ab2aeacb76..8d3d78ea45 100644
--- a/content/docs/core/schema-renderer.mdx
+++ b/content/docs/core/schema-renderer.mdx
@@ -57,6 +57,7 @@ The SchemaRenderer uses the Component Registry to resolve component types:
```tsx
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '@object-ui/react';
+import type { BaseSchema } from '@object-ui/types';
function MyWidgetComponent() {
return
My widget
;
@@ -65,6 +66,20 @@ function MyWidgetComponent() {
// Register a custom component
ComponentRegistry.register('my-widget', MyWidgetComponent);
+// Declare its node type. A node slot and the `schema` prop take the node types
+// `@object-ui/types` declares, plus each type an application adds to
+// `CustomNodeRegistry`, so the node below is checked against `MyWidgetSchema`.
+interface MyWidgetSchema extends BaseSchema {
+ type: 'my-widget';
+ customProp?: string;
+}
+
+declare module '@object-ui/types' {
+ interface CustomNodeRegistry {
+ 'my-widget': MyWidgetSchema;
+ }
+}
+
// Now you can use it in schemas
(
+export const Page = ({ schema }: { schema: DeclaredNode }) => (
diff --git a/packages/components/README.md b/packages/components/README.md
index ff94a172e7..8d30c787cf 100644
--- a/packages/components/README.md
+++ b/packages/components/README.md
@@ -86,10 +86,11 @@ tree-shake that import away.
```tsx
import { SchemaRenderer } from '@object-ui/react'
import { initializeComponents } from '@object-ui/components'
+import type { DeclaredNode } from '@object-ui/types'
initializeComponents()
-const schema = {
+const schema: DeclaredNode = {
type: 'card',
title: 'Welcome',
children: {
diff --git a/packages/components/src/__tests__/alias-precedence-cross-channel.test.tsx b/packages/components/src/__tests__/alias-precedence-cross-channel.test.tsx
index 13294852af..6a35c361e1 100644
--- a/packages/components/src/__tests__/alias-precedence-cross-channel.test.tsx
+++ b/packages/components/src/__tests__/alias-precedence-cross-channel.test.tsx
@@ -42,6 +42,7 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { SchemaRenderer } from '@object-ui/react';
+import { undeclaredNode } from '@object-ui/test-support';
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`. See
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
@@ -55,12 +56,15 @@ describe('alias precedence across the two renderer families (objectui#5123)', ()
// opposite of what was ruled.
const { container } = render(
);
@@ -123,11 +127,11 @@ describe('alias precedence across the two renderer families (objectui#5123)', ()
// pending ②, and is NOT decided here).
const text = render(
);
expect(text.container.querySelector('.xchan-legacy')?.textContent).toBe('ONLY_PROPS');
diff --git a/packages/components/src/__tests__/html-page-lazy-blocks.test.tsx b/packages/components/src/__tests__/html-page-lazy-blocks.test.tsx
index 219e7205f9..6b705e37d4 100644
--- a/packages/components/src/__tests__/html-page-lazy-blocks.test.tsx
+++ b/packages/components/src/__tests__/html-page-lazy-blocks.test.tsx
@@ -50,7 +50,7 @@ function lazyKanban() {
function renderHtmlPage(source: string) {
return render(
-
+
,
);
}
diff --git a/packages/components/src/__tests__/page-body-single-node-8310.test.tsx b/packages/components/src/__tests__/page-body-single-node-8310.test.tsx
index 79bc9daba8..21b5294d76 100644
--- a/packages/components/src/__tests__/page-body-single-node-8310.test.tsx
+++ b/packages/components/src/__tests__/page-body-single-node-8310.test.tsx
@@ -155,14 +155,15 @@ describe('`grid` reads `children` and nothing else — the defect shape (objectu
/* -------------------------------------------------------------------------- */
/**
- * The `const schema = {` object literal inside the README's "Basic Usage"
+ * The `const schema: DeclaredNode = {` object literal inside the README's "Basic Usage"
* fence, returned as source text. Scanned with brace-depth tracking rather than
* a regex so a nested array cannot end the span early.
*/
function basicUsageSchemaLiteral(): string {
const heading = README.indexOf('#### Basic Usage');
expect(heading).toBeGreaterThan(-1);
- const start = README.indexOf('const schema = {', heading);
+ // The example is typed as the node the `schema` prop takes since objectui#11466.
+ const start = README.indexOf('const schema: DeclaredNode = {', heading);
expect(start).toBeGreaterThan(-1);
let depth = 0;
diff --git a/packages/components/src/__tests__/page-variables.test.tsx b/packages/components/src/__tests__/page-variables.test.tsx
index 3baa90e3a3..239d307bb3 100644
--- a/packages/components/src/__tests__/page-variables.test.tsx
+++ b/packages/components/src/__tests__/page-variables.test.tsx
@@ -24,6 +24,7 @@ import {
usePageVariableBinding,
AdapterCtx,
} from '@object-ui/react';
+import { undeclaredNode } from '@object-ui/test-support';
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`, which is why this carried a raised
// timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
@@ -127,12 +128,15 @@ describe('SchemaRenderer page-variable visibility', () => {
,
);
@@ -147,12 +151,12 @@ describe('SchemaRenderer page-variable visibility', () => {
const { container } = render(
,
);
diff --git a/packages/components/src/__tests__/react-page-adapter.test.tsx b/packages/components/src/__tests__/react-page-adapter.test.tsx
index 12829088df..d24d8bf9b1 100644
--- a/packages/components/src/__tests__/react-page-adapter.test.tsx
+++ b/packages/components/src/__tests__/react-page-adapter.test.tsx
@@ -31,6 +31,7 @@ import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer, AdapterCtx, SchemaRendererContext } from '@object-ui/react';
+import type { PageDocumentNode } from '@object-ui/types';
import '../renderers';
/** The adapter each render of the stand-in block resolved from its context. */
@@ -51,7 +52,7 @@ function Page() {
);
}`;
-const SCHEMA = { type: 'home', kind: 'react', name: 'adapter_page', source: SOURCE };
+const SCHEMA: PageDocumentNode = { type: 'home', kind: 'react', name: 'adapter_page', label: 'Adapter page', source: SOURCE };
// The barrel import moved to module scope (see the `import '../renderers'`
// above): inside the hook its cold transform was billed to `hookTimeout`, which
diff --git a/packages/components/src/__tests__/react-page-invalidation.test.tsx b/packages/components/src/__tests__/react-page-invalidation.test.tsx
index 0d889b0076..d9503df295 100644
--- a/packages/components/src/__tests__/react-page-invalidation.test.tsx
+++ b/packages/components/src/__tests__/react-page-invalidation.test.tsx
@@ -36,6 +36,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, fireEvent, waitFor, act } from '@testing-library/react';
import React from 'react';
import { SchemaRenderer, AdapterCtx, notifyDataChanged, useDataInvalidation } from '@object-ui/react';
+import type { PageDocumentNode } from '@object-ui/types';
// `ReactKindPage` loads the page runtime with `import('@object-ui/react-runtime')`.
// Importing the same specifier here bills its cold transform to this module's
// import phase instead of to a `findBy` window (AGENTS.md, flaky-test
@@ -67,7 +68,7 @@ function Page() {
}`;
/** Module-scope so the schema identity is stable across host re-renders. */
-const SCHEMA = { type: 'home', kind: 'react', name: 'invalidation_page', source: SOURCE };
+const SCHEMA: PageDocumentNode = { type: 'home', kind: 'react', name: 'invalidation_page', label: 'Invalidation page', source: SOURCE };
let find: ReturnType;
let adapter: { find: typeof find };
diff --git a/packages/components/src/__tests__/react-page-scope.test.tsx b/packages/components/src/__tests__/react-page-scope.test.tsx
index 8695fa7b8c..fe469be955 100644
--- a/packages/components/src/__tests__/react-page-scope.test.tsx
+++ b/packages/components/src/__tests__/react-page-scope.test.tsx
@@ -48,7 +48,7 @@ const adapter = { find: async () => [], getObjectSchema: async () => ({ name: 's
function renderReactPage(source: string, hostAdapter: typeof adapter | null = adapter) {
return render(
-
+
,
);
}
diff --git a/packages/components/src/renderers/action/__tests__/action-button-icon-inputs-11168.test.tsx b/packages/components/src/renderers/action/__tests__/action-button-icon-inputs-11168.test.tsx
index ff79fff219..ccfde3856d 100644
--- a/packages/components/src/renderers/action/__tests__/action-button-icon-inputs-11168.test.tsx
+++ b/packages/components/src/renderers/action/__tests__/action-button-icon-inputs-11168.test.tsx
@@ -50,7 +50,12 @@ import '@testing-library/jest-dom';
import React, { useEffect } from 'react';
import { ComponentRegistry, createServerActionHandler } from '@object-ui/core';
import type { ActionContext, ActionDef, ActionResult } from '@object-ui/core';
-import type { BaseSchema, DataSource } from '@object-ui/types';
+import type { DataSource } from '@object-ui/types';
+// These nodes are written the way the runtime reads them, flat on the node,
+// which the closed `action:*` node types refuse: measured on objectui#11466,
+// typing the fixtures as `DeclaredNode` refuses them line by line. So each
+// crosses through the one test helper for undeclared input.
+import { undeclaredNode } from '@object-ui/test-support';
import {
ActionProvider,
PredicateScopeProvider,
@@ -156,7 +161,7 @@ function mount(schema: Record, options: MountOptions = {}) {
>
-
+
diff --git a/packages/components/src/renderers/action/__tests__/action-container-member-params-10290.test.tsx b/packages/components/src/renderers/action/__tests__/action-container-member-params-10290.test.tsx
index 57c3df4659..3864f5222a 100644
--- a/packages/components/src/renderers/action/__tests__/action-container-member-params-10290.test.tsx
+++ b/packages/components/src/renderers/action/__tests__/action-container-member-params-10290.test.tsx
@@ -32,7 +32,11 @@ import { render, screen, fireEvent, waitFor, within } from '@testing-library/rea
import '@testing-library/jest-dom';
import React from 'react';
import type { ActionContext, ActionDef, ActionResult } from '@object-ui/core';
-import type { BaseSchema } from '@object-ui/types';
+// These nodes are written the way the runtime reads them, flat on the node,
+// which the closed `action:*` node types refuse: measured on objectui#11466,
+// typing the fixtures as `DeclaredNode` refuses them line by line. So each
+// crosses through the one test helper for undeclared input.
+import { undeclaredNode } from '@object-ui/test-support';
import { ActionProvider, RecordContextProvider, SchemaRenderer } from '@object-ui/react';
// Module-scope side-effect imports: `action:bar` resolves its members and its
// overflow menu through the ComponentRegistry at render time, and the light
@@ -82,7 +86,7 @@ function renderOnRecordPage(schema: Record) {
return render(
-
+
,
);
diff --git a/packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx b/packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx
index 586af12824..6a8ee589f5 100644
--- a/packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx
+++ b/packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx
@@ -43,7 +43,12 @@ import '@testing-library/jest-dom';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { ActionContext, ActionDef, ActionResult } from '@object-ui/core';
-import type { BaseSchema, DataSource } from '@object-ui/types';
+import type { DataSource } from '@object-ui/types';
+// These nodes are written the way the runtime reads them, flat on the node,
+// which the closed `action:*` node types refuse: measured on objectui#11466,
+// typing the fixtures as `DeclaredNode` refuses them line by line. So each
+// crosses through the one test helper for undeclared input.
+import { undeclaredNode } from '@object-ui/test-support';
import {
ActionProvider,
PredicateScopeProvider,
@@ -94,7 +99,7 @@ function mount(schema: Record) {
-
+
,
diff --git a/packages/components/src/renderers/action/__tests__/action-params-templates-7867.test.tsx b/packages/components/src/renderers/action/__tests__/action-params-templates-7867.test.tsx
index 98c471e8fa..f09b29990d 100644
--- a/packages/components/src/renderers/action/__tests__/action-params-templates-7867.test.tsx
+++ b/packages/components/src/renderers/action/__tests__/action-params-templates-7867.test.tsx
@@ -39,7 +39,8 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import type { ActionContext, ActionDef, ActionResult } from '@object-ui/core';
-import type { BaseSchema, PublicBlockNodeOf } from '@object-ui/types';
+import type { PublicBlockNodeOf } from '@object-ui/types';
+import { undeclaredNode } from '@object-ui/test-support';
import { ActionProvider, RecordContextProvider, SchemaRenderer } from '@object-ui/react';
// Module-scope side-effect import so `action:button` is registered before the
// first render - the light `dom` project does not load the components graph.
@@ -64,11 +65,12 @@ afterEach(() => {
* Render `schema` on a record page bound to {@link ROW}.
*
* Typed as the `action:button` node (objectui#11364, the arm's input: executor
- * keys in the `properties` bag, objectui#11183) beside `BaseSchema`, so a
- * literal below is checked against the spec's spelling once `BaseSchema`'s
- * index signature goes (objectui#8347).
+ * keys in the `properties` bag, objectui#11183), so a literal below is checked
+ * against the spec's spelling. CASE A's node-level `params` is the one spelling
+ * that node refuses on purpose, and it crosses through the one test helper for
+ * undeclared input (objectui#11466).
*/
-function renderOnRecordPage(schema: BaseSchema | PublicBlockNodeOf<'action:button'>) {
+function renderOnRecordPage(schema: PublicBlockNodeOf<'action:button'>) {
return render(
@@ -100,11 +102,11 @@ describe('objectui#7867 - `params` values are templates, evaluated where `proper
try {
// The node-level `params` object is the probe: it is the spelling this
// case proves is NOT read, so it stays on the node.
- renderOnRecordPage({
+ renderOnRecordPage(undeclaredNode({
type: 'action:button',
properties: { label: 'Edit', actionType: 'navigate_edit' },
params: { objectName: 'account', recordId: '${record.id}' },
- });
+ }));
const params = await paramsReceived('Edit');
expect(params).toBeUndefined();
} finally {
diff --git a/packages/components/src/renderers/basic/__tests__/degenerate-config-bag.test.tsx b/packages/components/src/renderers/basic/__tests__/degenerate-config-bag.test.tsx
index 5f374ae6ae..e9fc887ff5 100644
--- a/packages/components/src/renderers/basic/__tests__/degenerate-config-bag.test.tsx
+++ b/packages/components/src/renderers/basic/__tests__/degenerate-config-bag.test.tsx
@@ -68,6 +68,9 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '@object-ui/react';
+// A degenerate `properties` value on a test-registered type is the point: it
+// crosses through the one test helper for undeclared input (objectui#11466).
+import { undeclaredNode } from '@object-ui/test-support';
import { readProps } from '../readProps';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
@@ -149,7 +152,7 @@ describe('objectui#6783 — the three channels are in series, measured end to en
it('the authored degenerate value still REACHES the renderer through SchemaRenderer', () => {
registerProbe();
const { getByTestId } = render(
-
+
);
// Neither objectui#6752's evaluation guard nor objectui#6760's hoist
@@ -163,7 +166,7 @@ describe('objectui#6783 — the three channels are in series, measured end to en
it('and the bag that renderer computes from it now carries nothing', () => {
registerProbe();
const { getByTestId } = render(
-
+
);
// BASE_READING through the same path: "0,1,2,3,4,5,6,7,8".
diff --git a/packages/components/src/renderers/layout/page.tsx b/packages/components/src/renderers/layout/page.tsx
index f5185737db..2f2121d350 100644
--- a/packages/components/src/renderers/layout/page.tsx
+++ b/packages/components/src/renderers/layout/page.tsx
@@ -13,7 +13,7 @@
*/
import React, { useMemo } from 'react';
-import type { BaseSchema, PageNodeSchema, PageNodeRegion, SchemaNode } from '@object-ui/types';
+import type { DeclaredNode, PageNodeSchema, PageNodeRegion, SchemaNode } from '@object-ui/types';
import {
SchemaRenderer,
toRenderableSchema,
@@ -619,7 +619,22 @@ export const PageRenderer: React.FC<{
);
}
- return tree ? : null;
+ if (!tree) return null;
+ /**
+ * ⭐ THE ONE BOUNDARY CAST (objectui#11466). `tree` is author source
+ * parsed at runtime (`@object-ui/sdui-parser`'s `SchemaElement`: a
+ * `type: string` plus unknown props), so the compiler cannot know which
+ * declared node type each element is. Its validator is what judges it:
+ * `compile` runs `validateTree` against the registry manifest (an
+ * unknown component, an unknown or missing prop, a wrong coarse type, an
+ * illegal enum value), and this line is reached only when that reported
+ * no error (the `errors` early return above). So the tree crosses into
+ * `DeclaredNode` here, once, on that validator's word, which is shallower
+ * than the declarations. ⛔ No second cast of this kind: a node built in
+ * code names its declared type instead.
+ */
+ const validatedTree = tree as unknown as DeclaredNode;
+ return ;
}
const TemplateLayout = resolveTemplate(schema);
if (TemplateLayout) {
diff --git a/packages/core/src/builder/schema-builder.ts b/packages/core/src/builder/schema-builder.ts
index 5aa197a44f..e8a6d72312 100644
--- a/packages/core/src/builder/schema-builder.ts
+++ b/packages/core/src/builder/schema-builder.ts
@@ -18,6 +18,7 @@
import type {
BaseSchema,
+ DeclaredNode,
FormSchema,
FormField,
ButtonSchema,
@@ -298,9 +299,10 @@ export class CardBuilder extends SchemaBuilder {
}
/**
- * Set card content
+ * Set card content: a node of a declared type, or a list of them
+ * (objectui#11466). Each node is checked against its own type's keys.
*/
- content(content: BaseSchema | BaseSchema[]): this {
+ content(content: DeclaredNode | DeclaredNode[]): this {
this.schema.content = content;
return this;
}
@@ -351,18 +353,19 @@ export class GridBuilder extends SchemaBuilder {
}
/**
- * Add a child
+ * Add a child: a node of a declared type (`DeclaredNode`, objectui#11466),
+ * checked against its own type's keys like a child written inline.
*/
- child(child: BaseSchema): this {
+ child(child: DeclaredNode): this {
const children = Array.isArray(this.schema.children) ? this.schema.children : [];
this.schema.children = [...children, child];
return this;
}
/**
- * Set all children
+ * Set all children: nodes of declared types (objectui#11466).
*/
- children(children: BaseSchema[]): this {
+ children(children: DeclaredNode[]): this {
this.schema.children = children;
return this;
}
@@ -421,18 +424,19 @@ export class FlexBuilder extends SchemaBuilder {
describe('a JSON `app-shell` node is refused by name (objectui#4841)', () => {
it('renders the OBJUI-001 unknown-component panel, not an empty shell', () => {
- const { container } = render();
+ const { container } = render();
const panel = container.querySelector('[role="alert"]');
expect(
@@ -169,7 +174,7 @@ describe('a JSON `app-shell` node is refused by name (objectui#4841)', () => {
// just as happily on a renderer that panels EVERY node, and this file would
// be green for a reason that has nothing to do with `app-shell`.
const { container } = render(
- ,
+ ,
);
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.textContent).not.toContain('Unknown component type');
diff --git a/packages/layout/src/__tests__/navigation-responsive-grid-retired-11441.test.tsx b/packages/layout/src/__tests__/navigation-responsive-grid-retired-11441.test.tsx
index 26740be5a0..07eab5f8cd 100644
--- a/packages/layout/src/__tests__/navigation-responsive-grid-retired-11441.test.tsx
+++ b/packages/layout/src/__tests__/navigation-responsive-grid-retired-11441.test.tsx
@@ -46,6 +46,11 @@ import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '@object-ui/react';
+// The two retired keys are refused on purpose, and the control is
+// `@object-ui/layout`'s own namespaced registration; no `@object-ui/types`
+// declaration names any of them, so each crosses through the one test helper for
+// undeclared input (objectui#11466).
+import { undeclaredNode } from '@object-ui/test-support';
// The shared registration reader (objectui#4894). Plain JS, and this package's
// test program sets `allowJs: false`, so the import is untyped here.
@@ -91,7 +96,7 @@ describe('the `navigation-renderer` and `responsive-grid` registrations are reti
describe('a node of either retired key renders the "Unknown component type" panel (objectui#11441)', () => {
it('`navigation-renderer` is refused by name', () => {
- const { container } = render();
+ const { container } = render();
const panel = container.querySelector('[role="alert"]');
expect(panel, 'a `navigation-renderer` node rendered something other than the unknown-type panel').not.toBeNull();
expect(panel?.textContent).toContain('Unknown component type: navigation-renderer');
@@ -100,7 +105,7 @@ describe('a node of either retired key renders the "Unknown component type" pane
it('`responsive-grid` is refused by name', () => {
const { container } = render(
- ,
+ ,
);
const panel = container.querySelector('[role="alert"]');
expect(panel, 'a `responsive-grid` node rendered something other than the unknown-type panel').not.toBeNull();
@@ -111,7 +116,7 @@ describe('a node of either retired key renders the "Unknown component type" pane
it('and the probe can tell a registered layout key apart — `layout:page:card` renders', () => {
// The control: without it, the two rows above would pass on a renderer that
// panels EVERY node.
- const { container } = render();
+ const { container } = render();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.textContent).not.toContain('Unknown component type');
});
diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md
index d326805667..4010c5c9ff 100644
--- a/packages/plugin-dashboard/README.md
+++ b/packages/plugin-dashboard/README.md
@@ -536,7 +536,12 @@ where it resolves.
objectui's legacy `component` envelope (`{ id, component, layout }`) is not the
spec's widget, and the default is not applied to it: an envelope with no `type`
-draws its `component` under its card heading, as it always did.
+draws its `component` under its card heading, as it always did. One node is
+retired there: an `object-metric` (under `object-metric` or
+`plugin-dashboard:object-metric`) draws the same retired-format prompt as a
+dataset-less metric widget and sends no query (objectui#11466). Bind a metric
+widget to a dataset instead. Every other envelope node draws as written, and the
+filter bar still scopes an envelope's `object-chart` and `object-data-table`.
A `type` that names no widget family and no component type (a typo, or a family
the spec no longer has) is refused by both validator faces at `type`. A stored
diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx
index 1f972cb8b9..8f217feb6a 100644
--- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx
+++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx
@@ -3,9 +3,9 @@ import { ResponsiveGridLayout, useContainerWidth, type LayoutItem as RGLLayout,
import 'react-grid-layout/css/styles.css';
import { cn, Card, CardHeader, CardTitle, CardContent, Button } from '@object-ui/components';
import { Edit, GripVertical, Save, X, RefreshCw } from 'lucide-react';
-import { SchemaRenderer, toRenderableSchema, useHasDndProvider, useDnd } from '@object-ui/react';
+import { SchemaRenderer, toRenderableSchema, useHasDndProvider, useDnd, type SchemaRendererProps } from '@object-ui/react';
import { useObjectTranslation, useObjectLabel, useSafeTranslate, pickLocalized } from '@object-ui/i18n';
-import type { BaseSchema, DashboardComponentSchema, ObjectChartSchema } from '@object-ui/types';
+import type { DashboardComponentSchema, ObjectChartSchema, PivotTableSchema } from '@object-ui/types';
import { completeWidgetLayout, defaultWidgetPlacement } from '@object-ui/types';
import { chartCategoryKey, chartConfigPresentation, chartMeasureKey } from '@object-ui/core';
import { isObjectProvider, deriveStaticTableColumns, composeSeriesLabel } from './utils';
@@ -19,7 +19,7 @@ import {
unsupportedWidgetSchema,
type DashboardWidgetSlotEntry,
} from './widgetDispatch';
-import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget } from './legacyRetiredWidget';
+import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget, isRetiredEnvelopeNode } from './legacyRetiredWidget';
import { DatasetWidget } from './DatasetWidget';
import { useWidgetSubCaption } from './widgetSubCaption';
import { useDashboardAutoRefresh } from './useDashboardAutoRefresh';
@@ -254,11 +254,18 @@ export const DashboardGridLayout: React.FC = ({
setLayouts(buildDefaultLayouts(schema));
}, [schema]);
- const getComponentSchema = React.useCallback((widget: DashboardWidgetSlotEntry) => {
+ // Every branch returns a node `SchemaRenderer` takes, so the return type is
+ // that prop's (objectui#11466): each node is checked against its declared
+ // type where it is built, and the render sites below take it with no cast.
+ const getComponentSchema = React.useCallback((widget: DashboardWidgetSlotEntry): SchemaRendererProps['schema'] => {
// Same boundary as `DashboardRenderer`: the author's node keeps its
// spelling except a `metric` / `metric-card` node key, which moves onto its
// namespaced registration (`toDashboardNodeType`, objectui#10859 batch 8).
const authoredComponent = entryComponent(widget);
+ // An `object-metric` node in the envelope is the last inline metric form,
+ // retired with the rest (objectui#11466, ruling A extending ruling C on
+ // objectui#11525): it draws the rebind prompt, as `DashboardRenderer` does.
+ if (isRetiredEnvelopeNode(authoredComponent)) return LEGACY_RETIRED_WIDGET_SCHEMA;
// `toRenderableSchema` (objectui#4622) bridges the envelope's `SchemaNode`
// to what `SchemaRenderer` takes: a number or boolean draws the same text
// (or nothing, when falsy) it drew when handed to the renderer bare.
@@ -370,7 +377,11 @@ export const DashboardGridLayout: React.FC = ({
}
// Single-value families render as a metric card, not a chart (#2943).
- if (dispatch.family === 'metric') {
+ // `classifyWidgetType` answers `metric` only for a named type, so the
+ // `widgetType` test narrows for the compiler and changes no verdict: the
+ // card's label falls back to that type, and the node's declared type
+ // requires a label (objectui#11466).
+ if (dispatch.family === 'metric' && widgetType !== undefined) {
const widgetData = (widget as any).data || options.data;
// provider: 'object' — RETIRED (objectui#11525, maintainer ruling C), with
// the same placeholder object this surface's pivot arm and
@@ -386,6 +397,8 @@ export const DashboardGridLayout: React.FC = ({
return {
// The namespaced node key, as `DashboardRenderer` emits it: the
// registration passes `skipFallback: true` (objectui#10859 batch 8).
+ // Its declared type is `DashboardMetricNodeSchema`, the
+ // `CustomNodeRegistry` entry `./widgetDispatch` adds (objectui#11466).
type: DASHBOARD_NODE_TYPES.metric,
...options,
label,
@@ -462,11 +475,26 @@ export const DashboardGridLayout: React.FC = ({
// is unchanged.
if (isObjectProvider(widgetData)) return LEGACY_RETIRED_WIDGET_SCHEMA;
+ // The declared node type, `PivotTableSchema` (objectui#11466). It used to
+ // spread `options` whole, which named no declared type: the required
+ // `rowField` / `columnField` / `valueField` were not stated, so the node
+ // reached `SchemaRenderer` only through a cast. The node now states each
+ // key `PivotTable` draws (its `PivotTableSchema` keys, and `className`),
+ // read from `options`; no other option key rides along.
return {
type: 'pivot',
- ...options,
+ title: options.title,
+ rowField: options.rowField,
+ columnField: options.columnField,
+ valueField: options.valueField,
+ aggregation: options.aggregation,
+ showRowTotals: options.showRowTotals,
+ showColumnTotals: options.showColumnTotals,
+ format: options.format,
+ columnColors: options.columnColors,
+ className: options.className,
data: Array.isArray(widgetData) ? widgetData : widgetData?.items || [],
- };
+ } satisfies PivotTableSchema;
}
if (dispatch.family === 'unsupported') {
@@ -576,14 +604,12 @@ export const DashboardGridLayout: React.FC = ({
widget's spec row, and the card's heading on the component arm. */}
{schema.widgets?.map((widget: DashboardWidgetSlotEntry, index: number) => {
const widgetId = widget.id || `widget-${index}`;
- // `getComponentSchema` builds a node for `SchemaRenderer` in every
- // branch, but not every branch's node is a declared type yet: the
- // `plugin-dashboard:metric` key is typed `string`
- // (`DASHBOARD_NODE_TYPES`, objectui#11466), and the static `pivot`
- // does not state `PivotTableSchema`'s required axes. So the
- // narrowing is named here once (objectui#4548) instead of being
- // spread across the two render sites below.
- const componentSchema = getComponentSchema(widget) as BaseSchema | string | null | undefined;
+ // `getComponentSchema` returns `SchemaRenderer`'s own prop type,
+ // and every branch builds a declared node (objectui#11466): the
+ // metric card as `plugin-dashboard:metric`, which
+ // `CustomNodeRegistry` declares, and the static pivot as
+ // `PivotTableSchema`. So both render sites below take it as it is.
+ const componentSchema = getComponentSchema(widget);
// ADR-0021 — a widget bound to a semantic-layer dataset renders
// through the governed queryDataset path (DatasetWidget) instead of
// the inline object-aggregate schema. Decided per widget AT THE
diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx
index 00fdb1e22a..7b59f1307c 100644
--- a/packages/plugin-dashboard/src/DashboardRenderer.tsx
+++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx
@@ -7,7 +7,7 @@
*/
import type { BaseSchema, DashboardComponentSchema, DataSource, ObjectChartSchema, ObjectDataTableSchema } from '@object-ui/types';
-import { SchemaRenderer, toRenderableSchema, useActionEngine, useObjectLabel, PageVariablesProvider, usePageVariables, useResolvedDataSource } from '@object-ui/react';
+import { SchemaRenderer, toRenderableSchema, useActionEngine, useObjectLabel, PageVariablesProvider, usePageVariables, useResolvedDataSource, type SchemaRendererProps } from '@object-ui/react';
import { useObjectTranslation, useSafeTranslate, pickLocalized, useDisplayLocale } from '@object-ui/i18n';
import type { ActionDef, ActionResult, ActionContext, ModalHandler, SduiDomPassThroughKey } from '@object-ui/core';
import {
@@ -42,12 +42,11 @@ import {
import { CSS } from '@dnd-kit/utilities';
import { isObjectProvider, deriveStaticTableColumns, composeSeriesLabel } from './utils';
import { classifyWidgetType, METRIC_LIKE_TYPES, DASHBOARD_NODE_TYPES, toDashboardNodeType, resolveWidgetType, isSlotComponentEntry, unsupportedWidgetSchema, entryComponent, type DashboardWidgetSlotEntry } from './widgetDispatch';
-import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget } from './legacyRetiredWidget';
+import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget, isRetiredEnvelopeNode } from './legacyRetiredWidget';
import { DatasetWidget } from './DatasetWidget';
import { useWidgetSubCaption } from './widgetSubCaption';
import { useDashboardAutoRefresh } from './useDashboardAutoRefresh';
import { DashboardFilterBar } from './DashboardFilterBar';
-import type { ObjectMetricWidgetProps } from './ObjectMetricWidget';
/**
* One `header.actions[]` entry, as the node's declaration types it: the spec's
@@ -148,10 +147,9 @@ function defaultChartDrill(chartType: string): { enabled: true } | undefined {
* (`chart`, `data-table` with inline `data`) have no query to scope and are
* intentionally not filtered.
*/
-type FilterableComponentType = 'object-chart' | 'object-metric' | 'object-data-table';
+type FilterableComponentType = 'object-chart' | 'object-data-table';
const FILTERABLE_COMPONENT_TYPES: ReadonlySet = new Set([
'object-chart',
- 'object-metric',
'object-data-table',
]);
@@ -162,30 +160,26 @@ const FILTERABLE_COMPONENT_TYPES: ReadonlySet = new Set {
+ // Every branch returns a node `SchemaRenderer` takes, so the return type
+ // is that prop's (objectui#11466): each node is checked against its
+ // declared type where it is built, and the render sites take it with no
+ // cast.
+ const getComponentSchema = (): SchemaRendererProps['schema'] => {
// The author-supplied node keeps its spelling; only a `metric` /
// `metric-card` node key moves onto its namespaced registration
// (`toDashboardNodeType`, objectui#10859 batch 8).
const authoredComponent = entryComponent(widget);
+ // An `object-metric` node in the envelope is the last inline metric
+ // form, retired with the rest (objectui#11466, ruling A extending
+ // ruling C on objectui#11525): it draws the rebind prompt, the object
+ // the metric and pivot arms below return, and sends no query.
+ if (isRetiredEnvelopeNode(authoredComponent)) return LEGACY_RETIRED_WIDGET_SCHEMA;
// `toRenderableSchema` (objectui#4622) bridges the envelope's `SchemaNode`
// to what `SchemaRenderer` takes: a number or boolean draws the same text
// (or nothing, when falsy) it drew when handed to the renderer bare.
@@ -849,7 +852,12 @@ const DashboardRendererInner = forwardRef 0
? buildWidgetScopedFilter(widget, filterDefs, filterValues)
: undefined;
- const componentSchema = (() => {
- // `as BaseSchema`, not `as Record< string, any >` (objectui#4548):
- // the old cast dropped the `type` every branch of
- // `getComponentSchema` actually sets, so what reached
- // `SchemaRenderer` was a bag with no component descriptor as far as
- // the type system knew. The `filter` read below does not lean on
- // `BaseSchema`'s index signature for arbitrary key access: it
- // narrows to the node schema that declares `filter` first
- // (`isFilterableComponentSchema`, objectui#11348).
- const cs = getComponentSchema() as BaseSchema;
- if (scopedFilter && cs && isFilterableComponentSchema(cs)) {
+ const componentSchema = ((): SchemaRendererProps['schema'] => {
+ // No cast (objectui#11466): `getComponentSchema` returns
+ // `SchemaRenderer`'s own prop type, and every branch builds a
+ // declared node. It was `as BaseSchema` (objectui#4548, which
+ // replaced an `as Record< string, any >` that dropped the `type`).
+ // The `filter` read below does not lean on an index signature for
+ // arbitrary key access: it narrows to the node schema that declares
+ // `filter` first (`isFilterableComponentSchema`, objectui#11348).
+ const cs = getComponentSchema();
+ if (scopedFilter && cs && typeof cs === 'object' && isFilterableComponentSchema(cs)) {
return { ...cs, filter: mergeFilters(cs.filter, scopedFilter) };
}
return cs;
diff --git a/packages/plugin-dashboard/src/__tests__/inlineObjectMetricRetired-11525.test.tsx b/packages/plugin-dashboard/src/__tests__/inlineObjectMetricRetired-11525.test.tsx
index 50a40e47b4..0d8d64cd97 100644
--- a/packages/plugin-dashboard/src/__tests__/inlineObjectMetricRetired-11525.test.tsx
+++ b/packages/plugin-dashboard/src/__tests__/inlineObjectMetricRetired-11525.test.tsx
@@ -36,6 +36,19 @@
* the dataset-bound metric for "a metric still draws its number", the static
* metric and the inline chart and table arms for what this card did not
* touch, and the envelope case for the filter-broadcast member this card kept.
+ *
+ * ⚠️ Dated note, 2026-10-03 — the envelope case flipped — objectui#11466. This
+ * file pinned that an `object-metric` node in a widget's legacy `component`
+ * envelope still drew its number, scoped by the filter bar, because this card
+ * kept `object-metric` in the broadcast's filterable set for it. That flat
+ * `filter` write is refused by the declared `object-metric` node, and the
+ * maintainer's ruling A on objectui#11466 extended ruling C to this last
+ * inline metric form: on both surfaces the envelope node now draws
+ * `LEGACY_RETIRED_WIDGET_SCHEMA` and sends no query, and `object-metric` left
+ * the filterable set. The last two blocks below pin that, and keep the
+ * broadcast's control on the envelope's `object-chart` and
+ * `object-data-table`, which still receive the filter bar's value. The rest of
+ * this header is kept as the reading of objectui#11525.
*/
import * as React from 'react';
@@ -211,21 +224,41 @@ describe.each(SURFACES)('%s surface — controls (objectui#11525)', (surface) =>
});
});
-describe('renderer surface — the filter broadcast still scopes an authored object-metric (objectui#11525)', () => {
- it('a `component` envelope holding an object-metric node receives the filter bar value', async () => {
- // Why `object-metric` stays a member of the broadcast's filterable set: the
- // producer is gone, but an author's envelope node still reaches the merge.
+/** The dashboard filter bar, with a default value, so the broadcast applies on first render. */
+const REGION_FILTER = {
+ globalFilters: [{ name: 'region', field: 'region', type: 'select', options: [{ value: 'EMEA', label: 'EMEA' }], defaultValue: 'EMEA' }],
+};
+
+/** The envelope node objectui#11525 kept in the broadcast, written as its pin wrote it. */
+const ENVELOPE_OBJECT_METRIC = { objectName: 'deal', aggregate: { field: 'amount', function: 'sum' } };
+
+describe.each(SURFACES)('%s surface — an object-metric in a component envelope is retired (objectui#11466, ruling A)', (surface) => {
+ it.each([
+ ['its bare key', 'object-metric'],
+ ["its registration's full name", 'plugin-dashboard:object-metric'],
+ ])('under %s it draws the placeholder object and sends no query', async (_label, type) => {
const adapter = makeAdapter();
- mount(
- 'renderer',
- [{ id: 'w1', title: 'Envelope', component: { type: 'object-metric', objectName: 'deal', aggregate: { field: 'amount', function: 'sum' } } }],
- adapter,
- { globalFilters: [{ name: 'region', field: 'region', type: 'select', options: [{ value: 'EMEA', label: 'EMEA' }], defaultValue: 'EMEA' }] },
- );
- await waitFor(() => expect(nodesOfType('object-metric').length).toBeGreaterThan(0));
- const node = nodesOfType('object-metric')[nodesOfType('object-metric').length - 1];
+ mount(surface, [{ id: 'w1', title: 'Envelope', component: { type, ...ENVELOPE_OBJECT_METRIC } }], adapter, REGION_FILTER);
+
+ await waitFor(() => expect(received).toContain(LEGACY_RETIRED_WIDGET_SCHEMA));
+ expect(screen.getByText(PLACEHOLDER)).toBeInTheDocument();
+ expect(nodesOfType(type)).toHaveLength(0);
+ expect(adapter.aggregate).not.toHaveBeenCalled();
+ expect(adapter.find).not.toHaveBeenCalled();
+ });
+});
+
+describe('renderer surface — the filter broadcast still scopes the envelope\'s other object-backed nodes (objectui#11466)', () => {
+ it.each([
+ ['object-chart', { type: 'object-chart', objectName: 'deal', chartType: 'bar', aggregate: { field: 'amount', function: 'sum', groupBy: 'name' }, xAxisKey: 'name', series: [{ dataKey: 'amount' }] }],
+ ['object-data-table', { type: 'object-data-table', objectName: 'deal' }],
+ ])('a `component` envelope holding an %s node receives the filter bar value', async (type, component) => {
+ const adapter = makeAdapter();
+ mount('renderer', [{ id: 'w1', title: 'Envelope', component }], adapter, REGION_FILTER);
+
+ await waitFor(() => expect(nodesOfType(type).length).toBeGreaterThan(0));
+ const node = nodesOfType(type)[nodesOfType(type).length - 1];
expect(node.filter).toEqual({ region: 'EMEA' });
- await waitFor(() => expect(adapter.aggregate).toHaveBeenCalled());
expect(received).not.toContain(LEGACY_RETIRED_WIDGET_SCHEMA);
});
});
diff --git a/packages/plugin-dashboard/src/__tests__/pivotNode.drillDownRetired-10932.test.tsx b/packages/plugin-dashboard/src/__tests__/pivotNode.drillDownRetired-10932.test.tsx
index 1ff7627617..c47e7779e8 100644
--- a/packages/plugin-dashboard/src/__tests__/pivotNode.drillDownRetired-10932.test.tsx
+++ b/packages/plugin-dashboard/src/__tests__/pivotNode.drillDownRetired-10932.test.tsx
@@ -26,6 +26,8 @@ import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { SchemaRenderer } from '@object-ui/react';
+import type { PivotTableSchema } from '@object-ui/types';
+import { undeclaredNode } from '@object-ui/test-support';
// Registers `pivot` → `PivotTable`, the registration every `pivot` path uses.
import '../index';
import { PivotTable } from '../PivotTable';
@@ -43,8 +45,13 @@ const PIVOT = {
],
};
-/** What an author's document carries: the retired key, reaching the renderer anyway. */
-const NODE = { ...PIVOT, drillDown: { enabled: true } };
+/**
+ * What an author's document carries: the retired key, reaching the renderer
+ * anyway. `PivotTableSchema` refuses `drillDown` by name, which is the point, so
+ * the node crosses through the one test helper for undeclared input
+ * (objectui#11466).
+ */
+const NODE = undeclaredNode({ ...PIVOT, drillDown: { enabled: true } });
describe('a `pivot` node authored with `drillDown` draws no drill affordance (objectui#10932)', () => {
it('renders the cross-tab through SchemaRenderer, with no interactive cell or header', () => {
diff --git a/packages/plugin-dashboard/src/index.tsx b/packages/plugin-dashboard/src/index.tsx
index ed88fea706..1456c8f220 100644
--- a/packages/plugin-dashboard/src/index.tsx
+++ b/packages/plugin-dashboard/src/index.tsx
@@ -42,6 +42,11 @@ export type {
WidgetDatasetDimension,
WidgetDatasetMeasure,
} from './dataset-catalog';
+// objectui#11466 — the `plugin-dashboard:metric` node type, which
+// `./widgetDispatch` declares in `@object-ui/types`' `CustomNodeRegistry`.
+// Exported from the entry so the published typings load that declaration for
+// every consumer of this package, not only for this package's own program.
+export type { DashboardMetricNodeSchema } from './widgetDispatch';
// objectui#9533 — the retirement table and the widget it renders. Exported for
// the same reason the sibling packages export their tombstones: the assertion
// that an authored `view:dashboard` is refused BY NAME, with the migration in
@@ -145,6 +150,11 @@ ComponentRegistry.register(
// spelling `objectui validate` refuses at `type` while the registry mounted it.
// The dashboard WIDGET type `metric` (the spec's `ChartTypeSchema` value) is a
// different vocabulary and is untouched.
+//
+// The NODE key is a declared node type (objectui#11466):
+// `DashboardMetricNodeSchema` in `./widgetDispatch`, entered in
+// `CustomNodeRegistry` under `plugin-dashboard:metric`, so both surfaces hand
+// `SchemaRenderer` a declared node with no cast.
ComponentRegistry.register(
'metric',
MetricWidget,
@@ -219,7 +229,9 @@ const OBJECT_METRIC_DATA_SOURCE: ElementDataSourceMapping = {
* The props keep their standing when there is no binding: `bound` IS the schema
* by reference in that case, so `bound?.x ?? props.x` resolves to what the
* spread already provided, and a host that renders this component with explicit
- * props and no schema at all (the dashboard grid path) is untouched.
+ * props and no schema at all is untouched. (This sentence named "the dashboard
+ * grid path" as such a host until objectui#11525 retired the dashboards'
+ * inline `object-metric` node; that path builds none now.)
*/
const ObjectMetricBlock: React.FC<{ schema?: any; [key: string]: any }> = elementDataSourceBlock(({ schema, ...props }) => (
= new Set([
+ 'object-metric',
+ 'plugin-dashboard:object-metric',
+]);
+
+/** Is `node`, read from a widget's `component` envelope, a retired node type? */
+export function isRetiredEnvelopeNode(node: unknown): boolean {
+ return (
+ typeof node === 'object' &&
+ node !== null &&
+ 'type' in node &&
+ typeof node.type === 'string' &&
+ RETIRED_ENVELOPE_NODE_TYPES.has(node.type)
+ );
+}
diff --git a/packages/plugin-dashboard/src/widgetDispatch.ts b/packages/plugin-dashboard/src/widgetDispatch.ts
index 7f60e1c686..42dfb3ba19 100644
--- a/packages/plugin-dashboard/src/widgetDispatch.ts
+++ b/packages/plugin-dashboard/src/widgetDispatch.ts
@@ -25,6 +25,7 @@
import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '@object-ui/types';
import type {
+ BaseSchema,
DashboardComponentSchema,
DashboardWidgetSchema,
DashboardWidgetSlotComponentSchema,
@@ -32,6 +33,7 @@ import type {
TextSchema,
} from '@object-ui/types';
import { DashboardWidgetSchema as SpecDashboardWidgetSchema } from '@objectstack/spec/ui';
+import type { MetricWidgetProps } from './MetricWidget';
/**
* One entry of a dashboard's `widgets[]`: the slot's element type, a widget or
@@ -255,11 +257,58 @@ export function classifyWidgetType(widgetType: string | undefined): WidgetDispat
* ⛔ Both surfaces (`DashboardRenderer`, `DashboardGridLayout`) emit through
* this table, so they cannot drift apart; a new `skipFallback` registration in
* this package that a widget can reach gets its row here in the same edit.
+ *
+ * The values are literal types (objectui#11466), so a node a surface builds
+ * under `DASHBOARD_NODE_TYPES.metric` is discriminated by its key and checked
+ * against {@link DashboardMetricNodeSchema}, the `CustomNodeRegistry` entry
+ * below. A lookup by an authored `type` string goes through the string-keyed
+ * view {@link toDashboardNodeType} reads.
*/
-export const DASHBOARD_NODE_TYPES: Readonly> = {
+export const DASHBOARD_NODE_TYPES = {
metric: 'plugin-dashboard:metric',
'metric-card': 'plugin-dashboard:metric-card',
-};
+} as const;
+
+/** {@link DASHBOARD_NODE_TYPES} read by an arbitrary `type` string. */
+const NODE_TYPE_BY_WIDGET_TYPE: Readonly> = DASHBOARD_NODE_TYPES;
+
+/**
+ * The `plugin-dashboard:metric` node: what both dashboard surfaces hand
+ * `SchemaRenderer` for a `metric` widget drawn inline, and what the `metric`
+ * registration (`skipFallback: true`) mounts as `MetricWidget`.
+ *
+ * Declared in `@object-ui/types`' `CustomNodeRegistry` (objectui#11466, as
+ * objectui#11479's Q1 A ruled), the registry an application augments for the
+ * node types it registers that `@object-ui/types` does not declare. So the
+ * producers name a declared node type, and `SchemaRenderer` takes the node
+ * with no cast. The key is this package's: `@object-ui/types` cannot name a
+ * plugin's namespace.
+ *
+ * Its keys are the ones `MetricWidget` reads off the node, typed by that
+ * component's props so the two cannot disagree. Host state (`loading`,
+ * `error`) and the `onClick` handler are props, ⛔ not node keys.
+ */
+export interface DashboardMetricNodeSchema extends BaseSchema {
+ type: typeof DASHBOARD_NODE_TYPES.metric;
+ label: MetricWidgetProps['label'];
+ value: MetricWidgetProps['value'];
+ description?: MetricWidgetProps['description'];
+ trend?: MetricWidgetProps['trend'];
+ /** A Lucide icon name. A React element is a host prop, never a node key. */
+ icon?: string;
+ colorVariant?: MetricWidgetProps['colorVariant'];
+ format?: MetricWidgetProps['format'];
+ currency?: MetricWidgetProps['currency'];
+ prefix?: MetricWidgetProps['prefix'];
+ suffix?: MetricWidgetProps['suffix'];
+ variant?: MetricWidgetProps['variant'];
+}
+
+declare module '@object-ui/types' {
+ interface CustomNodeRegistry {
+ 'plugin-dashboard:metric': DashboardMetricNodeSchema;
+ }
+}
/**
* `node` with its `type` moved onto the namespaced key {@link
@@ -274,6 +323,6 @@ export const DASHBOARD_NODE_TYPES: Readonly> = {
export function toDashboardNodeType(node: T): T {
if (!node || typeof node !== 'object') return node;
const type = (node as { type?: unknown }).type;
- const moved = typeof type === 'string' ? DASHBOARD_NODE_TYPES[type] : undefined;
+ const moved = typeof type === 'string' ? NODE_TYPE_BY_WIDGET_TYPE[type] : undefined;
return moved ? { ...node, type: moved } : node;
}
diff --git a/packages/plugin-detail/README.md b/packages/plugin-detail/README.md
index 4352963dba..769cb3c8c4 100644
--- a/packages/plugin-detail/README.md
+++ b/packages/plugin-detail/README.md
@@ -413,10 +413,12 @@ Displays related records in list, grid, or table format.
> schema={{
> type: 'record:related_list',
-> objectName: 'contact',
-> relationshipField: 'account_id',
-> title: 'Contacts',
-> columns: ['name', 'email', 'phone'],
+> properties: {
+> objectName: 'contact',
+> relationshipField: 'account_id',
+> title: 'Contacts',
+> columns: ['name', 'email', 'phone'],
+> },
> }}
> />
> );
diff --git a/packages/plugin-form/src/MasterDetailForm.i18nLabels.test.tsx b/packages/plugin-form/src/MasterDetailForm.i18nLabels.test.tsx
index 2d2186fb07..21207b70d2 100644
--- a/packages/plugin-form/src/MasterDetailForm.i18nLabels.test.tsx
+++ b/packages/plugin-form/src/MasterDetailForm.i18nLabels.test.tsx
@@ -66,7 +66,8 @@ vi.mock('@object-ui/components/ui/sonner', async (importOriginal) => {
import { I18nProvider } from '@object-ui/i18n';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
-import type { BaseSchema, DataSource, I18nLabel } from '@object-ui/types';
+import type { DataSource, I18nLabel } from '@object-ui/types';
+import { undeclaredNode } from '@object-ui/test-support';
import { safeValidateSchema } from '@object-ui/types/zod';
import { registerAllFields } from '@object-ui/fields';
// Registers `object-master-detail-form` — the block under test.
@@ -143,7 +144,10 @@ function mount(properties: Record, host: Record
-
+ {/* `host` merges runtime slots (`onCancel`, a function) into the node, which
+ no node type declares; it crosses through the one test helper for
+ undeclared input (objectui#11466). */}
+
,
);
diff --git a/packages/plugin-gantt/src/ObjectGantt.propertiesBag-10859.test.tsx b/packages/plugin-gantt/src/ObjectGantt.propertiesBag-10859.test.tsx
index 92c3014d07..5cccc6b353 100644
--- a/packages/plugin-gantt/src/ObjectGantt.propertiesBag-10859.test.tsx
+++ b/packages/plugin-gantt/src/ObjectGantt.propertiesBag-10859.test.tsx
@@ -34,7 +34,7 @@ import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
-import type { BaseSchema, DataSource } from '@object-ui/types';
+import type { DataSource, DeclaredNode } from '@object-ui/types';
import { safeValidateSchema } from '@object-ui/types/zod';
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }));
@@ -74,22 +74,22 @@ import './index';
const GANTT = { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name', progressField: 'progress' };
const FILTER = [{ field: 'status', operator: 'equals', value: 'open' }];
-const SORT = [{ field: 'name', order: 'desc' }];
+const SORT: Array<{ field: string; order: 'asc' | 'desc' }> = [{ field: 'name', order: 'desc' }];
const ROWS = [
{ id: 't1', name: 'Design', start_date: '2026-03-02', end_date: '2026-03-06', progress: 100, status: 'open' },
{ id: 't2', name: 'Build', start_date: '2026-03-09', end_date: '2026-03-20', progress: 40, status: 'open' },
];
/** The authored spelling, spec-valid. */
-const BAG = { type: 'object-gantt', properties: { objectName: 'task', gantt: GANTT, filter: FILTER, sort: SORT } };
+const BAG: DeclaredNode = { type: 'object-gantt', properties: { objectName: 'task', gantt: GANTT, filter: FILTER, sort: SORT } };
/** The same props written flat, with the `gantt` block — what a code composer can build. */
-const FLAT = { type: 'object-gantt', objectName: 'task', gantt: GANTT, filter: FILTER, sort: SORT };
+const FLAT: DeclaredNode = { type: 'object-gantt', objectName: 'task', gantt: GANTT, filter: FILTER, sort: SORT };
/** The flattened `GanttConfig` keys `ObjectView` writes onto the node it composes. */
-const FLAT_CONFIG = { type: 'object-gantt', objectName: 'task', ...GANTT, filter: FILTER, sort: SORT };
+const FLAT_CONFIG: DeclaredNode = { type: 'object-gantt', objectName: 'task', ...GANTT, filter: FILTER, sort: SORT };
/** The bag with its object supplied by the node's binding instead. */
-const BOUND_BAG = { type: 'object-gantt', dataSource: { object: 'task' }, properties: { gantt: GANTT } };
+const BOUND_BAG: DeclaredNode = { type: 'object-gantt', dataSource: { object: 'task' }, properties: { gantt: GANTT } };
/** The bag on inline rows. */
-const INLINE_BAG = { type: 'object-gantt', properties: { staticData: ROWS, gantt: GANTT } };
+const INLINE_BAG: DeclaredNode = { type: 'object-gantt', properties: { staticData: ROWS, gantt: GANTT } };
function makeAdapter() {
return {
@@ -111,16 +111,16 @@ function makeAdapter() {
};
}
-function renderNode(schema: Record) {
+function renderNode(schema: DeclaredNode) {
const adapter = makeAdapter();
const view = render(
- {/* Through `unknown`, deliberately: these fixtures are unjudged document
- JSON, two of them spellings the validator refuses (`FLAT`,
- `FLAT_CONFIG`), handed to the renderer the way a stored document
- reaches it. No node type declares all of them, and none should
- (objectui#11355). */}
-
+ {/* No cast (objectui#11466): each fixture is a `DeclaredNode`. The bag
+ spellings are the authored `object-gantt` node; `FLAT` and
+ `FLAT_CONFIG` are the `ObjectGanttSchema` twin, the node as code
+ composes it, which the validator refuses as authored metadata and
+ the renderer reads all the same (objectui#11355). */}
+
,
);
return { adapter, ...view };
diff --git a/packages/plugin-map/src/ObjectMap.propertiesBag-10859.test.tsx b/packages/plugin-map/src/ObjectMap.propertiesBag-10859.test.tsx
index e994e63f7b..fd35aafb40 100644
--- a/packages/plugin-map/src/ObjectMap.propertiesBag-10859.test.tsx
+++ b/packages/plugin-map/src/ObjectMap.propertiesBag-10859.test.tsx
@@ -33,7 +33,7 @@ import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
-import type { BaseSchema, DataSource } from '@object-ui/types';
+import type { DataSource, DeclaredNode } from '@object-ui/types';
import { safeValidateSchema } from '@object-ui/types/zod';
type StandInProps = { children?: React.ReactNode; latitude?: number; longitude?: number };
@@ -57,18 +57,18 @@ import './index';
const MAP = { latitudeField: 'lat', longitudeField: 'lng', titleField: 'name' };
const FILTER = [{ field: 'region', operator: 'equals', value: 'west' }];
-const SORT = [{ field: 'name', order: 'desc' }];
+const SORT: Array<{ field: string; order: 'asc' | 'desc' }> = [{ field: 'name', order: 'desc' }];
const ROWS = [
{ id: 's1', name: 'North Store', lat: 37.8044, lng: -122.2711, region: 'west' },
{ id: 's2', name: 'South Store', lat: 37.3382, lng: -121.8863, region: 'west' },
];
/** The authored spelling, spec-valid. */
-const BAG = { type: 'object-map', properties: { objectName: 'store', map: MAP, filter: FILTER, sort: SORT } };
+const BAG: DeclaredNode = { type: 'object-map', properties: { objectName: 'store', map: MAP, filter: FILTER, sort: SORT } };
/** The same props written flat — what a code composer builds. */
-const FLAT = { type: 'object-map', objectName: 'store', map: MAP, filter: FILTER, sort: SORT };
+const FLAT: DeclaredNode = { type: 'object-map', objectName: 'store', map: MAP, filter: FILTER, sort: SORT };
/** The bag with its object supplied by the node's binding instead. */
-const BOUND_BAG = { type: 'object-map', dataSource: { object: 'store' }, properties: { map: MAP } };
+const BOUND_BAG: DeclaredNode = { type: 'object-map', dataSource: { object: 'store' }, properties: { map: MAP } };
function makeAdapter() {
return {
@@ -84,15 +84,16 @@ function makeAdapter() {
};
}
-async function renderNode(schema: Record) {
+async function renderNode(schema: DeclaredNode) {
const adapter = makeAdapter();
const view = render(
- {/* Through `unknown`, deliberately: these fixtures are unjudged document
- JSON, one of them a spelling the validator refuses (`FLAT`), handed to
- the renderer the way a stored document reaches it. No node type
- declares all three, and none should (objectui#11355). */}
-
+ {/* No cast (objectui#11466): each fixture is a `DeclaredNode`. The bag
+ spellings are the authored `object-map` node; `FLAT` is the
+ `ObjectMapSchema` twin, the node as code composes it, which the
+ validator refuses as authored metadata and the renderer reads all
+ the same (objectui#11355). */}
+
,
);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
diff --git a/packages/react/README.md b/packages/react/README.md
index efa8070e05..bf60f2e8ba 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -25,8 +25,9 @@ npm install @object-ui/react @object-ui/core
```tsx
import { SchemaRenderer } from '@object-ui/react'
+import type { DeclaredNode } from '@object-ui/types'
-const schema = {
+const schema: DeclaredNode = {
type: 'text',
content: 'Hello, Object UI!'
}
@@ -36,9 +37,12 @@ function App() {
}
```
-`schema` takes a `BaseSchema` node, an `AuthoringNode` from `@object-ui/types` (a spec page
-block such as `element:text` with its typed `properties` bag, or a stored page document under
-its page kind), a bare string, or nothing.
+`schema` takes a `DeclaredNode` from `@object-ui/types`, a bare string, or nothing. A
+`DeclaredNode` is a node of a declared type, keyed by its `type`: a component schema that
+package declares, a spec page block such as `element:text` with its typed `properties` bag, a
+stored page document under its page kind, or a type your application registers and declares
+in `CustomNodeRegistry`. Each node, nested ones included, is checked against its own type; a
+`type` nothing declares is refused.
### With Data
@@ -54,8 +58,9 @@ for every conformant host.
```tsx
import { SchemaRenderer, PredicateScopeProvider } from '@object-ui/react'
+import type { DeclaredNode } from '@object-ui/types'
-const schema = {
+const schema: DeclaredNode = {
type: 'form',
children: [
{
@@ -90,9 +95,9 @@ function App() {
```tsx
import { SchemaRenderer } from '@object-ui/react'
-import type { BaseSchema } from '@object-ui/types'
+import type { DeclaredNode } from '@object-ui/types'
-declare const formSchema: BaseSchema
+declare const formSchema: DeclaredNode
function App() {
const handleSubmit = (data: Record) => {
@@ -118,11 +123,11 @@ below it:
```tsx
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'
import type { ApiFetch } from '@object-ui/react'
-import type { BaseSchema, DataSource } from '@object-ui/types'
+import type { DataSource, DeclaredNode } from '@object-ui/types'
declare const adapter: DataSource
declare const authenticatedFetch: ApiFetch
-declare const schema: BaseSchema
+declare const schema: DeclaredNode
(bag: T): T {
*
* ## Why `schema` is spelled as this union and not as a `SchemaNode`
*
- * The repo carries two competing `SchemaNode` types: `@object-ui/core`'s
- * interface (which requires `type: string`) and `@object-ui/types`' union
- * (`BaseSchema | string | number | boolean | null | undefined`). This component
+ * The repo carried two competing `SchemaNode` types: `@object-ui/core`'s
+ * interface (which requires `type: string`) and `@object-ui/types`' union (then
+ * `BaseSchema | string | number | boolean | null | undefined`). This component
* matched NEITHER. It declared core's — narrower than what it accepts, so every
* caller holding the types union was wrong and could not be told — while its
* runtime returns early for strings and nullish, which core's interface forbids.
@@ -1038,24 +1038,25 @@ function withoutAuthoredObjectFields(bag: T): T {
* handles: an object schema, a bare string (rendered as text), or nothing at
* all. `number` / `boolean` are deliberately excluded — the runtime tolerates
* them defensively (see the primitive guard in the evaluation memo) but no
- * author should be invited to pass them. Reconciling the two repo-wide
- * `SchemaNode` spellings is a separate concern and deliberately not done here.
- *
- * ## `AuthoringNode`: the spec-declared nodes, by reference (objectui#11364)
- *
- * The object member is `BaseSchema` OR an `AuthoringNode` from
- * `@object-ui/types`: a public block (its zod arm's input), an
- * `element:text_input` / `element:record_picker` (its spec row), or a stored
- * page document under its page kind. Each is derived from the zod arm or the
- * spec row, and none carries an index signature. Inside this union TypeScript
- * discriminates on the literal `type`, so those nodes' own keys are judged:
- * once objectui#8347 removes `BaseSchema`'s index signature, a misspelled key in
- * an `element:text` bag is refused while the spec's spelling compiles. ⛔ The
- * widening is by those referenced types only, never by an index signature or a
- * `Record` on this prop.
+ * author should be invited to pass them; `toRenderableSchema` (`./schema-input`)
+ * is the bridge from a `SchemaNode` that may hold one.
+ *
+ * ## The object member is `DeclaredNode` (objectui#11466)
+ *
+ * The object member is `DeclaredNode` from `@object-ui/types`, the same type a
+ * node slot takes: the discriminated union, keyed by the literal `type`, of the
+ * component schemas that package declares, the spec-declared `AuthoringNode`s
+ * (objectui#11364: public blocks, `element:text_input` /
+ * `element:record_picker`, a stored page document under its page kind), and
+ * the types an application declares in `CustomNodeRegistry`. It has no
+ * `type: string` arm, so a node whose `type` no declaration names is refused
+ * here, and each node's own keys are judged: once objectui#8347 removes
+ * `BaseSchema`'s index signature, a misspelled key is refused while the
+ * declared spelling compiles. ⛔ The prop is widened by declared types only,
+ * never by an index signature, a `Record` or a `type: string` arm.
*/
export interface SchemaRendererProps {
- schema: BaseSchema | AuthoringNode | string | null | undefined;
+ schema: DeclaredNode | string | null | undefined;
}
/**
diff --git a/packages/react/src/__tests__/SchemaRenderer.aria.test.tsx b/packages/react/src/__tests__/SchemaRenderer.aria.test.tsx
index 096794955d..4a5745a8b0 100644
--- a/packages/react/src/__tests__/SchemaRenderer.aria.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.aria.test.tsx
@@ -24,14 +24,23 @@ import { SchemaRenderer } from '../SchemaRenderer';
* `@objectstack/spec`'s `AriaProps`, the vocabulary that reader documents. The
* node also carries the `content` that `TestWidget` renders.
*
- * Each literal is checked against this node, not against the `BaseSchema`
- * that the `schema` prop accepts, so its keys stay checked once objectui#8347
- * removes `BaseSchema`'s index signature.
+ * Each literal is checked against this node, so its keys stay checked once
+ * objectui#8347 removes `BaseSchema`'s index signature. It is declared to
+ * `@object-ui/types` through `CustomNodeRegistry` below, the way an
+ * application declares a type it registers (objectui#11466): the `schema`
+ * prop takes the declared node types only.
*/
type FlatAriaProbe = BaseSchema &
- Pick & { content?: string };
+ Pick & { type: 'test-widget'; content?: string };
const flatAriaProbe = (schema: FlatAriaProbe): FlatAriaProbe => schema;
+declare module '@object-ui/types' {
+ interface CustomNodeRegistry {
+ /** This file's registered `TestWidget`. */
+ 'test-widget': FlatAriaProbe;
+ }
+}
+
// A simple test component that forwards ARIA attributes
const TestWidget: React.FC = (props) => (