From f7cd0c0f9f8818ff62ec9b2043c369338b39d889 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:39:56 +0000 Subject: [PATCH 01/16] feat(types,react)!: a node slot and SchemaRenderer's schema prop take the declared-node union (objectui#11466) `SchemaNode`'s object arm is `DeclaredNode`: the discriminated union, keyed by the literal `type`, of the component schemas `AnySchema` declares (without its `BaseSchema` arm, whose `type` is `string`), the spec-declared `AuthoringNode`s and the types an application declares in the new `CustomNodeRegistry` interface. `SchemaRendererProps.schema` takes the same type beside a string. No `type: string` arm, no index signature. The cycle: `SchemaNode` names `AuthoringNode`, whose `FlexBlockNode` is the input of `FlexBlockSchema`, whose bag names `SchemaNodeSchema`, whose type names `SchemaNode`. `FlexBlockNode` becomes an interface (members resolved lazily), and `zod/base.zod.ts` writes the node slot's zod type out through type aliases. Typing only; no schema object moves. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- packages/react/src/SchemaRenderer.tsx | 41 ++++++------- packages/react/src/schema-input.ts | 4 +- packages/types/src/authoring-nodes.ts | 46 ++++++++++----- packages/types/src/base.ts | 83 +++++++++++++++++++++++++-- packages/types/src/index.ts | 5 ++ packages/types/src/zod/base.zod.ts | 46 +++++++++++++-- 6 files changed, 178 insertions(+), 47 deletions(-) diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index 057740d1e6..cf7584f4b7 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -7,7 +7,7 @@ */ import React, { forwardRef, useContext, useMemo, useEffect, useReducer, useState, Component, type ForwardRefExoticComponent, type RefAttributes } from 'react'; -import type { AuthoringNode, BaseSchema } from '@object-ui/types'; +import type { BaseSchema, DeclaredNode } from '@object-ui/types'; import { ComponentRegistry, ExpressionEvaluator, @@ -1025,9 +1025,9 @@ function withoutAuthoredObjectFields(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/schema-input.ts b/packages/react/src/schema-input.ts index ae6480ab1c..a49343c08d 100644 --- a/packages/react/src/schema-input.ts +++ b/packages/react/src/schema-input.ts @@ -13,8 +13,8 @@ import type { SchemaRendererProps } from './SchemaRenderer.js'; * Narrow a loosely-typed metadata node onto {@link SchemaRendererProps.schema}. * * `@object-ui/types` declares `SchemaNode` as - * `BaseSchema | string | number | boolean | null | undefined`, and a lot of - * metadata plumbing (page regions, header-bar actions, detail tabs, view + * `DeclaredNode | string | number | boolean | null | undefined` (its object + * arm the declared-node union, objectui#11466), and a lot of metadata plumbing (page regions, header-bar actions, detail tabs, view * configs) is typed with it. `SchemaRenderer` deliberately does NOT declare the * `number` / `boolean` members — nobody should be invited to author them — so * forwarding such a value needs one honest step in between. diff --git a/packages/types/src/authoring-nodes.ts b/packages/types/src/authoring-nodes.ts index ce6c0110ff..6bf5cfc2c9 100644 --- a/packages/types/src/authoring-nodes.ts +++ b/packages/types/src/authoring-nodes.ts @@ -70,20 +70,24 @@ * defaults it. `PageNodeSchema` is the separate `type: 'page'` node, and * stays as it is. * - * ## What these types do, and what they do NOT do + * ## What these types do * - * `SchemaRendererProps.schema` (`@object-ui/react`) accepts {@link AuthoringNode} - * beside `BaseSchema`. In that union, TypeScript discriminates on the literal - * `type`, so a node of one of these types has its own keys judged: a misspelled - * key inside an `element:text` bag, or a bag on a node type that declares none, - * is refused once objectui#8347 lands (`authoring-nodes-11364.test.ts` pins it). - * The union does not make a member's REQUIRED keys required, because - * `BaseSchema` (`type: string`) still accepts the same node structurally. - * Requiredness is the validator's to judge. + * {@link AuthoringNode} is one of the three parts of `DeclaredNode` + * (`./base.ts`, objectui#11466), beside the component schemas this package + * declares and the types an application declares in `CustomNodeRegistry`. + * `DeclaredNode` is the object arm of `SchemaNode`, so every node slot takes + * these types, and it is what `SchemaRendererProps.schema` (`@object-ui/react`) + * takes beside a string. The union has no `type: string` arm, so TypeScript + * discriminates on the literal `type`: a node of one of these types has its own + * keys judged wherever it is written, nested in a slot or at the prop, and its + * required keys are required. A misspelled key inside an `element:text` bag, or + * a bag on a node type that declares none, is refused once objectui#8347 + * removes `BaseSchema`'s index signature (`authoring-nodes-11364.test.ts` and + * `node-slot-union-11466.test.ts` pin it). * - * ⛔ `SchemaNode` is not widened here. Every reader of a `SchemaNode` that - * reads an undeclared key compiles today only through `BaseSchema`'s index - * signature, and a closed member in the union turns that read into TS2339. + * Until that removal, the component-schema members that extend `BaseSchema` + * still carry its index signature, so a misspelled key on one of THEM still + * compiles. These members carry none. */ import type { z } from 'zod'; @@ -240,8 +244,19 @@ export type EmbeddableFormBlockNode = ObjectQLPublicBlockNodeOf<'embeddable-form * ({@link ClosedBag}), because `@objectstack/spec` has no `flex` row * (objectui#11276). The TypeScript `FlexSchema` (`./layout.ts`) stays the node * as the `flex` renderer reads it after the hoist. + * + * ⚠️ An interface, not a type alias, for one reason (objectui#11466). The bag + * holds node slots, so `FlexBlockSchema`'s inferred type names + * `SchemaNodeSchema`, whose type names `SchemaNode`; and `SchemaNode` names + * this type back, through `DeclaredNode` and {@link AuthoringNode}. A type + * alias is resolved eagerly when `SchemaNode` is, so that loop is a circular + * reference (TS2456 on this type, `AuthoringNode`, `DeclaredNode` and + * `SchemaNode`). An interface's members are resolved only when they are read, + * so the loop is never walked while `SchemaNode` is being resolved. The + * interface declares nothing of its own: every member is the arm's, as above. */ -export type FlexBlockNode = ClosedArmInput; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- an interface on purpose, to break the SchemaNode cycle its docblock names; it declares no member of its own. +export interface FlexBlockNode extends ClosedArmInput {} /** * `element:text_input`: `ComponentPropsMap['element:text_input']` @@ -275,8 +290,9 @@ type SpecPageInput = z.input; export type PageDocumentNode = SpecPageInput & { type: NonNullable }; /** - * Every node this module types, so the one place that accepts them all - * (`SchemaRendererProps.schema`) names one type. + * Every node this module types, named as one type for `DeclaredNode` + * (`./base.ts`), the union every node slot and `SchemaRendererProps.schema` + * take (objectui#11466). */ export type AuthoringNode = | PublicBlockNode diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index ce2bc9e31d..9c35028ee7 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -17,7 +17,9 @@ */ import type { I18nLabel } from '@objectstack/spec/ui'; +import type { AuthoringNode } from './authoring-nodes.js'; import type { ExpressionWire } from './expression.js'; +import type { AnySchema } from './index.js'; /** * A KEYED i18n label — a reference INTO a translation bundle (objectui#4581). @@ -599,9 +601,82 @@ export interface BaseSchema { } /** - * A schema node can be a full schema object or a primitive value. - * This union type supports both structured components and simple content. - * + * The registry an application augments to declare its OWN node types: the + * types it registers with `ComponentRegistry` that this package does not + * declare (objectui#11466). + * + * Each entry's key is the node's `type` string and its value is the node's + * schema type. An entry joins {@link DeclaredNode}, so a node of that type is + * authorable in every node slot and at `SchemaRenderer`'s `schema` prop: + * + * ```typescript + * interface MyWidgetSchema extends BaseSchema { + * type: 'my-widget'; + * customProp?: string; + * } + * + * declare module '@object-ui/types' { + * interface CustomNodeRegistry { + * 'my-widget': MyWidgetSchema; + * } + * } + * ``` + * + * ## Why a custom type must be declared here + * + * A node slot takes the union of the declared node types, discriminated by + * the literal `type`, so each inline child is checked against its own type's + * keys and a misspelled key is refused where it is written. An undeclared + * `type` string has no arm, so it is refused too, the same way the strict zod + * face refuses an unregistered type. The entry is what declares it. + * + * ⛔ An entry is joined under its KEY: the arm is the entry's type intersected + * with `{ type: KEY }`. So an entry cannot add a `type: string` arm, which + * would make every declared type's literal a candidate for that arm and let + * the arm's keys through on every node. An entry whose own `type` is a + * different literal than its key intersects to `never` and adds nothing. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- empty by design: applications fill it by declaration merging. +export interface CustomNodeRegistry {} + +/** One arm per {@link CustomNodeRegistry} entry, discriminated by the entry's key. */ +type RegisteredCustomNode = { + [K in keyof CustomNodeRegistry & string]: CustomNodeRegistry[K] & { type: K }; +}[keyof CustomNodeRegistry & string]; + +/** + * The arms of a union whose `type` is a literal (or a union of literals). An + * arm whose `type` is `string` matches every discriminant, so it is dropped: + * from `AnySchema`, that is `BaseSchema` alone, which + * `__tests__/node-slot-union-11466.test.ts` pins. + */ +type LiteralTypedArm = T extends { type: infer K } ? (string extends K ? never : T) : never; + +/** + * A node of a declared type: the object arm of {@link SchemaNode}, and what + * `SchemaRenderer`'s `schema` prop takes beside a string (objectui#11466). + * + * It is the discriminated union, keyed by the literal `type`, of + * + * - every component schema this package declares (`AnySchema` without its + * `BaseSchema` arm, whose `type` is `string`); + * - every spec-declared node `AuthoringNode` types by reference; + * - every custom type an application declares in {@link CustomNodeRegistry}. + * + * Inside the union TypeScript discriminates on `type`, so an inline child + * literal is checked against its own type's keys and value types, wherever + * it is nested. ⛔ No arm is open: there is no index signature and no + * `type: string` arm here. A node built at runtime whose `type` is not known + * to the compiler is not a `DeclaredNode`; narrow it to one, or declare its + * type in {@link CustomNodeRegistry}. + */ +export type DeclaredNode = LiteralTypedArm | AuthoringNode | RegisteredCustomNode; + +/** + * A schema node: a node of a declared type, or a primitive rendered as + * content. Every node slot (`children`, `trigger`, `content`, a view's + * `schema`) takes it, so an inline child is checked against its own type. + * * @example * ```typescript * const nodes: SchemaNode[] = [ @@ -611,7 +686,7 @@ export interface BaseSchema { * ] * ``` */ -export type SchemaNode = BaseSchema | string | number | boolean | null | undefined; +export type SchemaNode = DeclaredNode | string | number | boolean | null | undefined; /** * Component renderer function type. diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d17e692332..c45a48184f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -107,6 +107,11 @@ export type { // half of what stops them being mixed up (#4581). KeyedI18nLabel, SchemaNode, + // The object arm of `SchemaNode`: the discriminated union of the declared + // node types, plus the custom types an application declares in + // `CustomNodeRegistry` by declaration merging (objectui#11466). + DeclaredNode, + CustomNodeRegistry, ComponentRendererProps, ComponentInput, // The input the FRAMEWORK injects (`binding: 'object'`, `9e37d9b39`) — diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index fe6c4371c2..899648142d 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -191,6 +191,24 @@ export function defineNodeComponentUnion(union: T): T { return union; } +/** + * The type of {@link SchemaNodeSchema}, named by a type alias rather than spelled in + * the annotation (objectui#11466). `SchemaNode` (`../base.ts`) is the declared-node + * union, and resolving it walks back into this module's consts: through + * `AuthoringNode`, whose public-block members are the `z.input` of arms built on + * `BaseSchema` below. Type arguments spelled in a const's annotation are resolved + * eagerly, so they would reference themselves (TS4109); inside a type alias they are + * resolved when they are read. Typing only: the schema object does not change. + */ +type SchemaNodeZodType = z.ZodType; + +/** + * The type of {@link NodeSlotSchema}, the single-or-list node slot, written out for + * the same reason as {@link SchemaNodeZodType}: inferring it from the `z.union` call + * would resolve `SchemaNode` while `SchemaNode` is being resolved. + */ +type NodeSlotZodType = z.ZodOptional]>>; + /** * Schema Node — what a child slot holds: a COMPONENT document, or a primitive. * @@ -266,9 +284,14 @@ export function defineNodeComponentUnion(union: T): T { * what this schema accepts at runtime. * * ⛔ `SchemaNode` in `../base.ts` did NOT move under #8344 either: the TS face still - * says `BaseSchema | primitive`, and `BaseSchema` carries an index signature, so the - * runtime accept set is now NARROWER than the declaration rather than wider. The - * declaration repair is its own worklist and ⛔ not this const's to make. + * said `BaseSchema | primitive`, and `BaseSchema` carries an index signature, so the + * runtime accept set was NARROWER than the declaration rather than wider. The + * declaration repair was its own worklist and ⛔ not this const's to make. + * ⚠️ Dated note, 2026-10-02 (objectui#11466): that repair has landed. `SchemaNode`'s + * object arm is `DeclaredNode`, the union of the declared node types with no + * `type: string` arm, so an undeclared `type` is refused on the TS face too. The + * members that extend `BaseSchema` keep its index signature until objectui#8347 + * removes it. The two sentences above are kept as the reading at #8344. * * ⭐ What #7760 bought: `__tests__/zod-mirror-parity.test.ts` can now compare the * `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` single-or-list slots that @@ -285,7 +308,7 @@ export function defineNodeComponentUnion(union: T): T { * a declaration or narrowing a mirror to make the annotation fit: either is a * contract change wearing a type-annotation's clothes, and both are ruled elsewhere. */ -export const SchemaNodeSchema: z.ZodType = z.lazy(() => { +export const SchemaNodeSchema: SchemaNodeZodType = z.lazy(() => { // `z.lazy` memoises this getter, and that is FINE — because what it returns is the // one live union, whose option slot 0 IS the recursion point and is written by // {@link defineNodeComponentUnion}. ⛔ Do not move the union's CONSTRUCTION in here: @@ -294,6 +317,17 @@ export const SchemaNodeSchema: z.ZodType = z.lazy(() => return nodeUnion; }); +/** + * `BaseSchemaCore`'s `children` slot: one node or a list of nodes. It is the same + * schema it always was, `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`, + * held in a const only so its type can be written out ({@link NodeSlotZodType}, + * objectui#11466). Typing only: the accept set does not change. + */ +const NodeSlotSchema: NodeSlotZodType = z + .union([SchemaNodeSchema, z.array(SchemaNodeSchema)]) + .optional() + .describe('Child components (React-style)'); + /** * Base Schema - Core validation schema that all components extend * @@ -388,7 +422,7 @@ const BaseSchemaCore = z.object({ /** * Child components or content — the one child-list spelling (objectui#6771) */ - children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components (React-style)'), + children: NodeSlotSchema, /** * Visibility control — a boolean, or the predicate STRING the renderer @@ -523,7 +557,7 @@ const nodeUnionOptions: [z.ZodType, ...z.ZodType[]] = [ z.undefined(), ]; -const nodeUnion = z.union(nodeUnionOptions) as unknown as z.ZodType; +const nodeUnion = z.union(nodeUnionOptions) as unknown as SchemaNodeZodType; /** * A spec schema's fields, minus the keys objectui declares locally, as an From bf9768edc833a2d29bf320ed2b48568078d7bec5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:48:09 +0000 Subject: [PATCH 02/16] fix(types): `app` and `list` are one arm each in DeclaredNode (objectui#11466) `AppComponentSchema` (the app.json document, read structurally by `AppSchemaRenderer`, never dispatched by `ComponentRegistry`) is not a node, so `DeclaredNode` leaves it out: the registry's `app` key serves the page kind. `PageDocumentNode` admits every page kind but the interface-mode `list`, which `PageView` renders through `InterfaceListPage` and never hands to `SchemaRenderer`; the registry's `list` key is the `list` node's. Narrowing a `DeclaredNode` on `type === 'app'` now gives `PageDocumentNode` alone (was `AppComponentSchema | PageDocumentNode`), and on `'list'` gives `ListSchema` alone (was `ListSchema | PageDocumentNode`). `SchemaByType` reads `AnySchema` and does not move. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- packages/types/src/authoring-nodes.ts | 18 +++++++++++++++--- packages/types/src/base.ts | 19 +++++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/types/src/authoring-nodes.ts b/packages/types/src/authoring-nodes.ts index 6bf5cfc2c9..1fc6961ad5 100644 --- a/packages/types/src/authoring-nodes.ts +++ b/packages/types/src/authoring-nodes.ts @@ -67,8 +67,9 @@ * - {@link PageDocumentNode}: the spec's `PageSchema` input. `PageView` * hands a stored page to `SchemaRenderer` with its page KIND written into * `type` (objectui#9642), so `type` is required here, while the spec - * defaults it. `PageNodeSchema` is the separate `type: 'page'` node, and - * stays as it is. + * defaults it; every kind but the interface-mode `list`, which never + * reaches `SchemaRenderer`. `PageNodeSchema` is the separate + * `type: 'page'` node, and stays as it is. * * ## What these types do * @@ -286,8 +287,19 @@ type SpecPageInput = z.input; * input, whose `type` is the page kind (`PageTypeSchema`) and whose `kind` is * the override mode. `type` is required, because it is the discriminator the * registry dispatches on. + * + * ⛔ Every page kind but `list` (objectui#11466). A `list` page is ADR-0047 + * interface mode: `PageView` (`@object-ui/app-shell`) renders it through + * `InterfaceListPage`, never through `SchemaRenderer`, and `ComponentRegistry`'s + * `list` key is the `list` node's renderer (`ListSchema`, `./data-display.ts`). A page + * document under `type: 'list'` handed to `SchemaRenderer` would be drawn by + * that renderer, so this type does not admit one. That also leaves `list` one + * arm in `DeclaredNode` (`./base.ts`), where the page kind and the node type + * would otherwise share the discriminant and let each other's keys through. + * `page-kind-node-type-channel-9642` in `@object-ui/components` re-derives + * which kinds reach the registry. */ -export type PageDocumentNode = SpecPageInput & { type: NonNullable }; +export type PageDocumentNode = SpecPageInput & { type: Exclude, 'list'> }; /** * Every node this module types, named as one type for `DeclaredNode` diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index 9c35028ee7..4e8bb0f371 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -17,6 +17,7 @@ */ import type { I18nLabel } from '@objectstack/spec/ui'; +import type { AppComponentSchema } from './app.js'; import type { AuthoringNode } from './authoring-nodes.js'; import type { ExpressionWire } from './expression.js'; import type { AnySchema } from './index.js'; @@ -659,7 +660,8 @@ type LiteralTypedArm = T extends { type: infer K } ? (string extends K ? neve * It is the discriminated union, keyed by the literal `type`, of * * - every component schema this package declares (`AnySchema` without its - * `BaseSchema` arm, whose `type` is `string`); + * `BaseSchema` arm, whose `type` is `string`, and without + * `AppComponentSchema`, below); * - every spec-declared node `AuthoringNode` types by reference; * - every custom type an application declares in {@link CustomNodeRegistry}. * @@ -669,8 +671,21 @@ type LiteralTypedArm = T extends { type: infer K } ? (string extends K ? neve * `type: string` arm here. A node built at runtime whose `type` is not known * to the compiler is not a `DeclaredNode`; narrow it to one, or declare its * type in {@link CustomNodeRegistry}. + * + * ⛔ `AppComponentSchema` is not a node. It is the app-level document + * (`app.json`: navigation, areas), which `AppSchemaRenderer` + * (`@object-ui/layout`) reads structurally and `ComponentRegistry` never + * dispatches. The registry's `app` key serves the page KIND `app`, so as a + * node `type: 'app'` is a stored page document, `AuthoringNode`'s + * `PageDocumentNode`. Kept out, `app` is one arm here, not two that would let + * each other's keys through (`./registry.ts` names the two vocabularies at its + * `'page'` entry). The node that renders an app through the registry is + * `app-schema-renderer`. */ -export type DeclaredNode = LiteralTypedArm | AuthoringNode | RegisteredCustomNode; +export type DeclaredNode = + | LiteralTypedArm> + | AuthoringNode + | RegisteredCustomNode; /** * A schema node: a node of a declared type, or a primitive rendered as From 3b080de8a31922874daeba39150375c7d15df1f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:15:00 +0000 Subject: [PATCH 03/16] refactor(components,core): page.tsx crosses into DeclaredNode once, after validateTree; the builder's node slots take DeclaredNode (objectui#11466) - `kind: 'html'` pages: the runtime-parsed `SchemaElement` tree becomes a `DeclaredNode` at ONE documented boundary, reached only when `compile`'s `validateTree` reported no error (seat answer Q2 A on objectui#11479). - `@object-ui/core`'s schema builder: `.child()` / `.children()` (grid, flex) and the card's `.content()` take `DeclaredNode` instead of `BaseSchema`, so a child passed in is checked against its own type. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../components/src/renderers/layout/page.tsx | 19 +++++++++++++-- packages/core/src/builder/schema-builder.ts | 24 +++++++++++-------- 2 files changed, 31 insertions(+), 12 deletions(-) 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 d6657fc871..690ed2c8ce 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; } @@ -349,18 +351,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; } @@ -419,18 +422,19 @@ export class FlexBuilder extends SchemaBuilder Date: Fri, 2 Oct 2026 20:35:45 +0000 Subject: [PATCH 04/16] test: the test-only node types are declared, deliberately undeclared input crosses one helper, and the old-shape pins state the declared-node union (objectui#11466) - `@object-ui/test-support` gains `undeclaredNode`, THE one crossing for deliberately undeclared node input in a test: unknown or retired types, refused spellings (`props` / `visibility` on `element:text`, a node-level `params`, a retired `drillDown`), the three runtime-flat action suites, a host slot merged into a node, bare `{ type }` smoke nodes. - Types a test registers with `ComponentRegistry` are declared in `CustomNodeRegistry`, as an application would (react: test-widget, test-debug-div, crashing-widget / stable-widget, test-component, probe-7088, test-4548-div, identity-probe / self-updating-probe, test:probe-4795, valid-host / host-wrap, test-ctx-consumer, probe-3955, probe-7530, test:schema-input-bridge-8908). - Fixtures that are declared nodes name their type: plugin-gantt and plugin-map's propertiesBag fixtures (bag node and TS twin), the four `home` page documents (now with the spec-required `label`). - Pins: react propsResolution and schema-input.bridgeFidelity state `DeclaredNode`; types overlay-trigger-union-7081 likewise; node-recursion-point-8344 states the measured exclusion set the `BaseSchema` arm used to hide; zod-mirror-parity records the two Wider rows the `component` slot's `BaseSchema` fallback produces (objectui#8347's). - New pin `node-slot-union-11466.test.ts`: a nested misspelling on a closed node is refused with the signature present, no `type: string` arm, the `app` / `list` arms, registry entries join under their key. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../alias-precedence-cross-channel.test.tsx | 12 +- .../__tests__/html-page-lazy-blocks.test.tsx | 2 +- .../src/__tests__/page-variables.test.tsx | 12 +- .../src/__tests__/react-page-adapter.test.tsx | 3 +- .../react-page-invalidation.test.tsx | 3 +- .../src/__tests__/react-page-scope.test.tsx | 2 +- .../action-button-icon-inputs-11168.test.tsx | 9 +- ...ion-container-member-params-10290.test.tsx | 8 +- .../action-group-menu-inputs-11168.test.tsx | 9 +- .../action-params-templates-7867.test.tsx | 16 ++- .../__tests__/degenerate-config-bag.test.tsx | 7 +- .../app-shell-not-a-component-key.test.tsx | 9 +- ...ion-responsive-grid-retired-11441.test.tsx | 11 +- .../pivotNode.drillDownRetired-10932.test.tsx | 11 +- .../src/MasterDetailForm.i18nLabels.test.tsx | 8 +- .../ObjectGantt.propertiesBag-10859.test.tsx | 28 ++-- .../ObjectMap.propertiesBag-10859.test.tsx | 23 ++-- .../__tests__/SchemaRenderer.aria.test.tsx | 17 ++- .../__tests__/SchemaRenderer.debug.test.tsx | 13 +- .../SchemaRenderer.errorBoundary.test.tsx | 14 +- .../SchemaRenderer.expressions.test.tsx | 14 +- ...SchemaRenderer.hiddenDeclaredGate.test.tsx | 13 +- ...emaRenderer.hiddenVisibleSynonymy.test.tsx | 12 ++ ...enderer.predicateEnvelopeDeclared.test.tsx | 13 +- .../SchemaRenderer.primitiveSchema.test.tsx | 16 ++- .../SchemaRenderer.propsResolution.test.ts | 11 +- ...enderer.scopedStyleSchemaIdentity.test.tsx | 47 +++++-- ...r.unevaluatedExpressionDiagnostic.test.tsx | 16 ++- ...enderer.unknownTypeCaseSuggestion.test.tsx | 14 +- .../SchemaRenderer.validation.test.tsx | 14 +- .../SchemaRendererProvider.smoke.test.tsx | 20 ++- .../schema-input.bridgeFidelity.test.tsx | 21 ++- packages/test-support/src/index.ts | 7 + packages/test-support/src/undeclared-node.ts | 36 ++++++ .../__tests__/authoring-nodes-11364.test.ts | 7 + .../node-recursion-point-8344.test.ts | 51 +++++++- .../__tests__/node-slot-union-11466.test.ts | 122 ++++++++++++++++++ .../overlay-trigger-union-7081.test.ts | 10 +- .../src/__tests__/zod-mirror-parity.test.ts | 35 ++++- 39 files changed, 581 insertions(+), 115 deletions(-) create mode 100644 packages/test-support/src/undeclared-node.ts create mode 100644 packages/types/src/__tests__/node-slot-union-11466.test.ts 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-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/layout/src/__tests__/app-shell-not-a-component-key.test.tsx b/packages/layout/src/__tests__/app-shell-not-a-component-key.test.tsx index c96e96752d..f584bb0d45 100644 --- a/packages/layout/src/__tests__/app-shell-not-a-component-key.test.tsx +++ b/packages/layout/src/__tests__/app-shell-not-a-component-key.test.tsx @@ -66,6 +66,11 @@ import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '@object-ui/react'; +// Both node types below are ones no `@object-ui/types` declaration names: the +// probe is unregistered on purpose, and the control is `@object-ui/layout`'s own +// namespaced registration. The registry's runtime answer is the point, 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 — the local @@ -141,7 +146,7 @@ describe('`app-shell` is not registered (objectui#4841)', () => { 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/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-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/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) => (
{ @@ -37,7 +49,7 @@ const StableWidget: React.FC = (props) => ( * `content` stays checked once objectui#8347 removes `BaseSchema`'s index * signature. */ -type StableWidgetNode = BaseSchema & { content?: string }; +type StableWidgetNode = BaseSchema & { type: 'stable-widget'; content?: string }; const stableWidget = (schema: StableWidgetNode): StableWidgetNode => schema; describe('SchemaErrorBoundary', () => { diff --git a/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx b/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx index a056923291..7d27ccdc2c 100644 --- a/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx @@ -34,10 +34,22 @@ import type { PageComponent } from '@objectstack/spec/ui'; * that row. It is checked against this node, not against `BaseSchema`, so the * key stays checked once objectui#8347 removes `BaseSchema`'s index signature. */ -type DeprecatedVisibilityNode = BaseSchema & Pick; +type DeprecatedVisibilityNode = BaseSchema & Pick & { type: 'test-component' }; const deprecatedVisibilityNode = (schema: DeprecatedVisibilityNode): DeprecatedVisibilityNode => schema; import { PredicateScopeProvider } from '../hooks/useExpression'; +/** + * This file's registered `TestComponent`, typed by its probe node, declared to + * `@object-ui/types` the way an application declares a type it registers + * (objectui#11466): a node slot and the `schema` prop take the declared node + * types only. + */ +declare module '@object-ui/types' { + interface CustomNodeRegistry { + 'test-component': DeprecatedVisibilityNode; + } +} + /** * NOTE (objectui#7912): `SchemaRendererProvider.dataSource` — and the context * it feeds — declare the published `DataSource` adapter contract. The values diff --git a/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx b/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx index bd0a11b5f1..b5835db50c 100644 --- a/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx @@ -99,6 +99,17 @@ import { SchemaRendererContext } from '../context/SchemaRendererContext'; import { PredicateScopeProvider } from '../hooks/useExpression'; import { __resetVisibilityPredicateWarnings } from '../utils/visibilityDiagnostic'; +/** + * This file's registered probe, declared to `@object-ui/types` the way an + * application declares a type it registers (objectui#11466): a node slot and + * the `schema` prop take the declared node types only. + */ +declare module '@object-ui/types' { + interface CustomNodeRegistry { + 'probe-3955': BaseSchema; + } +} + /** * NOTE (objectui#7912): `SchemaRendererProvider.dataSource` — and the context * it feeds — declare the published `DataSource` adapter contract. The values @@ -157,7 +168,7 @@ function renderNode(schema: Record) { * well. That its verdict is IDENTICAL to the string form's, on all three keys * and in both polarities, is `SchemaRenderer.predicateEnvelopeDeclared.test.tsx`. */ -function renderDeclaredNode(schema: BaseSchema) { +function renderDeclaredNode(schema: BaseSchema & { type: 'probe-3955' }) { return render( diff --git a/packages/react/src/__tests__/SchemaRenderer.hiddenVisibleSynonymy.test.tsx b/packages/react/src/__tests__/SchemaRenderer.hiddenVisibleSynonymy.test.tsx index 77a56a695c..768a0d9fe7 100644 --- a/packages/react/src/__tests__/SchemaRenderer.hiddenVisibleSynonymy.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.hiddenVisibleSynonymy.test.tsx @@ -44,6 +44,18 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '../SchemaRenderer'; +import type { BaseSchema } from '@object-ui/types'; + +/** + * This file's registered probe, declared to `@object-ui/types` the way an + * application declares a type it registers (objectui#11466): a node slot and + * the `schema` prop take the declared node types only. + */ +declare module '@object-ui/types' { + interface CustomNodeRegistry { + 'probe-7088': BaseSchema; + } +} const Probe = () =>
; diff --git a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx index ef942d45ba..b6fc7dac60 100644 --- a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx @@ -67,6 +67,17 @@ import { SchemaRenderer } from '../SchemaRenderer'; import { SchemaRendererContext } from '../context/SchemaRendererContext'; import { PredicateScopeProvider } from '../hooks/useExpression'; +/** + * This file's registered probe, declared to `@object-ui/types` the way an + * application declares a type it registers (objectui#11466): a node slot and + * the `schema` prop take the declared node types only. + */ +declare module '@object-ui/types' { + interface CustomNodeRegistry { + 'probe-7530': BaseSchema; + } +} + /** * NOTE (objectui#7912): `SchemaRendererProvider.dataSource` — and the context * it feeds — declare the published `DataSource` adapter contract. The values @@ -93,7 +104,7 @@ const Probe = (props: { disabled?: unknown }) => ( const DATA = { status: 'draft', published: false }; /** The DECLARED path -- `BaseSchema`, nothing wider, no cast. */ -function mount(schema: BaseSchema) { +function mount(schema: BaseSchema & { type: 'probe-7530' }) { return render( diff --git a/packages/react/src/__tests__/SchemaRenderer.primitiveSchema.test.tsx b/packages/react/src/__tests__/SchemaRenderer.primitiveSchema.test.tsx index b365e755f4..a3a6c88a50 100644 --- a/packages/react/src/__tests__/SchemaRenderer.primitiveSchema.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.primitiveSchema.test.tsx @@ -34,6 +34,19 @@ import { render } from '@testing-library/react'; import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '../SchemaRenderer'; +import { undeclaredNode } from '@object-ui/test-support'; +import type { BaseSchema } from '@object-ui/types'; + +/** + * This file's registered probe, declared to `@object-ui/types` the way an + * application declares a type it registers (objectui#11466): a node slot and + * the `schema` prop take the declared node types only. + */ +declare module '@object-ui/types' { + interface CustomNodeRegistry { + 'test-4548-div': BaseSchema; + } +} const originalWarn = console.warn; beforeEach(() => { @@ -87,7 +100,8 @@ describe('objectui#4548 — non-object schema values', () => { it('still shows the error box for an OBJECT whose type is unregistered', () => { // The unknown-component box is correct HERE — an object that names a type // nothing implements. Only the primitive case stopped producing it. - const { container } = render(); + // Deliberately undeclared: the type is the point (objectui#11466). + const { container } = render(); expect(container.textContent).toContain('Unknown component type'); expect(container.textContent).toContain('no-such-component-4548'); }); diff --git a/packages/react/src/__tests__/SchemaRenderer.propsResolution.test.ts b/packages/react/src/__tests__/SchemaRenderer.propsResolution.test.ts index 617e59cd99..5ce00402c5 100644 --- a/packages/react/src/__tests__/SchemaRenderer.propsResolution.test.ts +++ b/packages/react/src/__tests__/SchemaRenderer.propsResolution.test.ts @@ -55,7 +55,7 @@ import { describe, it, expect } from 'vitest'; import type { ComponentProps } from 'react'; -import type { AuthoringNode, BaseSchema } from '@object-ui/types'; +import type { BaseSchema, DeclaredNode } from '@object-ui/types'; import { SchemaRenderer, type SchemaRendererProps } from '../SchemaRenderer'; import { toRenderableSchema } from '../schema-input'; @@ -69,10 +69,15 @@ type CallSiteProps = ComponentProps; // 1. THE assertion. `schema` survives to the call site with its declared type. // Before the fix this was `any`, so every schema — and any prop typo next to // it — passed silently. objectui#11364 widened the object member by the -// referenced `AuthoringNode` types only (no index signature, no `Record`). +// referenced `AuthoringNode` types only (no index signature, no `Record`); +// objectui#11466 made it `DeclaredNode`, the union of the declared node +// types, with no `BaseSchema` (`type: string`) arm. type _SchemaIsDeclared = Assert< - Equal + Equal >; +// 1b. …and `BaseSchema` is no longer an arm of it (objectui#11466): a value typed +// `BaseSchema` names no declared type, so it does not reach the prop. +type _BaseSchemaIsNotAnArm = Assert>; // 2. …and that is not vacuously true because the whole thing is `any`. On the // pre-fix shape assertion 1 would have "passed" against `any` for any type diff --git a/packages/react/src/__tests__/SchemaRenderer.scopedStyleSchemaIdentity.test.tsx b/packages/react/src/__tests__/SchemaRenderer.scopedStyleSchemaIdentity.test.tsx index 47009d389c..a47c66b213 100644 --- a/packages/react/src/__tests__/SchemaRenderer.scopedStyleSchemaIdentity.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.scopedStyleSchemaIdentity.test.tsx @@ -42,6 +42,19 @@ import { SchemaRenderer } from '../SchemaRenderer'; import { PageVariablesProvider, usePageVariables } from '../hooks/usePageVariables'; import type { BaseSchema } from '@object-ui/types'; import type { PageComponent } from '@objectstack/spec/ui'; +import { undeclaredNode } from '@object-ui/test-support'; + +/** + * This file's registered probes, declared to `@object-ui/types` the way an + * application declares a type it registers (objectui#11466): a node slot and + * the `schema` prop take the declared node types only. + */ +declare module '@object-ui/types' { + interface CustomNodeRegistry { + 'identity-probe': ScopedStyleNode; + 'self-updating-probe': ScopedStyleNode; + } +} /** * A node authoring `responsiveStyles` inline (objectui#11349), typed by @@ -51,8 +64,12 @@ import type { PageComponent } from '@objectstack/spec/ui'; * prop accepts, so the key stays checked once objectui#8347 removes * `BaseSchema`'s index signature. */ -type ScopedStyleNode = BaseSchema & Pick; -const scopedStyleNode = (schema: ScopedStyleNode): ScopedStyleNode => schema; +type ScopedStyleNode = BaseSchema & Pick & { content?: string }; +/** The registered `IdentityProbe`'s node: every fixture below is one (objectui#11466). */ +type IdentityProbeNode = ScopedStyleNode & { type: 'identity-probe' }; +/** The registered `SelfUpdatingProbe`'s node. */ +type SelfUpdatingProbeNode = ScopedStyleNode & { type: 'self-updating-probe' }; +const scopedStyleNode = (schema: SelfUpdatingProbeNode): SelfUpdatingProbeNode => schema; /** One capture per probe render: the exact `schema` object it was handed. */ interface Capture { @@ -89,21 +106,23 @@ const distinctIdentities = (id: string): number => // Module-level fixtures: a stable `schema` prop identity is the precondition // for the `evaluatedSchema` memo to hold, which is what makes this a test of // the spread branch rather than of the memo above it. -const PLAIN = { type: 'identity-probe', id: 'plain', content: 'plain' }; -const SCOPED = { +const PLAIN: IdentityProbeNode = { type: 'identity-probe', id: 'plain', content: 'plain' }; +const SCOPED: IdentityProbeNode = { type: 'identity-probe', id: 'scoped', content: 'scoped', responsiveStyles: { large: { padding: '8px' } }, }; // The trap fixture: `base` is NOT one of the four sized keys, so this node -// does not take the scope-class branch at all. -const BASE_ONLY = { +// does not take the scope-class branch at all. The spec's `responsiveStyles` +// declares no `base` either, so the fixture is deliberately undeclared and +// crosses through the one test helper for that (objectui#11466). +const BASE_ONLY: IdentityProbeNode = undeclaredNode({ type: 'identity-probe', id: 'baseonly', content: 'base-only', responsiveStyles: { base: { padding: '8px' } }, -}; +}); /** * A real parent re-render: parent state changes, every child re-renders, and @@ -294,13 +313,13 @@ describe('SchemaRenderer scoped-style schema identity (objectui#6270)', () => { describe('delivers a NEW identity when something really changed (anti-staleness)', () => { it('a changed `className` on a scoped node', () => { - const before = { + const before: IdentityProbeNode = { type: 'identity-probe', id: 'scoped', className: 'text-sm', responsiveStyles: { large: { padding: '8px' } }, }; - const after = { + const after: IdentityProbeNode = { type: 'identity-probe', id: 'scoped', className: 'text-lg', @@ -318,13 +337,13 @@ describe('SchemaRenderer scoped-style schema identity (objectui#6270)', () => { }); it('a changed value on a scoped node', () => { - const before = { + const before: IdentityProbeNode = { type: 'identity-probe', id: 'scoped', content: 'first', responsiveStyles: { large: { padding: '8px' } }, }; - const after = { + const after: IdentityProbeNode = { type: 'identity-probe', id: 'scoped', content: 'second', @@ -342,12 +361,12 @@ describe('SchemaRenderer scoped-style schema identity (objectui#6270)', () => { }); it('a changed responsiveStyles value re-compiles and re-identifies', () => { - const before = { + const before: IdentityProbeNode = { type: 'identity-probe', id: 'scoped', responsiveStyles: { large: { padding: '8px' } }, }; - const after = { + const after: IdentityProbeNode = { type: 'identity-probe', id: 'scoped', responsiveStyles: { large: { padding: '24px' } }, @@ -366,7 +385,7 @@ describe('SchemaRenderer scoped-style schema identity (objectui#6270)', () => { // The strongest anti-staleness pin: the `schema` PROP identity never // changes here, so only a genuinely reactive evaluation can move the // delivered identity. A memo that froze on the prop would go stale. - const REACTIVE = { + const REACTIVE: IdentityProbeNode = { type: 'identity-probe', id: 'reactive', content: '${page.tick}', diff --git a/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx b/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx index be59c65c95..5eb2bb3e21 100644 --- a/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx @@ -34,6 +34,18 @@ import { UNEVALUATED_EXPRESSION_PREFIX, } from '../utils/unevaluatedExpression'; +/** + * This file's registered `TextKeyProbe` (namespace `test`), declared to + * `@object-ui/types` the way an application declares a type it registers + * (objectui#11466): a node slot and the `schema` prop take the declared node + * types only. + */ +declare module '@object-ui/types' { + interface CustomNodeRegistry { + 'test:probe-4795': TextKeyProbeNode; + } +} + const DATA = { n: 99, total: 99, label: 'Widgets' }; /** @@ -55,7 +67,7 @@ const TextKeyProbe = ({ schema }: any) => ( * the prop accepts, so its keys stay checked once objectui#8347 removes * `BaseSchema`'s index signature. */ -type TextKeyProbeNode = BaseSchema & { value?: string; title?: string }; +type TextKeyProbeNode = BaseSchema & { type: 'test:probe-4795'; value?: string; title?: string }; const textKeyProbeNode = (schema: TextKeyProbeNode): TextKeyProbeNode => schema; /** Reads only what it is spread, like a plain component. */ @@ -126,7 +138,7 @@ describe('SchemaRenderer — unevaluated `${…}` diagnostic (objectui#4795)', ( }); it('reports once per schema object, however many times it re-renders', () => { - const schema = { type: 'test:probe-4795', value: '${data.n}' }; + const schema: TextKeyProbeNode = { type: 'test:probe-4795', value: '${data.n}' }; const { rerender } = renderWithData(schema); rerender( diff --git a/packages/react/src/__tests__/SchemaRenderer.unknownTypeCaseSuggestion.test.tsx b/packages/react/src/__tests__/SchemaRenderer.unknownTypeCaseSuggestion.test.tsx index 697f280263..8018a5b256 100644 --- a/packages/react/src/__tests__/SchemaRenderer.unknownTypeCaseSuggestion.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.unknownTypeCaseSuggestion.test.tsx @@ -38,6 +38,10 @@ import { render } from '@testing-library/react'; import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '../SchemaRenderer'; +// Every type below is deliberately one no registration and no declaration names: +// the suggestion is the point, so each crosses through the one test helper for +// undeclared input (objectui#11466). +import { undeclaredNode } from '@object-ui/test-support'; /** Marker text that appears ONLY if the component actually rendered. */ const RENDERED_MARKER = 'the-component-actually-rendered'; @@ -65,7 +69,7 @@ describe('objectui#5247 — case-only miss on the OBJUI-001 panel', () => { // would make the two assertions below agree about nothing. expect(ComponentRegistry.getKnownTypes()).toContain('page'); - const { container } = render(); + const { container } = render(); const text = container.textContent ?? ''; // Half 1 — the ruled behaviour that must NOT change (this passes on a @@ -80,7 +84,7 @@ describe('objectui#5247 — case-only miss on the OBJUI-001 panel', () => { }); it('names the namespaced spelling too', () => { - const { container } = render(); + const { container } = render(); const text = container.textContent ?? ''; expect(text).not.toContain(RENDERED_MARKER); @@ -92,7 +96,7 @@ describe('objectui#5247 — case-only miss on the OBJUI-001 panel', () => { // The counter-probe for the "always suggests" failure mode. On its own it // is a phantom — it passes on a revert — so it is pinned in the same run as // the two cases above. - const { container } = render(); + const { container } = render(); const text = container.textContent ?? ''; expect(container.querySelector('[role="alert"]')).not.toBeNull(); @@ -103,7 +107,7 @@ describe('objectui#5247 — case-only miss on the OBJUI-001 panel', () => { it('does not reach for an edit distance — a typo that is not a case typo suggests nothing', () => { // `pge` is one deletion away from `page`. The ruling granted case, and only // case; a fuzzy match here would be scope it did not give. - const { container } = render(); + const { container } = render(); const text = container.textContent ?? ''; expect(container.querySelector('[role="alert"]')).not.toBeNull(); @@ -117,7 +121,7 @@ describe('objectui#5247 — case-only miss on the OBJUI-001 panel', () => { ComponentRegistry.unregister('page', 'test5247'); expect(ComponentRegistry.getKnownTypes()).not.toContain('page'); - const { container } = render(); + const { container } = render(); const text = container.textContent ?? ''; expect(container.querySelector('[role="alert"]')).not.toBeNull(); diff --git a/packages/react/src/__tests__/SchemaRenderer.validation.test.tsx b/packages/react/src/__tests__/SchemaRenderer.validation.test.tsx index 49bef1f89b..b96a83824a 100644 --- a/packages/react/src/__tests__/SchemaRenderer.validation.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.validation.test.tsx @@ -12,7 +12,19 @@ import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '../SchemaRenderer'; import { SchemaRendererProvider } from '../context/SchemaRendererContext'; -import type { DataSource } from '@object-ui/types'; +import type { BaseSchema, DataSource } from '@object-ui/types'; + +/** + * This file's registered hosts, declared to `@object-ui/types` the way an + * application declares a type it registers (objectui#11466): a node slot and + * the `schema` prop take the declared node types only. + */ +declare module '@object-ui/types' { + interface CustomNodeRegistry { + 'valid-host': BaseSchema; + 'host-wrap': BaseSchema; + } +} /** * NOTE (objectui#7912): `SchemaRendererProvider.dataSource` — and the context diff --git a/packages/react/src/__tests__/SchemaRendererProvider.smoke.test.tsx b/packages/react/src/__tests__/SchemaRendererProvider.smoke.test.tsx index f9f16a17e3..b495334ac8 100644 --- a/packages/react/src/__tests__/SchemaRendererProvider.smoke.test.tsx +++ b/packages/react/src/__tests__/SchemaRendererProvider.smoke.test.tsx @@ -21,7 +21,19 @@ import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '../SchemaRenderer'; import { SchemaRendererProvider, useSchemaContext } from '../context/SchemaRendererContext'; -import type { DataSource } from '@object-ui/types'; +import type { BaseSchema, DataSource } from '@object-ui/types'; +import { undeclaredNode } from '@object-ui/test-support'; + +/** + * This file's registered context consumer, declared to `@object-ui/types` the + * way an application declares a type it registers (objectui#11466): a node + * slot and the `schema` prop take the declared node types only. + */ +declare module '@object-ui/types' { + interface CustomNodeRegistry { + 'test-ctx-consumer': BaseSchema; + } +} /** * NOTE (objectui#7912): `SchemaRendererProvider.dataSource` — and the context @@ -170,10 +182,12 @@ describe('Plugin component types render inside provider', () => { return; } - // Render via SchemaRenderer inside provider + // Render via SchemaRenderer inside provider. A bare `{ type }` on purpose: + // the node carries none of its type's required props, so it crosses + // through the one test helper for undeclared input (objectui#11466). const { container } = render( - + ); diff --git a/packages/react/src/__tests__/schema-input.bridgeFidelity.test.tsx b/packages/react/src/__tests__/schema-input.bridgeFidelity.test.tsx index 3090679566..d88619aa3f 100644 --- a/packages/react/src/__tests__/schema-input.bridgeFidelity.test.tsx +++ b/packages/react/src/__tests__/schema-input.bridgeFidelity.test.tsx @@ -58,11 +58,22 @@ import { describe, it, expect } from 'vitest'; import { render } from '@testing-library/react'; import React from 'react'; -import type { BaseSchema, SchemaNode } from '@object-ui/types'; +import type { BaseSchema, DeclaredNode, SchemaNode } from '@object-ui/types'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '../SchemaRenderer'; import { toRenderableSchema } from '../schema-input'; +/** + * This file's registered probe (`PROBE_TYPE`), declared to `@object-ui/types` + * the way an application declares a type it registers (objectui#11466): a node + * slot and the `schema` prop take the declared node types only. + */ +declare module '@object-ui/types' { + interface CustomNodeRegistry { + 'test:schema-input-bridge-8908': BaseSchema; + } +} + /* The `Assert< … >` alias below IS the assertion — it is deliberately never * referenced, and a violation is a COMPILE error rather than a use site. */ type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; @@ -72,21 +83,21 @@ const PROBE_TYPE = 'test:schema-input-bridge-8908'; ComponentRegistry.register(PROBE_TYPE, () => probe-node); /** One arm per member of the `SchemaNode` union, by name. */ -type MemberName = 'BaseSchema' | 'string' | 'number' | 'boolean' | 'null' | 'undefined'; +type MemberName = 'DeclaredNode' | 'string' | 'number' | 'boolean' | 'null' | 'undefined'; /** * The completeness pin. `MemberName` above is a hand-written list; this ties it * to the DECLARATION, so the list cannot go stale silently. */ type _MembersAreExactlyCovered = Assert< - Equal + Equal >; /** `renders` is the text `SchemaRenderer` puts in the DOM for this value. */ type Case = { label: string; value: SchemaNode; renders: string }; const MEMBER_CASES: Record = { - BaseSchema: [{ label: 'an object node', value: { type: PROBE_TYPE }, renders: 'probe-node' }], + DeclaredNode: [{ label: 'an object node', value: { type: PROBE_TYPE }, renders: 'probe-node' }], string: [ { label: "'' (empty)", value: '', renders: '' }, { label: "'txt'", value: 'txt', renders: 'txt' }, @@ -186,7 +197,7 @@ describe('objectui#8908 — the two legs of the mapping, at the bridge itself', }); it('returns every other member by identity — it converts nothing else', () => { - const node: BaseSchema = { type: PROBE_TYPE }; + const node: DeclaredNode = { type: PROBE_TYPE }; expect(toRenderableSchema(node)).toBe(node); expect(toRenderableSchema('txt')).toBe('txt'); expect(toRenderableSchema('')).toBe(''); diff --git a/packages/test-support/src/index.ts b/packages/test-support/src/index.ts index 27b38c9041..98f909a593 100644 --- a/packages/test-support/src/index.ts +++ b/packages/test-support/src/index.ts @@ -109,3 +109,10 @@ export type { LocaleCall } from './locale-tripwire'; * one is declared in `exports`, so `tsc` (moduleResolution `bundler`) resolves it * exactly as it resolves `.`. */ + +/** + * The one crossing for deliberately undeclared node input in a test + * (objectui#11466): see the module's docblock for what may, and may not, go + * through it. + */ +export { undeclaredNode } from './undeclared-node'; diff --git a/packages/test-support/src/undeclared-node.ts b/packages/test-support/src/undeclared-node.ts new file mode 100644 index 0000000000..80f4327175 --- /dev/null +++ b/packages/test-support/src/undeclared-node.ts @@ -0,0 +1,36 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * THE ONE CROSSING for deliberately undeclared node input in a test + * (objectui#11466). + * + * A node slot and `SchemaRenderer`'s `schema` prop take `DeclaredNode` from + * `@object-ui/types`: the union of the declared node types, with no + * `type: string` arm. Some tests pin what the RUNTIME does with input that no + * declaration covers, on purpose: an unknown or retired `type`, a spelling the + * contract refuses, a host value merged into a node. Such a test still has to + * hand that input over, and this is the one place it crosses. The return type + * is inferred from where the value goes, so a call reads as what it is: + * + * ```tsx + * + * ``` + * + * ⛔ Not for a node of a type the test registers with `ComponentRegistry`: + * declare that type in `CustomNodeRegistry` (`@object-ui/types`), the way an + * application declares its own types. ⛔ Not for a fixture that is a declared + * node: annotate it with its declared type. ⛔ Never from source under `src/` + * outside a test; this package is never published. + * + * It takes no `@object-ui/types` import on purpose: that package depends on + * this one for its own tests, so the target type comes from the call site. + */ +export function undeclaredNode(node: object): Target { + return node as unknown as Target; +} diff --git a/packages/types/src/__tests__/authoring-nodes-11364.test.ts b/packages/types/src/__tests__/authoring-nodes-11364.test.ts index 4e007d3c21..a21b3ceb18 100644 --- a/packages/types/src/__tests__/authoring-nodes-11364.test.ts +++ b/packages/types/src/__tests__/authoring-nodes-11364.test.ts @@ -28,6 +28,13 @@ * keeps running after the removal lands. `@object-ui/react` pins the prop's * union exactly (`SchemaRenderer.propsResolution.test.ts`, assertion 1), and * this file substitutes only its `BaseSchema` member. + * + * ⚠️ Dated note, 2026-10-02 (objectui#11466): the prop's object member is now + * `DeclaredNode`, which has no `BaseSchema` arm, so `PostRemovalSchema` is no + * longer the prop with one member substituted. It is kept as the model this + * file's assertions were written against: every `AuthoringNode` still fits it, + * and its refusals still hold. The current prop and slot are pinned in + * `node-slot-union-11466.test.ts`. */ import { describe, expect, it } from 'vitest'; diff --git a/packages/types/src/__tests__/node-recursion-point-8344.test.ts b/packages/types/src/__tests__/node-recursion-point-8344.test.ts index 36da1fdac1..e8ae17285b 100644 --- a/packages/types/src/__tests__/node-recursion-point-8344.test.ts +++ b/packages/types/src/__tests__/node-recursion-point-8344.test.ts @@ -52,6 +52,7 @@ import { describe, it, expect } from 'vitest'; import { AnyComponentSchema, CardSchema, IconSchema, SchemaNodeSchema, safeValidateSchema } from '../zod/index.zod.js'; +import type { RetiredKanbanNodeSchema } from '../zod/complex.zod.js'; import type { SchemaNode } from '../base.js'; import type { z } from 'zod'; @@ -219,13 +220,53 @@ describe('the late-binding wiring, read by IDENTITY on the exported wrapper', () * which reads as drift where there is none. The guard answers the empty case first and * leaves the projection to do exactly what it did before: NAME the arm when there is one. */ -type ArmsNotAssignableToSchemaNode = - [Exclude< z.output< typeof AnyComponentSchema >, SchemaNode >] extends [never] - ? never - : Exclude< z.output< typeof AnyComponentSchema >, SchemaNode > extends { type: infer K } ? K : never; +type ArmsNotAssignableToSchemaNode = Exclude< z.output< typeof AnyComponentSchema >, SchemaNode >; + +/** + * ⚠️ Dated note, 2026-10-02 (objectui#11466). The bound above was `never` while `SchemaNode`'s + * object arm was `BaseSchema`, whose `type: string` and index signature admitted every arm's + * output, drifted or not. objectui#11466 made that arm `DeclaredNode`, the union of the + * declared node types, so this reads the real exclusion set for the first time. Measured at + * that change, nine `type` literals and one arm typed `string`, each a DECLARATION matter + * between the two faces that the `BaseSchema` arm hid, ⛔ not a contract: + * + * - `app`: the zod arm is the app-level document (`AppComponentSchema`'s mirror), while a + * node slot's `app` is the page KIND `ComponentRegistry` renders (`DeclaredNode` leaves + * the app document out, objectui#11466). Two vocabularies on one token; `./registry.ts` + * names them at its `'page'` entry. + * - `record`, `home`, `utility`: the zod page-kind arm (objectui#11440) leaves `name` and + * `label` optional; the TypeScript `PageDocumentNode` is the spec's `PageSchema` input, + * which requires them. + * - `page`: `PageNodeSchema`'s `slots` differ between the faces. + * - `dashboard`: the widget slot's component arm differs between the faces. + * - `detail-section`, `app-schema-renderer`, `cloud:plan-status`: zod arms with no + * TypeScript declaration at all, so no node slot admits them. + * - the arm typed `string`: the retired `kanban` tombstone (`RetiredKanbanNodeSchema`), + * which parses nothing; its output type is not a node. + * + * Each is reported on objectui#11466 for its own card. The list is written out so this stays a + * ratchet in BOTH directions: a new arm drifting turns it red, and so does a listed one being + * reconciled, which then deletes its name here. ⛔ Do not add a name to make a red go away. + * The `[…] extends [never]` guard the paragraph above describes belonged to the + * non-distributive projection this replaced: `LiteralArmType` and `WideArm` distribute over + * the arms, so an empty exclusion set answers `never` by construction. + */ +type LiteralArmType< Arm > = Arm extends { type: infer K } ? (string extends K ? never : K) : never; +type WideArm< Arm > = Arm extends { type: infer K } ? (string extends K ? Arm : never) : never; +type MeasuredArmDrift11466 = + | 'app' + | 'record' + | 'home' + | 'utility' + | 'page' + | 'dashboard' + | 'detail-section' + | 'app-schema-renderer' + | 'cloud:plan-status'; export type NodeRecursionPointDeclarationDrift = [ - Expect< Equal< ArmsNotAssignableToSchemaNode, never > >, + Expect< Equal< LiteralArmType< ArmsNotAssignableToSchemaNode >, MeasuredArmDrift11466 > >, + Expect< Equal< WideArm< ArmsNotAssignableToSchemaNode >, z.output< typeof RetiredKanbanNodeSchema > > >, ]; diff --git a/packages/types/src/__tests__/node-slot-union-11466.test.ts b/packages/types/src/__tests__/node-slot-union-11466.test.ts new file mode 100644 index 0000000000..adafee6843 --- /dev/null +++ b/packages/types/src/__tests__/node-slot-union-11466.test.ts @@ -0,0 +1,122 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11466: a node slot and `SchemaRenderer`'s `schema` prop take + * `DeclaredNode`, the discriminated union of the declared node types, pinned. + * + * Most assertions here are TYPE-level: the `Assert` aliases and the + * `@ts-expect-error` directives are judged by this package's `type-check` (its + * `tsc -p tsconfig.test.json` leg), never by vitest, which strips types. A + * directive that stops being needed is TS2578, so each one holds in both + * directions. + * + * ## The compile-fail pin, and why it does not wait for objectui#8347 + * + * Until objectui#8347 removes `BaseSchema`'s index signature, a misspelled key on + * a node type that extends `BaseSchema` still compiles: the signature absorbs it + * on the type's own arm. What this card changes is the SLOT: it discriminates on + * `type`, so an inline child is judged against its own type's arm wherever it is + * nested. `ClosedProbeNode` below is a node type declared WITHOUT an index + * signature, the shape every arm has once the removal lands, registered through + * `CustomNodeRegistry`. Its misspelling is refused at a nested slot today, so the + * pin runs in every type-check now and keeps running after the removal. The + * spec-derived `AuthoringNode`s carry no signature either and are pinned the + * same way. + */ + +import { describe, expect, it } from 'vitest'; +import type { AppComponentSchema } from '../app.js'; +import type { AuthoringNode, PageDocumentNode } from '../authoring-nodes.js'; +import type { BaseSchema, CustomNodeRegistry, DeclaredNode, SchemaNode } from '../base.js'; +import type { ListSchema } from '../data-display.js'; +import type { AnySchema, SchemaByType } from '../index.js'; + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +/** A node type with no index signature: the shape every arm takes after objectui#8347. */ +interface ClosedProbeNode { + type: 'test:closed-probe-11466'; + id?: string; + label?: string; + probeOnly?: string; +} + +/** A LOOSE entry: `BaseSchema` itself, to pin that an entry joins under its key only. */ +type LooseProbeNode = BaseSchema; + +declare module '../base.js' { + interface CustomNodeRegistry { + 'test:closed-probe-11466': ClosedProbeNode; + 'test:loose-probe-11466': LooseProbeNode; + } +} + +/** What `SchemaRendererProps['schema']` is (`@object-ui/react` pins the prop itself). */ +type PropSchema = DeclaredNode | string | null | undefined; + +/** The members a `type === K` check narrows a `DeclaredNode` to. */ +type NarrowOn = T extends { type: infer D } ? (K extends D ? T : never) : never; + +// 1. `SchemaNode` is a declared node or a primitive rendered as content. +type _SchemaNodeShape = Assert>; + +// 2. No `type: string` arm: `BaseSchema` is not a member, and no member's `type` is `string`. +type _BaseSchemaIsNotAnArm = Assert>; +type WideTyped = U extends { type: infer K } ? (string extends K ? U : never) : never; +type _NoWideArm = Assert, never>>; + +// 3. Every component schema `AnySchema` declares is a member, except its `BaseSchema` arm and +// the app-level document (`AppComponentSchema`, read structurally and never dispatched). +type _AnySchemaArmsAreMembers = Assert extends DeclaredNode ? true : false>; +type _AppDocumentIsNotANode = Assert, never>>; +// …and every spec-declared `AuthoringNode`. +type _AuthoringNodesAreMembers = Assert; + +// 4. One arm per shared token (H4 of the dispatch). `app` is the page KIND the registry +// renders; `list` is the `list` node, because a `list` page never reaches `SchemaRenderer`. +// `SchemaByType` reads `AnySchema` and does not move. +type _AppNarrowsToThePageKind = Assert, PageDocumentNode>>; +type _ListNarrowsToTheListNode = Assert, ListSchema>>; +type _SchemaByTypeApp = Assert, AppComponentSchema>>; +type _SchemaByTypeList = Assert, ListSchema>>; + +// 5. A registry entry joins under its KEY: the arm is the entry intersected with +// `{ type: KEY }`, so even a loose `BaseSchema` entry adds no `type: string` arm. +type _LooseEntryJoinsUnderItsKey = Assert< + Equal, CustomNodeRegistry['test:loose-probe-11466'] & { type: 'test:loose-probe-11466' }> +>; + +describe('objectui#11466: a node slot takes the declared-node union', () => { + it('a nested child is judged against its own type: a misspelled key is refused, the declared spelling compiles', () => { + const spelled: SchemaNode = { type: 'card', children: [{ type: 'test:closed-probe-11466', label: 'x' }] }; + // @ts-expect-error `lable` is not a member of `ClosedProbeNode`, and the slot discriminates on `type` + const misspelled: SchemaNode = { type: 'card', children: [{ type: 'test:closed-probe-11466', lable: 'x' }] }; + // @ts-expect-error the same at a deeper slot + const deeper: SchemaNode = { type: 'card', children: [{ type: 'grid', children: [{ type: 'test:closed-probe-11466', lable: 'x' }] }] }; + // @ts-expect-error a spec-derived node is closed too: `contnt` is not in `element:text`'s bag + const bag: SchemaNode = { type: 'card', children: [{ type: 'element:text', properties: { contnt: 'x' } }] }; + expect([spelled, misspelled, deeper, bag]).toHaveLength(4); + }); + + it('an undeclared `type` is refused at a slot and at the prop', () => { + // @ts-expect-error `txt` names no declared node type and no registry entry + const nested: SchemaNode = { type: 'card', children: [{ type: 'txt' }] }; + // @ts-expect-error the same at `SchemaRenderer`'s prop + const atProp: PropSchema = { type: 'txt' }; + const registered: PropSchema = { type: 'test:closed-probe-11466', label: 'x' }; + expect([nested, atProp, registered]).toHaveLength(3); + }); + + it("a registered type's key does not escape onto a declared type", () => { + // @ts-expect-error `probeOnly` is `ClosedProbeNode`'s, not `element:text`'s + const escaped: SchemaNode = { type: 'card', children: [{ type: 'element:text', probeOnly: 'x', properties: { content: 'y' } }] }; + expect([escaped]).toHaveLength(1); + }); +}); diff --git a/packages/types/src/__tests__/overlay-trigger-union-7081.test.ts b/packages/types/src/__tests__/overlay-trigger-union-7081.test.ts index ce4bfeda8e..37c9021dee 100644 --- a/packages/types/src/__tests__/overlay-trigger-union-7081.test.ts +++ b/packages/types/src/__tests__/overlay-trigger-union-7081.test.ts @@ -71,7 +71,7 @@ import { fileURLToPath } from 'node:url'; // @ts-expect-error -- plain-JS shared helper, intentionally untyped (`allowJs: false`) import { stripComments as strip } from '../../../../scripts/js-comment-mask.mjs'; -import type { BaseSchema, SchemaNode } from '../base'; +import type { BaseSchema, DeclaredNode, SchemaNode } from '../base'; import type { CollapsibleSchema } from '../disclosure'; import type { ButtonSchema } from '../form'; import type { TextSchema } from '../layout'; @@ -153,10 +153,12 @@ export type _DropdownMenuRequired = Expect>; export type _ContextMenuTrigger = Expect>; -// Counter-control: `AdmitsArray` can say `false`, and `SchemaNode` itself did -// not move -- the widening is per member, not on the node type. +// Counter-control: `AdmitsArray` can say `false`, and the widening is per member, +// not on the node type: `SchemaNode` is one node or a primitive, never a list. +// (Its object arm became `DeclaredNode`, the declared-node union, under +// objectui#11466; that change is not this widening.) export type _SchemaNodeUntouched = Expect< - Equal + Equal >; interface SingularSlot { trigger: SchemaNode; diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index ff0b993d47..3f14325a92 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -617,8 +617,13 @@ * spelled "six" rots exactly as fast as one spelled `6`, it is just harder to * point a regex at. ⛔ Do not spell a live figure out again, and ⛔ do not * restate one without checking that the pin's spelling still reaches it. - * - **3 entries** in `WiderThanDeclared`, **3 keys** across them, and **3 arms** - * under those keys — split **2** SCHEMA-NODE, **1** CONCRETE, **0** MIXED, **0** unions. + * - **5 entries** in `WiderThanDeclared`, **5 keys** across them, and **7 arms** + * under those keys — split **2** SCHEMA-NODE, **3** CONCRETE, **0** MIXED, **2** unions. + * It read 3 / 3 / 3 — 2 / 1 / 0 / 0 — until objectui#11466 typed `SchemaNode`'s object + * arm as the declared-node union: `complex.zod.ts#DashboardWidgetSchema`'s `component` + * (two CONCRETE arms, the mirror's `BaseSchema` fallback admitting any `type`) and, + * through the element, `complex.zod.ts#DashboardComponentSchema`'s `widgets` (two + * CONCRETE arms) ENTERED together. * It read 4 / 4 / 4 — 2 / 2 / 0 / 0 — until objectui#11168 slice 3 (objectui#7759 * group C) narrowed `form.zod.ts#FormSchema`'s `layout` to the `vertical` / * `horizontal` its declaration states: `@objectstack/spec` 17.5.0 retired `grid`, and @@ -3903,6 +3908,25 @@ interface WiderThanDeclared { * pair measures clean and this entry would be STALE if it stayed. */ 'app.zod.ts#AppComponentSchema': 'areas'; + /** + * CONCRETE `widgets` (objectui#11466), RE-ENTERED after the history in the comment + * below, and inherited through the element: the widget arm's `component` slot, the + * `complex.zod.ts#DashboardWidgetSchema` entry next to it. Both arms of the element + * state a shape an author writes, so both verdicts are CONCRETE. + */ + 'complex.zod.ts#DashboardComponentSchema': 'widgets'; + /** + * CONCRETE `component` (objectui#11466). A widget's `component` slot is + * `DashboardWidgetSlotComponentSchema | SchemaNode` on the declaration, and + * `SchemaNode`'s object arm became `DeclaredNode`, the union of the declared node types + * with no `type: string` arm. The mirror's slot still ends in the `BaseSchema` fallback + * (`z.union([DashboardWidgetSlotComponentSchema, BaseSchema])`), which admits any `type` + * string, so it accepts a `component` node the declaration now refuses. That fallback is + * objectui#8347's to retire with the index signature, ⛔ not this ledger's: narrowing it + * here is the mirror change that card reserves. When it goes, this entry and the + * `widgets` one above measure clean and are deleted (clause 4 above). + */ + 'complex.zod.ts#DashboardWidgetSchema': 'component'; // `complex.zod.ts#ChatbotSchema` recorded `body` here (CONCRETE, and DISJOINT rather // than strictly wider — the pair carried a `KnownDrift` entry for the same key, one of // the measured cases where each face refuses something the other admits: the mirror @@ -4130,6 +4154,13 @@ const WIDER_ARM_ROW_SEPARATOR = '::'; const WIDER_ARMS: Readonly< Record< string, readonly WiderArmClass[] > > = { 'app.zod.ts#AppComponentSchema::areas': ['SCHEMA-NODE'], + // objectui#11466: the element's two arms, the component node and the widget, each a + // shape an author writes. The widget arm is the one that reads wider, through its + // `component` slot's `BaseSchema` fallback (the next row). + 'complex.zod.ts#DashboardComponentSchema::widgets': ['CONCRETE', 'CONCRETE'], + // objectui#11466: the slot's two arms, the widget slot's component arm and the + // `BaseSchema` fallback, which admits any `type` string. + 'complex.zod.ts#DashboardWidgetSchema::component': ['CONCRETE', 'CONCRETE'], 'layout.zod.ts#PageNodeSchema::slots': ['SCHEMA-NODE'], 'objectql.zod.ts#ObjectGridSchema::objectName': ['CONCRETE'], }; From 4aad4742eb7c6c26c24190066e62c8697c525c25 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:45:59 +0000 Subject: [PATCH 05/16] docs: examples name the declared node type; a registered custom type is declared in CustomNodeRegistry (objectui#11466) - Examples that held a node as `BaseSchema` or as an unannotated literal name `DeclaredNode` (the schema-rendering, expressions, architecture, data-source and runner guides; the root, components, data-objectstack and react READMEs; the blocks index). - `schema-renderer.mdx`'s custom widget declares `MyWidgetSchema` in `CustomNodeRegistry` beside its `ComponentRegistry.register` call. - `data-source.md` teaches a node that reads `objectName` (`object-grid`, its props in the bag) instead of `table`. - `plugin-detail`'s related-list example writes its props in the `properties` bag; the types README's `data-table` carries its required `columns`. - The schema reference, the types README and the react README describe `SchemaNode`, `DeclaredNode` and `CustomNodeRegistry` as they now are. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- README.md | 7 ++-- content/docs/api/schema-reference.md | 44 ++++++++++++++++++++------ content/docs/blocks/index.mdx | 6 ++-- content/docs/core/schema-renderer.mdx | 15 +++++++++ content/docs/guide/architecture.md | 4 +-- content/docs/guide/data-source.md | 6 ++-- content/docs/guide/expressions.md | 4 +-- content/docs/guide/schema-rendering.md | 8 ++--- content/docs/utilities/runner.mdx | 4 +-- packages/components/README.md | 3 +- packages/data-objectstack/README.md | 4 +-- packages/plugin-detail/README.md | 10 +++--- packages/react/README.md | 23 ++++++++------ packages/types/README.md | 6 ++-- 14 files changed, 100 insertions(+), 44 deletions(-) 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 80f1df9000..ce3d7aba76 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,13 +36,36 @@ 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` is declared like this: + +```typescript +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; + } +} ``` ### BaseSchema 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/data-objectstack/README.md b/packages/data-objectstack/README.md index 9b409e9cb1..dea28ca017 100644 --- a/packages/data-objectstack/README.md +++ b/packages/data-objectstack/README.md @@ -23,9 +23,9 @@ npm install @object-ui/data-objectstack ```typescript import { createObjectStackAdapter } from '@object-ui/data-objectstack'; import { SchemaRenderer } from '@object-ui/react'; -import type { BaseSchema } from '@object-ui/types'; +import type { DeclaredNode } from '@object-ui/types'; -declare const mySchema: BaseSchema; +declare const mySchema: DeclaredNode; // 1. Create the adapter const dataSource = createObjectStackAdapter({ diff --git a/packages/plugin-detail/README.md b/packages/plugin-detail/README.md index 5cd1312084..c46fa36726 100644 --- a/packages/plugin-detail/README.md +++ b/packages/plugin-detail/README.md @@ -411,10 +411,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/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 Date: Fri, 2 Oct 2026 20:48:32 +0000 Subject: [PATCH 06/16] chore(changeset): objectui#11466, and dated notes on seven pending entries its change falsifies One changeset (types, react, core minor with the breaking meaning stated; components patch). Dated, append-only notes on the pending entries whose standing reading of `SchemaNode`, the prop union, the bridge's types or the node-recursion pin this change makes false: 11364, 8331, 8908, 4622, 7767, 11479, 8572. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .changeset/11364-public-block-ts-faces.md | 2 + .changeset/11466-node-slot-union.md | 44 +++++++++++++++++++ .changeset/11479-producers-declared-types.md | 2 + .changeset/7767-collapsible-trigger-union.md | 2 + ...-data-table-empty-action-primitive-node.md | 2 + .changeset/8572-chatbot-body-retired.md | 2 + .changeset/8908-bridge-falsy-primitives.md | 2 + .../schema-input-bridge-permanent-4622.md | 2 + 8 files changed, 58 insertions(+) create mode 100644 .changeset/11466-node-slot-union.md 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-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/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/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. From c6ccffb7be56eab3385e377299d4b8688d9ebbea Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:33:41 +0000 Subject: [PATCH 07/16] fix(types,docs): DeclaredNode leaves the app document out by its `type`; two source pins and the schema reference follow (objectui#11466) - `DeclaredNode` excludes the app-level document as `Exclude` rather than by name, so `base.ts` does not name that interface (the `AppComponentSchema.actions[]` reader census in handler-keys-string-any-mirrors-7344 keys on the name). - page-body-arity-8310 reads the root README's typed example (`const schema: DeclaredNode = {`); overlay-node-slot-doc-types-7082 reads `SchemaNode`'s new spelling in `base.ts`. - The schema reference points at the SchemaRenderer page's registration walkthrough instead of a placeholder `my-widget` block. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- content/docs/api/schema-reference.md | 21 +++++-------------- .../overlay-node-slot-doc-types-7082.test.ts | 5 +++-- .../__tests__/page-body-arity-8310.test.ts | 10 +++++---- packages/types/src/base.ts | 19 ++++++++++------- 4 files changed, 25 insertions(+), 30 deletions(-) diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index ce3d7aba76..14a9fcc4fd 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -51,22 +51,11 @@ 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` is declared like this: - -```typescript -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; - } -} -``` +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 diff --git a/packages/types/src/__tests__/overlay-node-slot-doc-types-7082.test.ts b/packages/types/src/__tests__/overlay-node-slot-doc-types-7082.test.ts index 3415d8d5b4..77c22c2009 100644 --- a/packages/types/src/__tests__/overlay-node-slot-doc-types-7082.test.ts +++ b/packages/types/src/__tests__/overlay-node-slot-doc-types-7082.test.ts @@ -352,9 +352,10 @@ describe('six overlay/feedback pages name node slots at the declared type (objec // Control: the file is still there and still readable, so the absence above // is a reading about its CONTENT and not about a failed read. expect(blocks).toContain('@module blocks'); - // And `SchemaNode` is the slot type these keys actually carry. + // And `SchemaNode` is the slot type these keys actually carry. Its object arm + // is `DeclaredNode`, the declared-node union, since objectui#11466. expect(read('packages/types/src/base.ts')).toContain( - 'export type SchemaNode = BaseSchema | string | number | boolean | null | undefined;', + 'export type SchemaNode = DeclaredNode | string | number | boolean | null | undefined;', ); }); }); diff --git a/packages/types/src/__tests__/page-body-arity-8310.test.ts b/packages/types/src/__tests__/page-body-arity-8310.test.ts index 1b9e13a445..9e0f045175 100644 --- a/packages/types/src/__tests__/page-body-arity-8310.test.ts +++ b/packages/types/src/__tests__/page-body-arity-8310.test.ts @@ -282,14 +282,16 @@ describe('`body` is refused by name on the page node (objectui#6771)', () => { /* -------------------------------------------------------------------------- */ /** - * The `const schema = {` 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 `children[]` cannot end the span early. + * 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 `children[]` cannot end the span + * early. The annotation is part of the anchor since objectui#11466, which + * typed the example as the node the `schema` prop takes. */ function basicUsageSchemaLiteral(): string { const heading = README.indexOf('#### Basic Usage'); expect(heading).toBeGreaterThan(-1); - const start = README.indexOf('const schema = {', heading); + const start = README.indexOf('const schema: DeclaredNode = {', heading); expect(start).toBeGreaterThan(-1); let depth = 0; diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index 4e8bb0f371..c0d9ad447f 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -17,7 +17,6 @@ */ import type { I18nLabel } from '@objectstack/spec/ui'; -import type { AppComponentSchema } from './app.js'; import type { AuthoringNode } from './authoring-nodes.js'; import type { ExpressionWire } from './expression.js'; import type { AnySchema } from './index.js'; @@ -660,8 +659,8 @@ type LiteralTypedArm = T extends { type: infer K } ? (string extends K ? neve * It is the discriminated union, keyed by the literal `type`, of * * - every component schema this package declares (`AnySchema` without its - * `BaseSchema` arm, whose `type` is `string`, and without - * `AppComponentSchema`, below); + * `BaseSchema` arm, whose `type` is `string`, and without the app-level + * document, below); * - every spec-declared node `AuthoringNode` types by reference; * - every custom type an application declares in {@link CustomNodeRegistry}. * @@ -672,10 +671,14 @@ type LiteralTypedArm = T extends { type: infer K } ? (string extends K ? neve * to the compiler is not a `DeclaredNode`; narrow it to one, or declare its * type in {@link CustomNodeRegistry}. * - * ⛔ `AppComponentSchema` is not a node. It is the app-level document - * (`app.json`: navigation, areas), which `AppSchemaRenderer` - * (`@object-ui/layout`) reads structurally and `ComponentRegistry` never - * dispatches. The registry's `app` key serves the page KIND `app`, so as a + * ⛔ The app-level document (`./app.ts`, the `app.json` shape: navigation, + * areas, the `AnySchema` member whose `type` is `'app'`) is not a node: + * `AppSchemaRenderer` (`@object-ui/layout`) reads it structurally and + * `ComponentRegistry` never dispatches it. It is left out by that `type` + * (`Exclude`), which removes it alone, because + * no other `AnySchema` member's `type` is `'app'` (the `'app'` pin in + * `__tests__/node-slot-union-11466.test.ts` holds that). The registry's + * `app` key serves the page KIND `app`, so as a * node `type: 'app'` is a stored page document, `AuthoringNode`'s * `PageDocumentNode`. Kept out, `app` is one arm here, not two that would let * each other's keys through (`./registry.ts` names the two vocabularies at its @@ -683,7 +686,7 @@ type LiteralTypedArm = T extends { type: infer K } ? (string extends K ? neve * `app-schema-renderer`. */ export type DeclaredNode = - | LiteralTypedArm> + | LiteralTypedArm> | AuthoringNode | RegisteredCustomNode; From cd98e0f8f5cdfed3baef317a10c4732959f6f53b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:36:44 +0000 Subject: [PATCH 08/16] test(components): page-body-single-node-8310 reads the root README's typed example (objectui#11466) Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../src/__tests__/page-body-single-node-8310.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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; From 9a491a0a1222061d7f3bbb34a9fecd74247d0ab1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:39:38 +0000 Subject: [PATCH 09/16] test(types): overlay-trigger-union-7081 drops the import its pin no longer uses (objectui#11466) Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../types/src/__tests__/overlay-trigger-union-7081.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/types/src/__tests__/overlay-trigger-union-7081.test.ts b/packages/types/src/__tests__/overlay-trigger-union-7081.test.ts index 37c9021dee..4ba16fdde7 100644 --- a/packages/types/src/__tests__/overlay-trigger-union-7081.test.ts +++ b/packages/types/src/__tests__/overlay-trigger-union-7081.test.ts @@ -71,7 +71,7 @@ import { fileURLToPath } from 'node:url'; // @ts-expect-error -- plain-JS shared helper, intentionally untyped (`allowJs: false`) import { stripComments as strip } from '../../../../scripts/js-comment-mask.mjs'; -import type { BaseSchema, DeclaredNode, SchemaNode } from '../base'; +import type { DeclaredNode, SchemaNode } from '../base'; import type { CollapsibleSchema } from '../disclosure'; import type { ButtonSchema } from '../form'; import type { TextSchema } from '../layout'; @@ -186,7 +186,8 @@ export const singularStaysSingular: SingularSlot = { // and so does `shippedCollapsible` below (TS2322). // // The redundant `string |` half went with the widening. `_SchemaNodeUntouched` -// above pins `SchemaNode` as `BaseSchema | string | ...`, and the leg below +// above pins `SchemaNode` as `DeclaredNode | string | ...` (its object arm was +// `BaseSchema` until objectui#11466), and the leg below // spells the consequence out: `string | SchemaNode` denoted `SchemaNode` all // along, so dropping it moved nothing. export type _CollapsibleTrigger = Expect>; From ff7f748ecd209bc6c9948ae8adf6edb2f2d1bf9e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:57:03 +0000 Subject: [PATCH 10/16] chore(changeset): dated notes on two pending entries that read the prop as `BaseSchema` (objectui#11466) 8310-page-node-body-arity and 11354-plugin-form-literals each said, in the present tense, that `SchemaRenderer`'s `schema` prop is typed `BaseSchema`. After this change it is `DeclaredNode | string | null | undefined`. Each entry gets an append-only dated note; no existing line moves. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .changeset/11354-plugin-form-literals.md | 2 ++ .changeset/8310-page-node-body-arity.md | 2 ++ 2 files changed, 4 insertions(+) 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/8310-page-node-body-arity.md b/.changeset/8310-page-node-body-arity.md index 3523667b1c..b93e557ebe 100644 --- a/.changeset/8310-page-node-body-arity.md +++ b/.changeset/8310-page-node-body-arity.md @@ -34,3 +34,5 @@ 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. From b13093d6f1472492c48ccb60335ddb96d4151f99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:11:42 +0000 Subject: [PATCH 11/16] test(types): MeasuredArmDrift11466 drops the three arms objectui#11515 twinned (objectui#11466) objectui#11515 gave detail-section, app-schema-renderer and cloud:plan-status their TypeScript twins and joined them to AnySchema, so their zod output is assignable to SchemaNode and the two-way ratchet went red on the merged tree (TS2344 at NodeRecursionPointDeclarationDrift). The three names leave the measured set; a dated note records why, and the first measurement's bullet is kept as that change's reading. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../src/__tests__/node-recursion-point-8344.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/types/src/__tests__/node-recursion-point-8344.test.ts b/packages/types/src/__tests__/node-recursion-point-8344.test.ts index e8ae17285b..470bbe4643 100644 --- a/packages/types/src/__tests__/node-recursion-point-8344.test.ts +++ b/packages/types/src/__tests__/node-recursion-point-8344.test.ts @@ -250,6 +250,12 @@ type ArmsNotAssignableToSchemaNode = Exclude< z.output< typeof AnyComponentSchem * The `[…] extends [never]` guard the paragraph above describes belonged to the * non-distributive projection this replaced: `LiteralArmType` and `WideArm` distribute over * the arms, so an empty exclusion set answers `never` by construction. + * + * ⚠️ Dated note, 2026-10-03 (objectui#11466, on merging objectui#11515). Reconciled, so their + * names left the list below: `detail-section`, `app-schema-renderer` and `cloud:plan-status`. + * objectui#11515 gave each its TypeScript twin and joined it to `AnySchema`, so a node slot + * admits them and their zod output is assignable. The bullet above that names them is kept as + * the reading at objectui#11466's first measurement. */ type LiteralArmType< Arm > = Arm extends { type: infer K } ? (string extends K ? never : K) : never; type WideArm< Arm > = Arm extends { type: infer K } ? (string extends K ? Arm : never) : never; @@ -259,10 +265,7 @@ type MeasuredArmDrift11466 = | 'home' | 'utility' | 'page' - | 'dashboard' - | 'detail-section' - | 'app-schema-renderer' - | 'cloud:plan-status'; + | 'dashboard'; export type NodeRecursionPointDeclarationDrift = [ Expect< Equal< LiteralArmType< ArmsNotAssignableToSchemaNode >, MeasuredArmDrift11466 > >, From af7b616f8a586fb172bee0cfe611f26afad58792 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:39:26 +0000 Subject: [PATCH 12/16] feat(plugin-dashboard): plugin-dashboard:metric is declared in CustomNodeRegistry, the static pivot names PivotTableSchema, and the SchemaRenderer casts go (objectui#11466) - widgetDispatch.ts: DASHBOARD_NODE_TYPES keeps literal values (a string-keyed view serves the by-type lookup), and DashboardMetricNodeSchema, typed by MetricWidget's props, is entered in @object-ui/types' CustomNodeRegistry under plugin-dashboard:metric (objectui#11479 Q1 A). The entry exports it so the published typings load the declaration. - Both surfaces' getComponentSchema return SchemaRendererProps['schema'], so each node is checked against its declared type where it is built; the metric arms narrow widgetType for the required label. - DashboardGridLayout's static pivot states the keys PivotTableSchema declares and PivotTable draws, read from options, instead of spreading options whole. - The `as BaseSchema | string | null | undefined` cast in DashboardGridLayout and the `as BaseSchema` cast in DashboardRenderer are gone. No new cast. - index.tsx: ObjectMetricBlock's docblock no longer names the dashboard grid path as a host (objectui#11525 retired it). One line remains in DashboardRenderer: the filter broadcast's object-metric member writes a flat `filter` onto an object-metric node, which the declared node (the spec's bag form) refuses by name. Held for a decision, reported on objectui#11466. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../src/DashboardGridLayout.tsx | 50 ++++++++++++----- .../src/DashboardRenderer.tsx | 39 ++++++++----- packages/plugin-dashboard/src/index.tsx | 14 ++++- .../plugin-dashboard/src/widgetDispatch.ts | 55 ++++++++++++++++++- 4 files changed, 126 insertions(+), 32 deletions(-) diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index 1f972cb8b9..1cf1313889 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'; @@ -254,7 +254,10 @@ 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). @@ -370,7 +373,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 +393,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 +471,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 the `pivot` node declares and `PivotTable` draws, read from + // `options`; an option key the node does not declare no longer 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 +600,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..097d9d9551 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 { @@ -700,7 +700,11 @@ const DashboardRendererInner = forwardRef { + // 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). @@ -849,7 +853,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/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 }) => ( > = { +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; } From f90ba8d2ec81f75c58e554b9251a6f31f2035b00 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:43:09 +0000 Subject: [PATCH 13/16] chore(changeset): plugin-dashboard's metric node and static pivot, and dated notes on three pending entries (objectui#11466) - New .changeset/11466-dashboard-metric-node.md (@object-ui/plugin-dashboard minor): DashboardMetricNodeSchema, the static pivot's stated keys and what no longer rides along, and the casts gone. - Dated, append-only notes: - 8310-page-node-body-arity.md: the root README's Basic Usage example authors `children`, not `body` (already so at this branch's base); - 10528-grid-object-table-fetch.md and 11514-dashboard-slot-entry-types.md: their "unchanged" / "draws as before" readings for the grid's static pivot, whose node now states its keys. - DashboardGridLayout.tsx: the pivot comment names the keys it states. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .changeset/10528-grid-object-table-fetch.md | 2 ++ .changeset/11466-dashboard-metric-node.md | 9 +++++++++ .changeset/11514-dashboard-slot-entry-types.md | 2 ++ .changeset/8310-page-node-body-arity.md | 2 ++ packages/plugin-dashboard/src/DashboardGridLayout.tsx | 4 ++-- 5 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 .changeset/11466-dashboard-metric-node.md 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/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/11514-dashboard-slot-entry-types.md b/.changeset/11514-dashboard-slot-entry-types.md index 81562e32b6..86fbf994e3 100644 --- a/.changeset/11514-dashboard-slot-entry-types.md +++ b/.changeset/11514-dashboard-slot-entry-types.md @@ -13,3 +13,5 @@ - **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. diff --git a/.changeset/8310-page-node-body-arity.md b/.changeset/8310-page-node-body-arity.md index b93e557ebe..f9b9b2b653 100644 --- a/.changeset/8310-page-node-body-arity.md +++ b/.changeset/8310-page-node-body-arity.md @@ -36,3 +36,5 @@ Which channel `PageRenderer` reads — `body` or `children` — is a separate qu 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/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index 1cf1313889..a502682883 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -475,8 +475,8 @@ export const DashboardGridLayout: React.FC = ({ // 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 the `pivot` node declares and `PivotTable` draws, read from - // `options`; an option key the node does not declare no longer rides along. + // key `PivotTable` draws (its `PivotTableSchema` keys, and `className`), + // read from `options`; no other option key rides along. return { type: 'pivot', title: options.title, From 20fb2278f7562bb725c3a4907d8380d7fdc14941 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 06:20:47 +0000 Subject: [PATCH 14/16] fix(plugin-dashboard): an object-metric in a widget's component envelope draws the retired-format placeholder, and leaves the filter broadcast (objectui#11466) The maintainer's ruling A on objectui#11466 (comment 5966278275), which extends ruling C on objectui#11525 to the last inline metric form. - legacyRetiredWidget.ts: isRetiredEnvelopeNode, shared by both surfaces, answers an object-metric node (bare key or the registration's full name) read from a widget's legacy `component` envelope. - DashboardRenderer and DashboardGridLayout return the imported LEGACY_RETIRED_WIDGET_SCHEMA for it, before the envelope pass-through. - DashboardRenderer: object-metric leaves FilterableComponentType / FILTERABLE_COMPONENT_TYPES, so the flat-filter write the declared object-metric node refuses is gone with no cast. The docblock now names what the broadcast covers. - inlineObjectMetricRetired-11525: the envelope case flips to the placeholder on both surfaces (dated note in the header), and a control keeps the envelope's object-chart and object-data-table receiving the filter bar's value. Untouched: the envelope's other node types, the authored object-metric block on pages, and every zod and TypeScript authoring face. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../src/DashboardGridLayout.tsx | 6 +- .../src/DashboardRenderer.tsx | 41 +++++++------ .../inlineObjectMetricRetired-11525.test.tsx | 59 +++++++++++++++---- .../src/legacyRetiredWidget.ts | 35 +++++++++++ 4 files changed, 106 insertions(+), 35 deletions(-) diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index a502682883..8f217feb6a 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -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'; @@ -262,6 +262,10 @@ export const DashboardGridLayout: React.FC = ({ // 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. diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index 097d9d9551..7b59f1307c 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -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 }); }); -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/legacyRetiredWidget.ts b/packages/plugin-dashboard/src/legacyRetiredWidget.ts index d70ff3dc89..71c7335cd7 100644 --- a/packages/plugin-dashboard/src/legacyRetiredWidget.ts +++ b/packages/plugin-dashboard/src/legacyRetiredWidget.ts @@ -124,3 +124,38 @@ export function isLegacyRetiredWidget(widget: DashboardWidgetSlotEntry | null | const widgetData = w.data || w.options?.data; return !widgetData && !!w.object; } + +/** + * The node types a widget's legacy `component` envelope no longer draws: + * `object-metric`, under its bare key and its registration's full name + * (objectui#11466, the maintainer's ruling A, which extends ruling C on + * objectui#11525 to this form). + * + * Ruling C put a dashboard metric's number on the semantic layer only: a + * metric binds a `dataset` (ADR-0021). It retired the dataset-less inline path + * the two surfaces built; an `object-metric` node an author placed in the + * envelope (`{ id, component, layout }`, objectui's own format, which the + * spec's widget has no member for) was the one inline metric form left. Both + * surfaces now answer it with {@link LEGACY_RETIRED_WIDGET_SCHEMA}, the same + * imported object their metric and pivot arms return, so the tile draws the + * rebind prompt and sends no query. + * + * ⛔ Every other envelope node draws as before, `object-chart` and + * `object-data-table` included, and so does an `object-metric` block authored + * on a page: this set is read only at the envelope. + */ +const RETIRED_ENVELOPE_NODE_TYPES: ReadonlySet = 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) + ); +} From fab635a2d3af1bbf7cde14ec98d7401588c73017 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 06:22:33 +0000 Subject: [PATCH 15/16] docs(plugin-dashboard): the envelope's object-metric retirement in the README and guide, its changeset, and dated notes on three pending entries (objectui#11466) - New .changeset/11466-envelope-object-metric-retired.md (@object-ui/plugin-dashboard minor): before, now, the fix (bind a dataset) and what does not move. - README.md and content/docs/plugins/plugin-dashboard.mdx: the envelope paragraph names the one retired node and what still draws. - Dated, append-only notes: 11525-inline-metric-retired.md (the envelope object-metric no longer receives the filter bar's values), 11348-dashboard-widget-reads.md (the broadcast narrows over two types, not three), 11514-dashboard-slot-entry-types.md (an envelope's object-metric draws the placeholder). Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .changeset/11348-dashboard-widget-reads.md | 2 ++ .../11466-envelope-object-metric-retired.md | 20 +++++++++++++++++++ .../11514-dashboard-slot-entry-types.md | 2 ++ .changeset/11525-inline-metric-retired.md | 2 ++ content/docs/plugins/plugin-dashboard.mdx | 7 ++++++- packages/plugin-dashboard/README.md | 7 ++++++- 6 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 .changeset/11466-envelope-object-metric-retired.md 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/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/11514-dashboard-slot-entry-types.md b/.changeset/11514-dashboard-slot-entry-types.md index 86fbf994e3..c41e3522fc 100644 --- a/.changeset/11514-dashboard-slot-entry-types.md +++ b/.changeset/11514-dashboard-slot-entry-types.md @@ -15,3 +15,5 @@ ⚠️ **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/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index 7387ceac63..ebb5f159f2 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -406,7 +406,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/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 From cae2224d53421c1c3ff548e16d447d6341e9a083 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 23:35:14 +0000 Subject: [PATCH 16/16] test(types): MeasuredArmDrift11466's docblock names where its remaining drifts are recorded (objectui#11466) The docblock said each listed drift "is reported on objectui#11466 for its own card". Only detail-section, app-schema-renderer and cloud:plan-status got one (objectui#11515). app, record, home, utility, page and dashboard have none; the sentence now says they are recorded for objectui#8347's family, which reads them before the signature removal. Prose only: no assertion moves. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .../types/src/__tests__/node-recursion-point-8344.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/types/src/__tests__/node-recursion-point-8344.test.ts b/packages/types/src/__tests__/node-recursion-point-8344.test.ts index 470bbe4643..be8ed4eedb 100644 --- a/packages/types/src/__tests__/node-recursion-point-8344.test.ts +++ b/packages/types/src/__tests__/node-recursion-point-8344.test.ts @@ -244,7 +244,10 @@ type ArmsNotAssignableToSchemaNode = Exclude< z.output< typeof AnyComponentSchem * - the arm typed `string`: the retired `kanban` tombstone (`RetiredKanbanNodeSchema`), * which parses nothing; its output type is not a node. * - * Each is reported on objectui#11466 for its own card. The list is written out so this stays a + * Of these, only `detail-section`, `app-schema-renderer` and `cloud:plan-status` got a card of + * their own, objectui#11515 (the dated note below). `app`, `record`, `home`, `utility`, `page` + * and `dashboard` have none: they are recorded for objectui#8347's family, which reads them + * before the signature removal. The list is written out so this stays a * ratchet in BOTH directions: a new arm drifting turns it red, and so does a listed one being * reconciled, which then deletes its name here. ⛔ Do not add a name to make a red go away. * The `[…] extends [never]` guard the paragraph above describes belonged to the