diff --git a/.changeset/default-children-retired-5051.md b/.changeset/default-children-retired-5051.md new file mode 100644 index 0000000000..0605f03d1c --- /dev/null +++ b/.changeset/default-children-retired-5051.md @@ -0,0 +1,36 @@ +--- +'@object-ui/types': minor +'@object-ui/core': minor +'@object-ui/components': minor +--- + +The register-meta key `defaultChildren` is retired (objectui#5051). + +It was declared in four places, produced in eleven, and read in **none**. The designer's +drop path builds a new node from its twin key only — `PageDesigner.tsx`, +`props: paletteItem?.defaultProps ?? {}` — with no `children:` line, so a palette item +that declared `defaultChildren` dropped an **empty** node and the declared children never +materialised. Nothing rendered the wrong thing; an entire declaration surface was simply +inert, which is the declared-but-unenforced shape ADR-0049 targets. Per the maintainer +ruling of 2026-08-19, the key is removed rather than wired up; if designer +default-children UX is ever product-wanted it returns as its own designed card. + +**If you author plugins against the published register-meta table, drop the key.** It is +gone from `skills/objectui/guides/plugin-development.md`, which had been teaching it. A +meta that still declares it stays *valid*: `ComponentMetaSchema` is a plain `z.object`, +and measured on zod 4.4.3 that STRIPS unknown keys rather than rejecting them — so the +key is silently dropped from the parse output instead of failing validation. TypeScript +authors get the loud signal instead: all three `ComponentMeta` declarations +(`@object-ui/types` `base.ts` and `plugin-scope.ts`, `@object-ui/core` `Registry.ts`) no +longer offer it, so re-declaring it is now a compile error. + +**No runtime behaviour changes in either direction.** No code path read the key before +this change, and the eleven producers that set it (`sidebar.tsx` x10, `span.tsx`) were +feeding a reader that did not exist. Dropping a `span` or any of the ten sidebar types +into the designer produces exactly the node it produced yesterday. + +Two suites keep it retired, one per package: `packages/types` pins the zod twin (the key +is absent from the parse output, with a surviving sibling asserted present through the +same parse as the control) plus the two TS twins with `@ts-expect-error`, and +`packages/core` pins the registration surface the eleven producers were written against. +Both are compile-time-enforced through each package's chained `tsconfig.test.json`. diff --git a/packages/components/src/renderers/basic/span.tsx b/packages/components/src/renderers/basic/span.tsx index be1a310cf1..19ed1c07f5 100644 --- a/packages/components/src/renderers/basic/span.tsx +++ b/packages/components/src/renderers/basic/span.tsx @@ -156,9 +156,6 @@ ComponentRegistry.register('span', ], defaultProps: { className: 'px-1.5 py-0.5 sm:px-2 sm:py-1' - }, - defaultChildren: [ - { type: 'text', content: 'Inline text' } - ] + } } ); diff --git a/packages/components/src/renderers/navigation/sidebar.tsx b/packages/components/src/renderers/navigation/sidebar.tsx index 5c51b6a376..fe6f3a3d99 100644 --- a/packages/components/src/renderers/navigation/sidebar.tsx +++ b/packages/components/src/renderers/navigation/sidebar.tsx @@ -49,11 +49,7 @@ ComponentRegistry.register('sidebar-provider', ], defaultProps: { defaultOpen: true - }, - defaultChildren: [ - { type: 'sidebar' }, - { type: 'sidebar-inset' } - ] + } } ); @@ -73,12 +69,7 @@ ComponentRegistry.register('sidebar', collapsible: 'icon', side: 'left', variant: 'sidebar' - }, - defaultChildren: [ - { type: 'sidebar-header' }, - { type: 'sidebar-content' }, - { type: 'sidebar-footer' } - ] + } } ); @@ -88,10 +79,7 @@ ComponentRegistry.register('sidebar-header', ), { namespace: 'ui', - label: 'Sidebar Header', - defaultChildren: [ - { type: 'text', content: 'Sidebar Header' } - ] + label: 'Sidebar Header' } ); @@ -101,10 +89,7 @@ ComponentRegistry.register('sidebar-content', ), { namespace: 'ui', - label: 'Sidebar Content', - defaultChildren: [ - { type: 'sidebar-group' } - ] + label: 'Sidebar Content' } ); @@ -136,10 +121,7 @@ ComponentRegistry.register('sidebar-group', ], defaultProps: { label: 'Menu' - }, - defaultChildren: [ - { type: 'sidebar-menu' } - ] + } } ); @@ -149,11 +131,7 @@ ComponentRegistry.register('sidebar-menu', ), { namespace: 'ui', - label: 'Sidebar Menu', - defaultChildren: [ - { type: 'sidebar-menu-item' }, - { type: 'sidebar-menu-item' } - ] + label: 'Sidebar Menu' } ); @@ -163,10 +141,7 @@ ComponentRegistry.register('sidebar-menu-item', ), { namespace: 'ui', - label: 'Sidebar Menu Item', - defaultChildren: [ - { type: 'sidebar-menu-button' } - ] + label: 'Sidebar Menu Item' } ); @@ -186,10 +161,7 @@ ComponentRegistry.register('sidebar-menu-button', ], defaultProps: { size: 'default' - }, - defaultChildren: [ - { type: 'text', content: 'Menu Item' } - ] + } } ); @@ -199,10 +171,7 @@ ComponentRegistry.register('sidebar-footer', ), { namespace: 'ui', - label: 'Sidebar Footer', - defaultChildren: [ - { type: 'text', content: 'Footer' } - ] + label: 'Sidebar Footer' } ); @@ -212,10 +181,7 @@ ComponentRegistry.register('sidebar-inset', ), { namespace: 'ui', - label: 'Sidebar Inset', - defaultChildren: [ - { type: 'div', className: 'p-4', body: [{ type: 'text', content: 'Main content area' }] } - ] + label: 'Sidebar Inset' } ); diff --git a/packages/core/src/registry/Registry.ts b/packages/core/src/registry/Registry.ts index 4aeb130cb9..1e8dc6cec1 100644 --- a/packages/core/src/registry/Registry.ts +++ b/packages/core/src/registry/Registry.ts @@ -7,7 +7,6 @@ */ import type { ComponentInputControlType } from '@object-ui/types'; -import type { SchemaNode } from '../types/index.js'; import { PUBLIC_BLOCKS } from './public-blocks.js'; export type ComponentRenderer = T; @@ -103,7 +102,6 @@ export type ComponentMeta = { labelling?: 'control' | 'group' | 'display'; inputs?: ComponentInput[]; defaultProps?: Record; // Default props when dropped - defaultChildren?: SchemaNode[]; // Default children when dropped examples?: Record; // Example configurations isContainer?: boolean; // Whether the component can have children resizable?: boolean; // Whether the component can be resized in the designer diff --git a/packages/core/src/registry/__tests__/default-children-retired.test.ts b/packages/core/src/registry/__tests__/default-children-retired.test.ts new file mode 100644 index 0000000000..9198a442b9 --- /dev/null +++ b/packages/core/src/registry/__tests__/default-children-retired.test.ts @@ -0,0 +1,59 @@ +/** + * 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. + */ + +/** + * Retirement pin — the fourth declaration twin of the register-meta key + * `defaultChildren` (objectui#5051, ADR-0049 enforce-or-remove; maintainer + * ruling of 2026-08-19 adopted option B, "retire the key everywhere"). + * + * `ComponentMeta` here is the registration surface every `ComponentRegistry.register` + * call is checked against, so it is the twin a producer would re-grow the key + * through: the eleven producers retired alongside it (`sidebar.tsx` x10, + * `span.tsx`) were all written against THIS type. Its three siblings — the two + * `ComponentMeta` interfaces and the `ComponentMetaSchema` validator in + * `@object-ui/types` — are pinned in that package. + * + * This is a COMPILE-TIME pin only, and deliberately claims nothing at runtime. + * The registry does not validate meta: it stores what a caller hands it, so a + * caller casting through `any` could still park the key on a config object at + * runtime and no assertion here could see it. `@ts-expect-error` is real + * enforcement because `packages/core/tsconfig.test.json` is chained from this + * package's `type-check` script (objectui#3009), which is what CI's Type Check + * job runs. + */ + +import { describe, it, expect } from 'vitest'; +import type { ComponentMeta } from '../Registry'; + +describe('ComponentMeta (core registry) — the retired key is gone from the registration surface', () => { + it('rejects `defaultChildren` at compile time', () => { + const retired: ComponentMeta = { + label: 'Inline Container', + // @ts-expect-error `defaultChildren` was retired by objectui#5051 — the + // designer's drop path reads `defaultProps` only, so the declared default + // children never materialised. Re-declaring it here re-opens the + // declared-but-unenforced gap ADR-0049 targets. + defaultChildren: [{ type: 'text', content: 'Inline text' }], + }; + // Referenced so the binding is not merely unused — the `@ts-expect-error` + // above is the actual assertion. + expect(retired.label).toBe('Inline Container'); + }); + + it('still offers the twin the designer actually reads', () => { + // Positive control: the surface is not simply refusing everything. This is + // the key `PageDesigner` consumes on drop, and it is untouched by #5051. + const legal: ComponentMeta = { + label: 'Inline Container', + defaultProps: { className: 'px-1.5 py-0.5' }, + isContainer: true, + }; + expect(legal.defaultProps).toEqual({ className: 'px-1.5 py-0.5' }); + expect(legal.isContainer).toBe(true); + }); +}); diff --git a/packages/types/src/__tests__/default-children-retired-contract-twins.test.ts b/packages/types/src/__tests__/default-children-retired-contract-twins.test.ts new file mode 100644 index 0000000000..22a76d4cf0 --- /dev/null +++ b/packages/types/src/__tests__/default-children-retired-contract-twins.test.ts @@ -0,0 +1,143 @@ +/** + * 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. + */ + +/** + * Retirement pin — the register-meta key `defaultChildren` (objectui#5051, + * ADR-0049 enforce-or-remove; maintainer ruling of 2026-08-19 adopted option B, + * "retire the key everywhere"). + * + * The key was declared in four places, produced in eleven, and read in NONE. + * The designer drag-and-drop path builds a dropped node from its twin key only + * (`PageDesigner.tsx`, `props: paletteItem?.defaultProps ?? {}`), with no + * `children:` line — so a palette item that declared `defaultChildren` dropped + * an empty node and the declaration never materialised. This package holds two + * of the four declaration twins plus the runtime one: + * + * 1. `ComponentMetaSchema` (`zod/base.zod.ts`) — the runtime validator. + * 2. `ComponentMeta` (`base.ts`) — the published `.d.ts` autocomplete surface, + * i.e. what a plugin author (or an AI author) copies from. + * 3. `ComponentMeta` (`plugin-scope.ts`) — its plugin-facing twin, which spelt + * the same key `any[]`. + * + * The Registry twin (`@object-ui/core`) is pinned in that package instead, so + * this suite does not have to import its own dependent. + * + * Two kinds of assertion, deliberately different because the surfaces differ: + * + * The Zod half is NOT a refusal. Measured on zod 4.4.3, a `z.object` STRIPS + * unknown keys rather than rejecting them, and `ComponentMetaSchema` is a plain + * `z.object` with no `.strict()`. So the honest pin is that the key is silently + * DROPPED from the parse output — which is precisely the behaviour the ruling's + * confidence-gap note called out for external authors who already declare it. + * Asserting a rejection here would pin a verdict this validator never emits. + * + * That "absent from the output" assertion is worthless on its own — a schema + * that stripped EVERYTHING would satisfy it — so each case carries its own + * positive control: a surviving sibling key asserted PRESENT in the same parse + * output, through the same call. + * + * The two TS halves erase at runtime, so they are pinned with + * `@ts-expect-error`, which is real enforcement only because + * `packages/types/tsconfig.test.json` is chained from this package's + * `type-check` script (#3009). + */ + +import { describe, it, expect } from 'vitest'; +import { ComponentMetaSchema } from '../zod/base.zod.js'; +import type { ComponentMeta } from '../base.js'; +import type { ComponentMeta as PluginScopeComponentMeta } from '../plugin-scope.js'; + +/** The retired key, and the twin the designer actually reads. */ +const RETIRED = 'defaultChildren'; +const SURVIVOR = 'defaultProps'; + +describe('ComponentMetaSchema — the runtime twin no longer carries the retired key', () => { + it('drops an authored `defaultChildren` from the parse output, while carrying its surviving twin through the same parse', () => { + const result = ComponentMetaSchema.safeParse({ + label: 'Inline Container', + defaultProps: { className: 'px-1.5' }, + defaultChildren: [{ type: 'text', content: 'Inline text' }], + }); + + // Not a refusal: the schema strips, so an author who still declares the key + // keeps a VALID meta. The retirement is that the key stops travelling. + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.data).not.toHaveProperty(RETIRED); + + // Positive control, same parse, same call: the schema is still capable of + // carrying a key through. Without this, a schema that dropped every key + // would pass the assertion above. + expect(result.data).toHaveProperty(SURVIVOR); + expect(result.data.defaultProps).toEqual({ className: 'px-1.5' }); + }); + + it('shrank by exactly one member — the sibling meta vocabulary still round-trips', () => { + // Guards the removal from over-reaching: a hand-edited schema that dropped + // a neighbour would otherwise pass every assertion above. + const result = ComponentMetaSchema.safeParse({ + label: 'Sidebar', + icon: 'panel-left', + category: 'Navigation', + defaultProps: { collapsible: 'icon' }, + examples: { basic: {} }, + isContainer: true, + resizable: true, + tags: ['navigation'], + description: 'A sidebar', + }); + + expect(result.success).toBe(true); + if (!result.success) return; + + for (const key of [ + 'label', + 'icon', + 'category', + 'defaultProps', + 'examples', + 'isContainer', + 'resizable', + 'tags', + 'description', + ]) { + expect(result.data).toHaveProperty(key); + } + }); +}); + +describe('the published TS twins no longer offer the retired key', () => { + it('`ComponentMeta` (base.ts) rejects it at compile time', () => { + const legal: ComponentMeta = { label: 'Span', defaultProps: { className: 'px-1' } }; + expect(legal.defaultProps).toEqual({ className: 'px-1' }); + + const retired: ComponentMeta = { + label: 'Span', + // @ts-expect-error `defaultChildren` was retired by objectui#5051 — it had + // no consumer, so the declared default children never materialised on drop. + defaultChildren: [{ type: 'text', content: 'Inline text' }], + }; + // Referenced so the binding is not merely unused — the `@ts-expect-error` + // above is the actual assertion, enforced by `tsconfig.test.json`. + expect(retired.label).toBe('Span'); + }); + + it('`ComponentMeta` (plugin-scope.ts) rejects it at compile time', () => { + const legal: PluginScopeComponentMeta = { label: 'Span', defaultProps: { className: 'px-1' } }; + expect(legal.defaultProps).toEqual({ className: 'px-1' }); + + const retired: PluginScopeComponentMeta = { + label: 'Span', + // @ts-expect-error `defaultChildren` was retired by objectui#5051; the + // plugin-facing twin spelt it `any[]` and is retired in lockstep. + defaultChildren: [{ type: 'text', content: 'Inline text' }], + }; + expect(retired.label).toBe('Span'); + }); +}); diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index 68a14232ca..c06cc8c458 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -534,11 +534,6 @@ export interface ComponentMeta { */ defaultProps?: Record; - /** - * Default children for container components - */ - defaultChildren?: SchemaNode[]; - /** * Example configurations for documentation */ diff --git a/packages/types/src/plugin-scope.ts b/packages/types/src/plugin-scope.ts index a0e4bcb69d..2e2d916f42 100644 --- a/packages/types/src/plugin-scope.ts +++ b/packages/types/src/plugin-scope.ts @@ -152,7 +152,6 @@ export interface ComponentMeta { category?: string; inputs?: ComponentInput[]; defaultProps?: Record; - defaultChildren?: any[]; examples?: Record; isContainer?: boolean; resizable?: boolean; diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index a3d6a2032c..78caa38307 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -247,7 +247,6 @@ export const ComponentMetaSchema = z.object({ category: z.string().optional().describe('Component category'), inputs: z.array(ComponentInputSchema).optional().describe('Configurable properties'), defaultProps: z.record(z.string(), z.any()).optional().describe('Default property values'), - defaultChildren: z.array(SchemaNodeSchema).optional().describe('Default children'), examples: z.record(z.string(), z.any()).optional().describe('Example configurations'), isContainer: z.boolean().optional().describe('Can have children'), resizable: z.boolean().optional().describe('Can be resized'), diff --git a/skills/objectui/guides/plugin-development.md b/skills/objectui/guides/plugin-development.md index 3cc3a2a2c5..f8a63ff6f3 100644 --- a/skills/objectui/guides/plugin-development.md +++ b/skills/objectui/guides/plugin-development.md @@ -77,7 +77,6 @@ ComponentRegistry.register('my-widget', MyWidgetRenderer, { | `skipFallback` | `boolean` | Don't register non-namespaced fallback (prevents overwrites) | | `inputs` | `ComponentInput[]` | Schema inputs for designer | | `defaultProps` | `Record` | Default properties | -| `defaultChildren` | `SchemaNode[]` | Default child schema | | `isContainer` | `boolean` | Accepts child components | | `resizable` | `boolean` | Designer allows resizing | | `resizeConstraints` | `object` | Min/max width/height |