From 4c5c46d8ee05bcedb83990c7559c4aba62a746a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:07:32 +0000 Subject: [PATCH 1/6] feat(components,types)!: retire the raw-HTML `html` node type (objectui#13449) The `html` renderer wrote `schema.html` into the page through dangerouslySetInnerHTML with no sanitiser, and @objectstack/spec declares no html component. Remove the registration, `HtmlSchema` and its zod arm (kept as a by-name refusal via retiredNodeType), the PUBLIC_BLOCKS entry, the docs page and the catalog example; regenerate the CLI's known types. Claude-Session: https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ Co-authored-by: Claude --- .../html-node-retired-13449.test.tsx | 140 +++++++++++++++++ .../src/__tests__/html-tier-manifest.test.ts | 7 +- .../src/__tests__/public-contract.test.ts | 2 +- content/docs/components/basic/html.mdx | 17 -- content/docs/components/basic/meta.json | 1 - content/docs/components/index.md | 1 - examples/schema-catalog/src/index.ts | 10 -- .../components-basic-html/basic-html.json | 4 - .../__tests__/widget-dom-leak-sweep.test.tsx | 4 +- packages/cli/src/utils/known-schema-types.ts | 2 - .../src/__tests__/basic-renderers.test.tsx | 28 ++-- .../components/src/renderers/basic/html.tsx | 43 ------ .../components/src/renderers/basic/index.ts | 5 +- packages/core/src/registry/public-blocks.ts | 11 +- .../src/__tests__/registry-collision.test.ts | 15 +- packages/fields/src/index.tsx | 11 +- .../types/examples/data-display-examples.json | 5 - .../__tests__/arm-named-export-8784.test.ts | 5 + .../content-channel-family-d-9256.test.ts | 9 +- .../__tests__/html-node-retired-13449.test.ts | 146 ++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 13 +- packages/types/src/data-display.ts | 68 +------- packages/types/src/index.ts | 1 - packages/types/src/registry.ts | 7 +- packages/types/src/zod/README.md | 6 +- packages/types/src/zod/data-display.zod.ts | 57 ++++--- packages/types/src/zod/index.zod.ts | 6 +- 27 files changed, 417 insertions(+), 207 deletions(-) create mode 100644 apps/console/src/__tests__/html-node-retired-13449.test.tsx delete mode 100644 content/docs/components/basic/html.mdx delete mode 100644 examples/schema-catalog/src/schemas/components-basic-html/basic-html.json delete mode 100644 packages/components/src/renderers/basic/html.tsx create mode 100644 packages/types/src/__tests__/html-node-retired-13449.test.ts diff --git a/apps/console/src/__tests__/html-node-retired-13449.test.tsx b/apps/console/src/__tests__/html-node-retired-13449.test.tsx new file mode 100644 index 0000000000..1da604b79a --- /dev/null +++ b/apps/console/src/__tests__/html-node-retired-13449.test.tsx @@ -0,0 +1,140 @@ +/** + * 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#13449 — the raw-HTML `html` node type is retired, and neither + * authoring surface can put unsanitised markup into the page any more. + * + * ## The defect + * + * `@object-ui/components` registered `html` (`ui:html` plus the bare key), + * whose renderer wrote the authored `html` string into a `div` through + * `dangerouslySetInnerHTML` with no sanitiser. `@objectstack/spec` declares no + * `html` component. Two doors reached it: + * + * 1. a JSON node `{ "type": "html", "html": "…" }`; + * 2. a `kind:'html'` page — the tier documented as parsed, never executed, + * and safe for untrusted authors. Its compile whitelists every registered + * type, so `` compiled with NO diagnostic and the + * renderer injected the string. Measured on the base before the fix: an + * `img` carrying an `onerror` attribute reached the DOM through both doors. + * + * ## What each suite holds + * + * 1. the `kind:'html'` page refuses the tag at compile time and renders no + * authored markup (red on the base: zero diagnostics, the `img` with its + * `onerror` attribute in the DOM); + * 2. the JSON node draws the unknown-component panel and no authored markup + * (red on the base: the markup was injected); + * 3. CONTROL — the same page without the retired tag renders its authored + * content, so suite 1's absence is not a page that renders nothing. + * + * It lives in `apps/console` because the claim is about the whole registration + * graph: `@object-ui/fields` registers a `field:html` editor that must not take + * over the bare key once the display renderer is gone. + */ +import { describe, it, expect } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { compile, manifestFromConfigs } from '@object-ui/sdui-parser'; + +// The full registration graph — the pair `dev/manifest-dump.tsx` builds the +// published artifacts from, and the pair every live-path suite here reads. +import '@object-ui/components'; +import '../register-plugins'; + +const dataSource = { + find: async () => ({ data: [], total: 0, hasMore: false }), + findOne: async () => null, + create: async () => ({}), + update: async () => ({}), + delete: async () => ({}), + count: async () => 0, + getObjectSchema: async (name: string) => ({ name, label: name, fields: {} }), + getObjects: async () => [], + onMutation: () => () => {}, +} as any; + +/** + * The manifest an html-kind page validates against, built the way the renderer + * builds it — `ComponentRegistry.getKnownTypes()` + each type's declared + * `inputs`, mirroring `page.tsx`'s `getJsxManifest()`. + */ +const livePageManifest = () => + manifestFromConfigs( + ComponentRegistry.getKnownTypes().map((t) => { + const meta = ComponentRegistry.getMeta(t); + return { type: t, namespace: meta?.namespace, isContainer: meta?.isContainer, inputs: meta?.inputs }; + }) as unknown as Parameters[0], + ); + +function renderNode(schema: Record) { + return render( + + + , + ); +} + +/** Script-bearing markup, as an author would write it. Never executed here: happy-dom loads no image. */ +const PAYLOAD = 'raw'; + +/** Did any of the authored markup reach the DOM as elements? */ +function injected(container: HTMLElement) { + return { + img: container.querySelector('img[data-probe="img13449"]') !== null, + onerror: container.querySelector('[onerror]') !== null, + bold: container.querySelector('#p13449') !== null, + }; +} + +const NOTHING_INJECTED = { img: false, onerror: false, bold: false }; + +describe("suite 1 — a `kind:'html'` page cannot write the retired tag (objectui#13449)", () => { + const SOURCE = `
`; + + it('the live compile refuses `` by name', () => { + const diagnostics = compile(SOURCE, livePageManifest()).diagnostics; + const onTag = diagnostics.filter((d) => (d as { tag?: string }).tag === 'html'); + expect(onTag.length).toBeGreaterThan(0); + expect(onTag.every((d) => d.severity === 'error')).toBe(true); + }); + + it('the rendered page carries none of the authored markup', async () => { + const { container } = renderNode({ type: 'page', kind: 'html', name: 'retired_html_page', source: SOURCE }); + // The page settles on its compile report; wait until it has drawn anything. + await waitFor(() => expect(container.textContent ?? '').not.toEqual('')); + expect(injected(container)).toEqual(NOTHING_INJECTED); + }); +}); + +describe('suite 2 — a JSON `html` node draws the unknown-component panel (objectui#13449)', () => { + it('neither registry key survives, and the field editor did not take the bare one', () => { + expect(ComponentRegistry.get('html')).toBeFalsy(); + expect(ComponentRegistry.get('ui:html')).toBeFalsy(); + // Lit control: the FIELD widget of the same name is still registered. + expect(ComponentRegistry.get('field:html')).toBeTruthy(); + }); + + it('the node renders no authored markup', async () => { + const { container } = renderNode({ type: 'html', html: PAYLOAD }); + await waitFor(() => expect(container.querySelector('[role="alert"]')).not.toBeNull()); + expect(injected(container)).toEqual(NOTHING_INJECTED); + }); +}); + +describe('suite 3 — CONTROL: the same page without the retired tag renders its content', () => { + it('an html-tier `p` reaches the DOM with its text', async () => { + const { container } = renderNode({ + type: 'page', + kind: 'html', + name: 'control_page', + source: '

control text

', + }); + await waitFor(() => expect(container.querySelector('p.ctl13449')?.textContent).toBe('control text')); + }); +}); diff --git a/apps/console/src/__tests__/html-tier-manifest.test.ts b/apps/console/src/__tests__/html-tier-manifest.test.ts index b92f96d260..2b5bdb3df1 100644 --- a/apps/console/src/__tests__/html-tier-manifest.test.ts +++ b/apps/console/src/__tests__/html-tier-manifest.test.ts @@ -202,7 +202,12 @@ describe('the curated tier is unmoved — the JSON-surface vocabulary did not gr expect(curated).toEqual(PUBLIC_BLOCKS.filter((tag) => tag in published.components)); // Anti-vacuity: the curated tier resolved, and to the vocabulary it is. expect(curated.length).toBeGreaterThan(50); - expect(curated).toEqual(expect.arrayContaining(['object-grid', 'box', 'html'])); + expect(curated).toEqual(expect.arrayContaining(['object-grid', 'box', 'text'])); + // The raw-HTML `html` block left the curated tier with its node type + // (objectui#13449). Read off the manifest the published artifact is built + // from, not off the roster, so a stray registration would show up here. + expect(curated).not.toContain('html'); + expect('html' in published.components).toBe(false); }); it('the stamped entries are exactly the roster, in roster order, and nothing else', () => { diff --git a/apps/console/src/__tests__/public-contract.test.ts b/apps/console/src/__tests__/public-contract.test.ts index 7cc9ea4801..5a433adcb0 100644 --- a/apps/console/src/__tests__/public-contract.test.ts +++ b/apps/console/src/__tests__/public-contract.test.ts @@ -112,7 +112,7 @@ const EXPECTED_COVERED = [ 'badge', 'alert', 'button', - 'html', + // `html` left with the raw-HTML node type it named (objectui#13449). ]; /** diff --git a/content/docs/components/basic/html.mdx b/content/docs/components/basic/html.mdx deleted file mode 100644 index 1de66c1e0a..0000000000 --- a/content/docs/components/basic/html.mdx +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: "HTML" -description: "Render raw HTML content safely" ---- - -## Examples - - - -## Schema - -```ts -interface HtmlSchema { - type: 'html'; - html: string; -} -``` diff --git a/content/docs/components/basic/meta.json b/content/docs/components/basic/meta.json index 0992ea16bf..4268c87c66 100644 --- a/content/docs/components/basic/meta.json +++ b/content/docs/components/basic/meta.json @@ -5,7 +5,6 @@ "icon", "image", "separator", - "html", "button-group", "pagination", "navigation-menu", diff --git a/content/docs/components/index.md b/content/docs/components/index.md index 650ffad5fc..72fc56aa78 100644 --- a/content/docs/components/index.md +++ b/content/docs/components/index.md @@ -45,7 +45,6 @@ The foundation of your UI. These are simple, single-purpose components: - **[Icon](/docs/components/basic/icon)** - Render icons from Lucide React - **[Image](/docs/components/basic/image)** - Display images with lazy loading - **[Separator](/docs/components/basic/separator)** - Visual divider between content -- **[HTML](/docs/components/basic/html)** - Render raw HTML content ### Form Components diff --git a/examples/schema-catalog/src/index.ts b/examples/schema-catalog/src/index.ts index cfd9381c4b..cbaac6a157 100644 --- a/examples/schema-catalog/src/index.ts +++ b/examples/schema-catalog/src/index.ts @@ -39,7 +39,6 @@ import components_basic_div_grid_layout from './schemas/components-basic-div/gri import components_basic_div_nested_divs from './schemas/components-basic-div/nested-divs.json' with { type: 'json' }; import components_basic_div_use_box_instead from './schemas/components-basic-div/use-box-instead.json' with { type: 'json' }; import components_basic_div_use_card_instead from './schemas/components-basic-div/use-card-instead.json' with { type: 'json' }; -import components_basic_html_basic_html from './schemas/components-basic-html/basic-html.json' with { type: 'json' }; import components_basic_icon_basic_icon from './schemas/components-basic-icon/basic-icon.json' with { type: 'json' }; import components_basic_icon_colored_icons from './schemas/components-basic-icon/colored-icons.json' with { type: 'json' }; import components_basic_icon_icon_sizes from './schemas/components-basic-icon/icon-sizes.json' with { type: 'json' }; @@ -704,15 +703,6 @@ const REGISTRY: Record = { }, schema: components_basic_div_use_card_instead, }, - 'components-basic-html/basic-html': { - id: 'components-basic-html/basic-html', - meta: { - title: "Basic Html", - description: "", - category: 'components-basic-html', - }, - schema: components_basic_html_basic_html, - }, 'components-basic-icon/basic-icon': { id: 'components-basic-icon/basic-icon', meta: { diff --git a/examples/schema-catalog/src/schemas/components-basic-html/basic-html.json b/examples/schema-catalog/src/schemas/components-basic-html/basic-html.json deleted file mode 100644 index 13929b5964..0000000000 --- a/examples/schema-catalog/src/schemas/components-basic-html/basic-html.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "html", - "html": "

This is HTML content

" -} diff --git a/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx b/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx index b1756dec9e..c09aa8b421 100644 --- a/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx +++ b/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx @@ -772,7 +772,7 @@ const COMPONENTS_PLAIN_TYPES: readonly string[] = [ 'ui:date-picker', 'ui:dd', 'ui:del', 'ui:div', 'ui:dl', 'ui:dt', 'ui:em', 'ui:email', 'ui:empty', 'ui:figcaption', 'ui:figure', 'ui:file-upload', 'ui:filter-builder', 'ui:flex', 'ui:footer', 'ui:form', 'ui:grid', 'ui:h1', 'ui:h2', 'ui:h3', 'ui:h4', 'ui:h5', 'ui:h6', - 'ui:header', 'ui:home', 'ui:hr', 'ui:html', 'ui:i', 'ui:icon', 'ui:image', 'ui:img', + 'ui:header', 'ui:home', 'ui:hr', 'ui:i', 'ui:icon', 'ui:image', 'ui:img', 'ui:input', 'ui:input-otp', 'ui:ins', 'ui:kbd', 'ui:label', 'ui:li', 'ui:list', 'ui:loading', 'ui:main', 'ui:mark', 'ui:menubar', 'ui:nav', 'ui:navigation-menu', 'ui:ol', 'ui:p', @@ -1177,7 +1177,7 @@ const COMPONENTS_LEAK_GROUPS: readonly LedgerGroup[] = [ 'ui:cite', 'ui:code', 'ui:collapsible', 'ui:command', 'ui:dd', 'ui:del', 'ui:div', 'ui:dl', 'ui:dt', 'ui:em', 'ui:empty', 'ui:figcaption', 'ui:figure', 'ui:footer', 'ui:h1', 'ui:h2', 'ui:h3', 'ui:h4', 'ui:h5', 'ui:h6', 'ui:header', - 'ui:hr', 'ui:html', 'ui:i', 'ui:image', 'ui:img', 'ui:ins', 'ui:kbd', + 'ui:hr', 'ui:i', 'ui:image', 'ui:img', 'ui:ins', 'ui:kbd', 'ui:label', 'ui:li', 'ui:list', 'ui:loading', 'ui:main', 'ui:mark', 'ui:menubar', 'ui:nav', 'ui:navigation-menu', 'ui:ol', 'ui:p', 'ui:pagination', 'ui:pre', 'ui:progress', 'ui:q', 'ui:resizable', 'ui:scroll-area', 'ui:section', diff --git a/packages/cli/src/utils/known-schema-types.ts b/packages/cli/src/utils/known-schema-types.ts index 285dbf9e58..1fbf40cbe0 100644 --- a/packages/cli/src/utils/known-schema-types.ts +++ b/packages/cli/src/utils/known-schema-types.ts @@ -198,7 +198,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'home', 'hover-card', 'hr', - 'html', 'i', 'icon', 'image', @@ -500,7 +499,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'ui:home', 'ui:hover-card', 'ui:hr', - 'ui:html', 'ui:i', 'ui:icon', 'ui:image', diff --git a/packages/components/src/__tests__/basic-renderers.test.tsx b/packages/components/src/__tests__/basic-renderers.test.tsx index 9893221fdc..0ba35ba16f 100644 --- a/packages/components/src/__tests__/basic-renderers.test.tsx +++ b/packages/components/src/__tests__/basic-renderers.test.tsx @@ -19,7 +19,6 @@ import { import '../renderers'; import type { DivSchema, - HtmlSchema, IconSchema, ImageSchema, SeparatorSchema, @@ -40,7 +39,6 @@ const spanNode = (schema: TextSpanSchema): TextSpanSchema => schema; const imageNode = (schema: ImageSchema): ImageSchema => schema; const iconNode = (schema: IconSchema): IconSchema => schema; const separatorNode = (schema: SeparatorSchema): SeparatorSchema => schema; -const htmlNode = (schema: HtmlSchema): HtmlSchema => schema; /** * Comprehensive tests for basic renderer components @@ -275,21 +273,21 @@ describe('Basic Renderers - Display Issue Detection', () => { }); }); - describe('HTML Renderer', () => { - it('should be properly registered', () => { - const validation = validateComponentRegistration('html'); - expect(validation.isRegistered).toBe(true); + describe('the retired `html` node type (objectui#13449)', () => { + // The raw-HTML renderer injected `schema.html` through + // `dangerouslySetInnerHTML` with no sanitiser, and `@objectstack/spec` + // declares no `html` component. It is retired, not sanitised: neither + // registry key survives. + it('registers neither the bare key nor `ui:html`', () => { + expect(validateComponentRegistration('html').isRegistered).toBe(false); + expect(validateComponentRegistration('ui:html').isRegistered).toBe(false); }); - it('should render HTML content safely', () => { - const { container } = renderComponent(htmlNode({ - type: 'html', - html: '

HTML Content

', - })); - - // HTML renderer uses dangerouslySetInnerHTML - const hasContent = container.querySelector('p') || container.textContent?.includes('HTML Content'); - expect(hasContent).toBeTruthy(); + it('control: a sibling basic renderer from the same module set is still registered', () => { + // Without this, the two `false` readings above would also pass under a + // `../renderers` import that registered nothing at all. + expect(validateComponentRegistration('text').isRegistered).toBe(true); + expect(validateComponentRegistration('ui:text').isRegistered).toBe(true); }); }); }); diff --git a/packages/components/src/renderers/basic/html.tsx b/packages/components/src/renderers/basic/html.tsx deleted file mode 100644 index b2a9adc072..0000000000 --- a/packages/components/src/renderers/basic/html.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/** - * 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. - */ - -import React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; -import type { HtmlSchema } from '@object-ui/types'; -import { cn } from '../../lib/utils'; - -ComponentRegistry.register('html', - ({ schema, className, ...props }: { schema: HtmlSchema; className?: string; [key: string]: any }) => { - // Extract designer-related props - const { - 'data-obj-id': dataObjId, - 'data-obj-type': dataObjType, - style, - ...htmlProps - } = props; - - return ( -
- ); - }, - { - namespace: 'ui', - label: 'HTML Content', - inputs: [ - { name: 'html', type: 'string', description: 'Raw HTML content' } - ] - } -); diff --git a/packages/components/src/renderers/basic/index.ts b/packages/components/src/renderers/basic/index.ts index 8cdfc9e41a..30a08b4d2a 100644 --- a/packages/components/src/renderers/basic/index.ts +++ b/packages/components/src/renderers/basic/index.ts @@ -12,7 +12,10 @@ import './span'; import './separator'; import './image'; import './icon'; -import './html'; +// `./html` (the raw-HTML `html` node type) is retired (objectui#13449): it +// injected `schema.html` unsanitised and `@objectstack/spec` declares no such +// component. Rich HTML is a field value (`html` / `richtext`, sanitised) or a +// `kind: 'html'` page. import './html-elements'; import './button-group'; import './pagination'; diff --git a/packages/core/src/registry/public-blocks.ts b/packages/core/src/registry/public-blocks.ts index 1b382afaeb..a1aee46583 100644 --- a/packages/core/src/registry/public-blocks.ts +++ b/packages/core/src/registry/public-blocks.ts @@ -16,7 +16,7 @@ * contract (`getPublicConfigs`). * * Shaped like Salesforce App Builder standard components — small, object-centric - * — plus a thin layout/content layer and one escape hatch. A component not yet + * — plus a thin layout/content layer. A component not yet * registered is simply skipped (the list is aspirational-safe). Registrations * may also opt in individually via `tier: 'public'`. * @@ -169,8 +169,13 @@ export const PUBLIC_BLOCKS: readonly string[] = [ 'badge', 'alert', 'button', - // ── Tier C — escape hatch (flagged, second-class) ───────────────────────── - 'html', + // ⛔ The raw-HTML `html` block that sat here as a "Tier C escape hatch" is + // RETIRED (objectui#13449): it injected `schema.html` unsanitised, so any + // metadata author — a `kind:'html'` page included, which could write it as an + // `` tag — could put script-bearing markup in front of every + // viewer, and `@objectstack/spec` declares no `html` component. Rich HTML is + // a sanitised field value (`html` / `richtext`) or a `kind:'html'` page; + // there is no raw-HTML block. ]; /** Fast membership set built from {@link PUBLIC_BLOCKS}. */ diff --git a/packages/fields/src/__tests__/registry-collision.test.ts b/packages/fields/src/__tests__/registry-collision.test.ts index 6eaba7cbb6..c9cd96861a 100644 --- a/packages/fields/src/__tests__/registry-collision.test.ts +++ b/packages/fields/src/__tests__/registry-collision.test.ts @@ -10,7 +10,7 @@ * Registry collision guard. * * `@object-ui/components` registers display widgets (`text`, `image`, - * `avatar`, `html`, `grid`) under their bare names, and the html tier's + * `avatar`, `grid`) under their bare names, and the html tier's * inline `code` element passthrough (objectui#10756). `@object-ui/fields` * registers form-input widgets that — by name — collide with those. * @@ -37,7 +37,7 @@ import { ComponentRegistry } from '@object-ui/core'; import '@object-ui/components'; import { registerAllFields } from '../index'; -const COLLIDING_TYPES = ['text', 'html', 'image', 'avatar', 'grid', 'code'] as const; +const COLLIDING_TYPES = ['text', 'image', 'avatar', 'grid', 'code'] as const; describe('registry collision (fields ↔ components)', () => { beforeAll(() => { @@ -102,4 +102,15 @@ describe('registry collision (fields ↔ components)', () => { expect(ComponentRegistry.get(type)).not.toBe(ComponentRegistry.get(`field:${type}`)); } }); + + it('bare `html` resolves to NOTHING once the raw-HTML node type retired (objectui#13449)', () => { + // `html` left COLLIDING_TYPES: `@object-ui/components` no longer registers + // the raw-HTML `ui:html` node that owned the bare key. The field widget + // still stands down from it, so a stale `{ type: 'html' }` node draws the + // unknown-component panel instead of a rich-text editor, and a form still + // reaches the widget under its namespace. + expect(ComponentRegistry.get('field:html'), 'field:html must be registered').toBeTruthy(); + expect(ComponentRegistry.get('html'), 'bare "html" must not be registered').toBeFalsy(); + expect(ComponentRegistry.get('ui:html'), '"ui:html" must not be registered').toBeFalsy(); + }); }); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index d9c55a62e5..71bd5286f8 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -4780,10 +4780,9 @@ const FIELD_TYPES_SKIP_FALLBACK = new Set([ // expect (e.g. `{ type: 'markdown', content }` → the markdown renderer, not // the RichText editor). Without skipFallback each of these logged a // "bare-name fallback is being overwritten" warning at boot. - // Display widgets (text/html/image/avatar/grid live in @object-ui/components + // Display widgets (text/image/avatar/grid live in @object-ui/components // or @object-ui/layout as the bare-name owners). 'text', - 'html', 'image', 'avatar', 'grid', @@ -4848,6 +4847,14 @@ const FIELD_TYPES_SKIP_FALLBACK = new Set([ 'url', 'user', 'vector', + // ── Retired bare node key (objectui#13449) ──────────────────────────────── + // `html` sat in the display-widget group above while `@object-ui/components` + // registered its raw-HTML `ui:html` node, which owned the bare key. That node + // type is retired, so no package owns bare `html` any more. It stays in this + // set so the bare key resolves to NOTHING: a stale `{ type: 'html' }` node + // must draw the unknown-component panel, never this rich-text editor, and a + // form keeps reaching the widget as `field:html`. + 'html', ]); /** diff --git a/packages/types/examples/data-display-examples.json b/packages/types/examples/data-display-examples.json index 8a3f01b4b3..d14ddaac4e 100644 --- a/packages/types/examples/data-display-examples.json +++ b/packages/types/examples/data-display-examples.json @@ -228,11 +228,6 @@ "type": "markdown", "id": "readme", "content": "# Welcome\n\nThis is a **markdown** component.\n\n- Feature 1\n- Feature 2\n- Feature 3" - }, - "html": { - "type": "html", - "id": "custom-html", - "html": "

Custom HTML

This is raw HTML content.

" } }, "compositeExample": { diff --git a/packages/types/src/__tests__/arm-named-export-8784.test.ts b/packages/types/src/__tests__/arm-named-export-8784.test.ts index ae18512520..d0574295f6 100644 --- a/packages/types/src/__tests__/arm-named-export-8784.test.ts +++ b/packages/types/src/__tests__/arm-named-export-8784.test.ts @@ -185,6 +185,11 @@ const ABSENT_BY_DECISION: Readonly> = { + 'retired `cloud:plan-status` literal (objectui#12954), in the shape of the `kanban` row above. ' + 'The decision is written on the barrel beside the `cloud.zod.js` export: "it is deliberately ' + 'NOT exported — nothing outside this package parses against a refusal arm".', + html: + 'data-display.zod.ts#RetiredHtmlNodeSchema — the same ADR-0049 refusal arm, for the ' + + 'retired raw-HTML `html` literal (objectui#13449), in the shape of the `kanban` row above. ' + + 'The decision is written on the barrel beside the `data-display.zod.js` block: "it is ' + + 'deliberately NOT exported — nothing outside this package parses against a refusal arm".', // ⭐ The two rows objectui#9067 moved here out of ABSENT_PENDING_DECISION. They // quote `.changeset/8499-node-slot-registered-arms.md` rather than cite it, diff --git a/packages/types/src/__tests__/content-channel-family-d-9256.test.ts b/packages/types/src/__tests__/content-channel-family-d-9256.test.ts index 90a456e907..b93f8f19dc 100644 --- a/packages/types/src/__tests__/content-channel-family-d-9256.test.ts +++ b/packages/types/src/__tests__/content-channel-family-d-9256.test.ts @@ -146,7 +146,6 @@ import { import { ListSchema as ListMirror, TimelineSchema as TimelineMirror, - HtmlSchema as HtmlMirror, AvatarSchema as AvatarMirror, TreeViewSchema as TreeViewMirror, StatisticSchema as StatisticMirror, @@ -259,7 +258,6 @@ const ROWS: ReadonlyArray, required: Record, ]> = [ ['separator', SeparatorMirror as unknown as Mirror, ['body', 'children'], {}], - ['html', HtmlMirror as unknown as Mirror, ['body', 'children'], {"html":"x"}], ['button-group', ButtonGroupMirror as unknown as Mirror, ['body', 'children'], {}], ['pagination', PaginationMirror as unknown as Mirror, ['body', 'children'], {"totalPages":1}], ['navigation-menu', NavigationMenuMirror as unknown as Mirror, ['body', 'children'], {}], @@ -372,9 +370,12 @@ const CASES = ROWS.flatMap(([type, mirror, dead, required]) => describe('objectui#9256 — family D refuses the content channels its renderers never read', () => { it('the population is the measured one — a row dropped from the table fails here', () => { - expect(ROWS).toHaveLength(67); + // 66 since objectui#13449 retired the `html` node type: its row left with + // the arm, whose remaining claim is the by-name refusal pinned in + // `html-node-retired-13449.test.ts`. + expect(ROWS).toHaveLength(66); // 3 fewer than 2x: the chatbot faces carry `children` only. - expect(CASES).toHaveLength(67 * 2 - 3); + expect(CASES).toHaveLength(66 * 2 - 3); }); it.each(CASES)('%s is refused at that key\'s own path', (_label, mirror, key, required) => { diff --git a/packages/types/src/__tests__/html-node-retired-13449.test.ts b/packages/types/src/__tests__/html-node-retired-13449.test.ts new file mode 100644 index 0000000000..36ef3e9b67 --- /dev/null +++ b/packages/types/src/__tests__/html-node-retired-13449.test.ts @@ -0,0 +1,146 @@ +/** + * 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 raw-HTML `html` NODE TYPE is retired, and a document naming it is refused + * BY NAME, with its remedy (objectui#13449). + * + * Its renderer wrote the authored `html` string into the page through + * `dangerouslySetInnerHTML` with no sanitiser, and `@objectstack/spec` declares + * no `html` component. The renderer, `HtmlSchema` and its member-by-member arm + * went; the literal was declared on a face, so the retirement keeps a + * `retiredNodeType()` arm in `DataDisplaySchema` — the shape objectui#8802 + * ruled for the bare `kanban` key (maintainer ruling 2026-09-09), followed by + * `cloud-plan-status-retired-12954.test.ts`, whose suites this file follows. + * + * ## ⚠️ The mechanism, which is NOT the passthrough rule + * + * `AnyComponentSchema` is a DISCRIMINATED union: an unclaimed literal matches no + * arm, so deleting the arm would already refuse — with the union's remedy-free + * `Invalid input`. The refusal arm claims the literal and answers with a + * `custom` issue at `type` naming what to author instead. Suite 1 measures that + * refusal; suite 3 measures that an unknown literal still gets the generic + * path, which is the control that separates the two. + * + * ## ⛔ The layer this retirement does NOT touch + * + * `html` is ALSO a field type (`HtmlFieldMetadata`): a form or record field + * whose value renders through the sanitised fields path. Suite 4 pins that a + * form declaring an `html` field still validates, because the remedy this + * retirement names IS that field. + */ + +import { describe, it, expect } from 'vitest'; +import { safeValidateSchema } from '../zod/index.zod'; +import type { DataDisplaySchema } from '../data-display'; +import type { ComponentType } from '../registry'; +import type { HtmlFieldMetadata } from '../field-types'; + +/* -------------------------------------------------------------------------- */ +/* Compile-time pins — read by tsc -p tsconfig.test.json, not by vitest. */ +/* -------------------------------------------------------------------------- */ + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type IsNever = [T] extends [never] ? true : false; + +// The TypeScript half of the retirement: no union member and no registry row +// for the literal, so `tsc` refuses it at the authoring site. +type _DataDisplayHasNoHtmlArm = Assert>, true>>; +type _ComponentTypeHasNoHtml = Assert>; +// Non-vacuity on the same instruments: a surviving sibling IS selectable. +type _DataDisplayKeepsKbd = Assert>, false>>; +type _ComponentTypeKeepsKbd = Assert>; +// The FIELD layer is a different declaration and stays. +type _HtmlFieldStays = Assert>; + +/* -------------------------------------------------------------------------- */ +/* Runtime — through `safeValidateSchema`, the tolerant node union. */ +/* -------------------------------------------------------------------------- */ + +interface IssueLike { + readonly path?: readonly PropertyKey[]; + readonly message?: string; + readonly code?: string; + readonly errors?: readonly (readonly IssueLike[])[]; +} + +/** Flatten zod's nested union issues into `{ path, message, code }` rows. */ +function flattenIssues(issues: readonly IssueLike[]): Array<{ path: string; message: string; code: string }> { + return issues.flatMap((issue) => [ + { path: (issue.path ?? []).join('.'), message: issue.message ?? '', code: issue.code ?? '' }, + ...(issue.errors ?? []).flatMap((nested) => flattenIssues(nested)), + ]); +} + +function refusals(schema: unknown) { + const result = safeValidateSchema(schema); + return result.success ? [] : flattenIssues(result.error.issues as unknown as IssueLike[]); +} + +/** The node that used to validate under the retired arm, spelled unchanged. */ +const RETIRED_NODE = { type: 'html', html: '

This is HTML content

' } as const; + +describe('suite 1 — a `type: "html"` node is REFUSED, by name (objectui#13449)', () => { + it('refuses the document with one `custom` issue at `type`', () => { + const found = refusals(RETIRED_NODE); + expect(found.filter((f) => f.code === 'custom' && f.path === 'type')).toHaveLength(1); + }); + + it('the refusal names the retired type and the two replacements', () => { + // ⭐ The whole reason the arm survives as a refusal instead of being + // deleted. Suite 3 is its control. + const [issue] = refusals(RETIRED_NODE).filter((f) => f.code === 'custom' && f.path === 'type'); + expect(issue?.message).toContain('`html` is a RETIRED node type'); + expect(issue?.message).toContain('`richtext`'); + expect(issue?.message).toContain("`kind: 'html'`"); + }); + + it('refuses a bare node too, whatever else it carries', () => { + expect(refusals({ type: 'html' }).filter((f) => f.code === 'custom' && f.path === 'type')).toHaveLength(1); + }); + + it('refuses it nested inside a container, where a page would place it', () => { + const found = refusals({ type: 'flex', children: [RETIRED_NODE] }); + expect(found.some((f) => f.code === 'custom' && f.message.includes('`html` is a RETIRED node type'))).toBe(true); + }); +}); + +describe('suite 2 — the refusal is not a validator that turned strict', () => { + it('a surviving data-display node on the same call is still accepted', () => { + // Firing control: without it every assertion in suite 1 would also pass + // under a `safeValidateSchema` that had started refusing everything. + expect(refusals({ type: 'kbd', keys: ['Ctrl', 'K'] })).toEqual([]); + }); +}); + +describe('suite 3 — control: an unknown literal takes the GENERIC path', () => { + it('a name no arm claims is refused without the retirement text', () => { + // ⭐ The separator. If this document ALSO carried the guidance, suite 1 + // would be measuring the union's boilerplate rather than this arm. + const found = refusals({ type: 'no-such-node-13449', html: '

x

' }); + const text = found.map((r) => r.message).join('\n'); + expect(text).not.toEqual(''); + expect(found.some((f) => f.code === 'custom')).toBe(false); + expect(text).not.toContain('RETIRED'); + }); +}); + +describe('suite 4 — the `html` FIELD type is a different layer and is untouched', () => { + it('a form declaring an `html` field still validates', () => { + // The catalog's own `fields-rich-text/html-editor` document, spelled + // unchanged: the remedy suite 1 names has to keep working. + const form = { + type: 'form', + showSubmit: false, + showCancel: false, + fields: [{ name: 'description', label: 'Description', type: 'html', placeholder: 'Enter HTML content...', rows: 8 }], + }; + expect(refusals(form)).toEqual([]); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index e153c08a28..0549a8674c 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -959,7 +959,7 @@ import { RecordApprovalDecisionBlockSchema } from '../zod/record-approval-decisi import { RecordApprovalsBlockSchema, RecordAttachmentsBlockSchema } from '../zod/record-panels.zod.js'; import { CalendarEventSchema, CalendarViewSchema, CarouselItemSchema, CarouselSchema, ChatbotSchema, ChatbotEnhancedSchema, ChatbotFloatingSchema, ChatMessageSchema, ChatMessageSourceSchema, ChatToolInvocationSchema, DashboardComponentSchema, DashboardConfigSchema, DashboardWidgetConfigSchema, DashboardWidgetLayoutSchema, DashboardWidgetSchema, FilterBuilderSchema, FilterFieldSchema, KanbanCardSchema, KanbanColumnSchema, CardTemplateSchema, FilterGroupSchema } from '../zod/complex.zod.js'; import { ActionSchema, CRUDDialogSchema, DetailSchema } from '../zod/crud.zod.js'; -import { AlertSchema, AvatarSchema, BadgeSchema, BarChartSchema, ChartDataSeriesSchema, ChartSchema, DataTableSchema, DrillDownConfigSchema, HtmlSchema, KbdSchema, ListItemSchema, ListSchema, MarkdownSchema, PivotTableSchema, StaticTableColumnSchema, StatisticSchema, TableColumnSchema, TableSchema, TimelineEventSchema, TimelineFeedItemSchema, TimelineGanttItemBarSchema, TimelineGanttItemSchema, TimelineSchema, TreeNodeSchema, TreeViewSchema } from '../zod/data-display.zod.js'; +import { AlertSchema, AvatarSchema, BadgeSchema, BarChartSchema, ChartDataSeriesSchema, ChartSchema, DataTableSchema, DrillDownConfigSchema, KbdSchema, ListItemSchema, ListSchema, MarkdownSchema, PivotTableSchema, StaticTableColumnSchema, StatisticSchema, TableColumnSchema, TableSchema, TimelineEventSchema, TimelineFeedItemSchema, TimelineGanttItemBarSchema, TimelineGanttItemSchema, TimelineSchema, TreeNodeSchema, TreeViewSchema } from '../zod/data-display.zod.js'; import { BPMNEdgeSchema, BPMNLaneSchema, BPMNNodeSchema, DataModelDesignerSchema, DataModelEntitySchema, DataModelFieldSchema, DataModelRelationshipSchema, DesignerCanvasConfigSchema, DesignerComponentSchema, DesignerFieldDefinitionSchema, DesignerFieldOptionSchema, DesignerPaletteCategorySchema, DesignerPaletteItemSchema, DesignerPositionSchema, FieldDesignerSchema, ObjectDefinitionSchema, ObjectManagerSchema, PageDesignerSchema, ProcessDesignerSchema, ReportDesignerElementSchema, ReportDesignerSchema, ReportDesignerSectionSchema } from '../zod/designer.zod.js'; import { AccordionItemSchema, AccordionSchema, CollapsibleSchema, ToggleGroupItemSchema, ToggleGroupSchema } from '../zod/disclosure.zod.js'; import { EmptySchema, LoadingSchema, ProgressSchema, SkeletonSchema, SonnerSchema, SpinnerSchema, ToasterSchema, ToastSchema } from '../zod/feedback.zod.js'; @@ -981,7 +981,7 @@ import type { RecordApprovalsBlockSchema as Ts_RecordApprovalsBlockSchema, Recor import type { CalendarEvent as Ts_CalendarEvent, CalendarViewSchema as Ts_CalendarViewSchema, CarouselItem as Ts_CarouselItem, CarouselSchema as Ts_CarouselSchema, ChatbotSchema as Ts_ChatbotSchema, ChatbotEnhancedSchema as Ts_ChatbotEnhancedSchema, ChatbotFloatingSchema as Ts_ChatbotFloatingSchema, ChatMessage as Ts_ChatMessage, ChatMessageSource as Ts_ChatMessageSource, ChatToolInvocation as Ts_ChatToolInvocation, DashboardComponentSchema as Ts_DashboardComponentSchema, DashboardWidgetLayout as Ts_DashboardWidgetLayout, DashboardWidgetSchema as Ts_DashboardWidgetSchema, FilterBuilderSchema as Ts_FilterBuilderSchema, FilterField as Ts_FilterField, KanbanCard as Ts_KanbanCard, KanbanColumn as Ts_KanbanColumn, CardTemplate as Ts_CardTemplate } from '../complex'; import type { BPMNEdge as Ts_BPMNEdge, BPMNLane as Ts_BPMNLane, BPMNNode as Ts_BPMNNode, DashboardConfig as Ts_DashboardConfig, DashboardWidgetConfig as Ts_DashboardWidgetConfig, DataModelDesignerSchema as Ts_DataModelDesignerSchema, DataModelEntity as Ts_DataModelEntity, DataModelField as Ts_DataModelField, DataModelRelationship as Ts_DataModelRelationship, DesignerCanvasConfig as Ts_DesignerCanvasConfig, DesignerFieldDefinition as Ts_DesignerFieldDefinition, DesignerFieldOption as Ts_DesignerFieldOption, DesignerPaletteCategory as Ts_DesignerPaletteCategory, DesignerPaletteItem as Ts_DesignerPaletteItem, DesignerPosition as Ts_DesignerPosition, FieldDesignerSchema as Ts_FieldDesignerSchema, ObjectDefinition as Ts_ObjectDefinition, ObjectManagerSchema as Ts_ObjectManagerSchema, PageDesignerSchema as Ts_PageDesignerSchema, ProcessDesignerSchema as Ts_ProcessDesignerSchema, ReportDesignerElement as Ts_ReportDesignerElement, ReportDesignerSchema as Ts_ReportDesignerSchema, ReportDesignerSection as Ts_ReportDesignerSection } from '../designer'; import type { CRUDDialogSchema as Ts_CRUDDialogSchema, DetailSchema as Ts_DetailSchema } from '../crud'; -import type { AlertSchema as Ts_AlertSchema, AvatarSchema as Ts_AvatarSchema, BadgeSchema as Ts_BadgeSchema, BarChartSchema as Ts_BarChartSchema, ChartDataSeries as Ts_ChartDataSeries, ChartSchema as Ts_ChartSchema, DataTableSchema as Ts_DataTableSchema, DrillDownConfig as Ts_DrillDownConfig, HtmlSchema as Ts_HtmlSchema, KbdSchema as Ts_KbdSchema, ListItem as Ts_ListItem, ListSchema as Ts_ListSchema, MarkdownSchema as Ts_MarkdownSchema, PivotTableSchema as Ts_PivotTableSchema, StaticTableColumn as Ts_StaticTableColumn, StatisticSchema as Ts_StatisticSchema, TableColumn as Ts_TableColumn, TableSchema as Ts_TableSchema, TimelineEvent as Ts_TimelineEvent, TimelineFeedItem as Ts_TimelineFeedItem, TimelineGanttItem as Ts_TimelineGanttItem, TimelineGanttItemBar as Ts_TimelineGanttItemBar, TimelineSchema as Ts_TimelineSchema, TreeViewSchema as Ts_TreeViewSchema, BreadcrumbItem as Ts_BreadcrumbItem, BreadcrumbSchema as Ts_BreadcrumbSchema } from '../data-display'; +import type { AlertSchema as Ts_AlertSchema, AvatarSchema as Ts_AvatarSchema, BadgeSchema as Ts_BadgeSchema, BarChartSchema as Ts_BarChartSchema, ChartDataSeries as Ts_ChartDataSeries, ChartSchema as Ts_ChartSchema, DataTableSchema as Ts_DataTableSchema, DrillDownConfig as Ts_DrillDownConfig, KbdSchema as Ts_KbdSchema, ListItem as Ts_ListItem, ListSchema as Ts_ListSchema, MarkdownSchema as Ts_MarkdownSchema, PivotTableSchema as Ts_PivotTableSchema, StaticTableColumn as Ts_StaticTableColumn, StatisticSchema as Ts_StatisticSchema, TableColumn as Ts_TableColumn, TableSchema as Ts_TableSchema, TimelineEvent as Ts_TimelineEvent, TimelineFeedItem as Ts_TimelineFeedItem, TimelineGanttItem as Ts_TimelineGanttItem, TimelineGanttItemBar as Ts_TimelineGanttItemBar, TimelineSchema as Ts_TimelineSchema, TreeViewSchema as Ts_TreeViewSchema, BreadcrumbItem as Ts_BreadcrumbItem, BreadcrumbSchema as Ts_BreadcrumbSchema } from '../data-display'; import type { AccordionItem as Ts_AccordionItem, AccordionSchema as Ts_AccordionSchema, CollapsibleSchema as Ts_CollapsibleSchema, ToggleGroupItem as Ts_ToggleGroupItem, ToggleGroupSchema as Ts_ToggleGroupSchema } from '../disclosure'; import type { EmptySchema as Ts_EmptySchema, LoadingSchema as Ts_LoadingSchema, ProgressSchema as Ts_ProgressSchema, SkeletonSchema as Ts_SkeletonSchema, SonnerSchema as Ts_SonnerSchema, SpinnerSchema as Ts_SpinnerSchema, ToasterSchema as Ts_ToasterSchema, ToastSchema as Ts_ToastSchema } from '../feedback'; import type { ButtonSchema as Ts_ButtonSchema, CalendarSchema as Ts_CalendarSchema, CheckboxSchema as Ts_CheckboxSchema, CodeEditorSchema as Ts_CodeEditorSchema, ComboboxOption as Ts_ComboboxOption, ComboboxSchema as Ts_ComboboxSchema, CommandGroup as Ts_CommandGroup, CommandItem as Ts_CommandItem, CommandSchema as Ts_CommandSchema, DatePickerSchema as Ts_DatePickerSchema, FieldCondition as Ts_FieldCondition, FieldValidationRules as Ts_FieldValidationRules, FileUploadSchema as Ts_FileUploadSchema, FormField as Ts_FormField, FormSchema as Ts_FormSchema, InputOTPSchema as Ts_InputOTPSchema, InputSchema as Ts_InputSchema, InputShorthandSchema as Ts_InputShorthandSchema, UiCalendarSchema as Ts_UiCalendarSchema, LabelSchema as Ts_LabelSchema, RadioGroupSchema as Ts_RadioGroupSchema, RadioOption as Ts_RadioOption, SelectOption as Ts_SelectOption, SelectSchema as Ts_SelectSchema, SliderSchema as Ts_SliderSchema, SwitchSchema as Ts_SwitchSchema, TextareaSchema as Ts_TextareaSchema, ToggleSchema as Ts_ToggleSchema } from '../form'; @@ -1853,7 +1853,6 @@ const MIRRORS = { 'data-display.zod.ts#ChartSchema': ChartSchema, 'data-display.zod.ts#DataTableSchema': DataTableSchema, 'data-display.zod.ts#DrillDownConfigSchema': DrillDownConfigSchema, - 'data-display.zod.ts#HtmlSchema': HtmlSchema, 'data-display.zod.ts#KbdSchema': KbdSchema, 'data-display.zod.ts#BarChartSchema': BarChartSchema, 'data-display.zod.ts#ListItemSchema': ListItemSchema, @@ -2066,7 +2065,6 @@ interface Declared { 'data-display.zod.ts#ChartSchema': Ts_ChartSchema; 'data-display.zod.ts#DataTableSchema': Ts_DataTableSchema; 'data-display.zod.ts#DrillDownConfigSchema': Ts_DrillDownConfig; - 'data-display.zod.ts#HtmlSchema': Ts_HtmlSchema; 'data-display.zod.ts#KbdSchema': Ts_KbdSchema; 'data-display.zod.ts#BarChartSchema': Ts_BarChartSchema; 'data-display.zod.ts#ListItemSchema': Ts_ListItem; @@ -4839,6 +4837,11 @@ const EXCLUSIONS: Readonly> = { // former pair `cloud.zod.ts#CloudPlanStatusSchema` left `MIRRORS` with it. 'cloud.zod.ts#RetiredCloudPlanStatusNodeSchema': 'a RETIRED node type refusal arm (objectui#12954), not a mirror — its only member is the `type` literal it refuses on, and the TS half of that retirement is the ABSENCE of a `cloud:plan-status` member in `AnySchema`', + // objectui#13449 — the same kind of arm for the retired `html` literal, + // pinned in `./html-node-retired-13449.test.ts`. Its former pair + // `data-display.zod.ts#HtmlSchema` left `MIRRORS` with it. + 'data-display.zod.ts#RetiredHtmlNodeSchema': + 'a RETIRED node type refusal arm (objectui#13449), not a mirror — its only member is the `type` literal it refuses on, and the TS half of that retirement is the ABSENCE of an `html` member in `DataDisplaySchema`', 'ai.zod.ts#AIComponentSchema': "a union OVER the mirrors, not an object of its own — its members are checked individually above", 'product-blocks.zod.ts#ProductBlockComponentSchema': @@ -5310,7 +5313,7 @@ const ZOD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'zod'); * MINUEND under it had moved. Nothing failed on any of those days, because nothing * compared the registry to a number. objectui#7433 is that absence, not the digits. */ -const EXPECTED_MIRROR_PAIRS = 205; +const EXPECTED_MIRROR_PAIRS = 204; /** * A ledger this file can size from its own AST. `WiderThanDeclared` joined at diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 147be3454b..2266a16c0b 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -3672,6 +3672,13 @@ export interface BarChartSchema extends BaseSchema { /** * Union type of all data display schemas + * + * ⛔ `HtmlSchema` (`type: 'html'`, a raw-HTML node) is RETIRED (objectui#13449): + * its renderer injected `html` unsanitised and `@objectstack/spec` declares no + * such component. The TypeScript half of the retirement is its ABSENCE here; + * the zod half is `RetiredHtmlNodeSchema` in `zod/data-display.zod.ts`, which + * refuses a `type: "html"` node by name. The `html` FIELD type + * (`HtmlFieldMetadata`, `field-types.ts`) is a different layer and stays. */ export type DataDisplaySchema = | AlertSchema @@ -3685,69 +3692,8 @@ export type DataDisplaySchema = | ChartSchema | PivotTableSchema | TimelineSchema - | HtmlSchema | StatisticSchema | BreadcrumbSchema | KbdSchema | BarChartSchema; -/** - * Raw HTML component - */ -export interface HtmlSchema extends BaseSchema { - type: 'html'; - /** - * The HTML content string - */ - html: string; - /** - * REFUSED BY NAME (objectui#9256, ADR-0049) — `html` reads NEITHER content - * channel: no renderer read consumes `body` or `children` for this node. - * - * MEASURED with the TypeScript TYPE CHECKER and not with grep, across all 24 - * packages that register components plus the generic traversers — a docblock - * mention is not a read, and `body: schema.requestBody` in - * `packages/plugin-chatbot/src/renderer.tsx` is the kind of prefix hit grep - * scores. Every read is filed under the TYPE of the object it is read from; - * this declaration carries none. What the renderer DOES read off this node: - * `html` (in `packages/components/src/renderers/basic/html.tsx`). - * - * Before objectui#9256 tombstoned them here, `body` and `children` were both - * inherited-and-optional from {@link BaseSchema} — so authoring either here - * type-checked, parsed green through `.passthrough()`, and rendered NOTHING: - * no render-time error or warning and no element; only the parser tier's - * `not-a-container` warning (objectui#9910) noticed it. objectui#6771 has since retired `body` on - * `BaseSchema` itself; `BaseSchema` still declares `children`, so this node's - * own tombstone is what refuses it here. `SchemaRenderer` strips both keys - * out of the props bag it spreads, so neither reaches the component by - * another route either. - * - * @deprecated Not a channel `html` reads — nothing renders it. - */ - body?: never; - /** - * REFUSED BY NAME (objectui#9256, ADR-0049) — `html` reads NEITHER content - * channel: no renderer read consumes `body` or `children` for this node. - * - * MEASURED with the TypeScript TYPE CHECKER and not with grep, across all 24 - * packages that register components plus the generic traversers — a docblock - * mention is not a read, and `body: schema.requestBody` in - * `packages/plugin-chatbot/src/renderer.tsx` is the kind of prefix hit grep - * scores. Every read is filed under the TYPE of the object it is read from; - * this declaration carries none. What the renderer DOES read off this node: - * `html` (in `packages/components/src/renderers/basic/html.tsx`). - * - * Before objectui#9256 tombstoned them here, `body` and `children` were both - * inherited-and-optional from {@link BaseSchema} — so authoring either here - * type-checked, parsed green through `.passthrough()`, and rendered NOTHING: - * no render-time error or warning and no element; only the parser tier's - * `not-a-container` warning (objectui#9910) noticed it. objectui#6771 has since retired `body` on - * `BaseSchema` itself; `BaseSchema` still declares `children`, so this node's - * own tombstone is what refuses it here. `SchemaRenderer` strips both keys - * out of the props bag it spreads, so neither reaches the component by - * another route either. - * - * @deprecated Not a channel `html` reads — nothing renders it. - */ - children?: never; -} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 1f3daadff0..2eb3e2e3a0 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -310,7 +310,6 @@ export type { TimelineGanttItemBar, TimelineSchema, KbdSchema, - HtmlSchema, StatisticSchema, BarChartSchema, DataDisplaySchema, diff --git a/packages/types/src/registry.ts b/packages/types/src/registry.ts index 513fafdac0..17f1ca923c 100644 --- a/packages/types/src/registry.ts +++ b/packages/types/src/registry.ts @@ -53,7 +53,6 @@ import type { TreeViewSchema, ChartSchema, TimelineSchema, - HtmlSchema, StatisticSchema, BarChartSchema, } from './data-display.js'; @@ -221,7 +220,11 @@ export interface SchemaRegistry { 'tree-view': TreeViewSchema; 'chart': ChartSchema; 'timeline': TimelineSchema; - 'html': HtmlSchema; + // ⛔ `'html'` RETIRED (objectui#13449): the raw-HTML node injected its `html` + // string unsanitised and `@objectstack/spec` declares no such component, so + // `ComponentType` no longer offers it. The zod half is + // `RetiredHtmlNodeSchema` in `zod/data-display.zod.ts`, which refuses a + // `type: "html"` node by name. 'statistic': StatisticSchema; 'bar-chart': BarChartSchema; diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index 1356d380f9..c66d295cfb 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -254,12 +254,14 @@ function validateComponent(config: unknown) { - `InputOTPSchema`, `ComboboxSchema`, `LabelSchema` - `CommandSchema`, `FormSchema` -### Data Display Components (15) +### Data Display Components (14) - `AlertSchema`, `BadgeSchema`, `AvatarSchema` - `ListSchema`, `TableSchema`, `DataTableSchema` - `MarkdownSchema`, `TreeViewSchema`, `ChartSchema` - `TimelineSchema`, `BreadcrumbSchema`, `PivotTableSchema` -- `KbdSchema`, `HtmlSchema`, `StatisticSchema` +- `KbdSchema`, `StatisticSchema` + +`html` is RETIRED (objectui#13449): its renderer injected the authored markup unsanitised and `@objectstack/spec` declares no such component. Its arm stays in `DataDisplaySchema` as a refusal, not exported: a node naming it is refused by name at `type`, with the remedy of an `html` / `richtext` field or a `kind: 'html'` page. ### Feedback Components (8) - `LoadingSchema`, `ProgressSchema`, `SkeletonSchema` diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 94b7e367ae..66144bda50 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -24,7 +24,7 @@ import { ReportSchema as SpecReportSchema, } from '@objectstack/spec/ui'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; -import { aliasKeyRefusal, handlerKeyRefusal, neitherContentChannelGuidance, retirementTombstone } from './tombstone.zod.js'; +import { aliasKeyRefusal, handlerKeyRefusal, neitherContentChannelGuidance, retiredNodeType, retirementTombstone } from './tombstone.zod.js'; import { TABLE_COLUMN_TYPES, type TreeNode, type TimelineFeedItem, type TimelineGanttItem } from '../data-display.js'; import { stripImportedDefaults } from './imported-defaults.js'; @@ -1646,26 +1646,40 @@ export const KbdSchema = BaseSchema.extend({ }); /** - * HTML Schema - Raw HTML renderer + * ⛔ The `html` node type is RETIRED (objectui#13449) — this is its NAMED + * REFUSAL, the half a deletion would not have given. + * + * Its renderer (`@object-ui/components`, `ui:html` plus the bare `html` key) + * wrote the authored `html` string into the page through + * `dangerouslySetInnerHTML` with no sanitiser, so anyone who could write + * metadata — a `kind:'html'` page included, which compiled `` + * with no diagnostic — could put script-bearing markup in front of every + * viewer. `@objectstack/spec` declares no `html` component, so it went rather + * than gaining a sanitiser. `HtmlSchema`'s member-by-member arm went with the + * TypeScript interface; what stays is an arm claiming the literal, in the shape + * of `complex.zod.ts#RetiredKanbanNodeSchema` (objectui#8802, maintainer ruling + * 2026-09-09): deleting the arm would already refuse, but with the union's + * remedy-free `Invalid input`. + * + * ⛔ The `html` FIELD type (`HtmlFieldMetadata`, a form or record field whose + * value is rendered through the sanitised fields path) is a different layer and + * is untouched: field metadata never parses against this node union. + * + * Deliberately NOT exported from `index.zod.ts`: nothing outside this package + * parses against a refusal arm. No TypeScript twin either — the TS half of the + * retirement is the ABSENCE of an `html` member in `DataDisplaySchema`. + * + * Pinned in `../__tests__/html-node-retired-13449.test.ts`. */ -export const HtmlSchema = BaseSchema.extend({ - type: z.literal('html'), - html: z.string().describe('HTML content'), - body: retirementTombstone( - 'REFUSED (objectui#9256, ADR-0049) — `html` reads NEITHER content channel: measured with the ' - + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' - + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' - + 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `html`.', - ), - children: retirementTombstone( - 'REFUSED (objectui#9256, ADR-0049) — `html` reads NEITHER content channel: measured with the ' - + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' - + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' - + 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `html`.', - ), -}); +export const RetiredHtmlNodeSchema = retiredNodeType( + 'html', + 'Its renderer wrote the authored `html` string into the page unsanitised, and ' + + '`@objectstack/spec` declares no `html` component (objectui#13449). For rich text ' + + 'a user edits, declare an `html` or `richtext` FIELD on the object — its value ' + + 'renders through the sanitised fields path; for authored page copy, write a ' + + '`kind: \'html\'` page, whose JSX is parsed against a tag whitelist and never ' + + 'injected as markup.', +); /** objectui#9256 (family-D re-measure): ONE refusal string for both content channels of `BarChartSchema`. */ const BAR_CHART_NEITHER_CHANNEL = neitherContentChannelGuidance( @@ -1722,7 +1736,8 @@ const DataDisplaySchemaInferred = z.discriminatedUnion('type', [ PivotTableSchema, TimelineSchema, KbdSchema, - HtmlSchema, + // objectui#13449 — the retired `html` literal's named refusal. + RetiredHtmlNodeSchema, BarChartSchema, ]); diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 740668203b..3f618a2080 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -205,7 +205,11 @@ export { TimelineGanttItemBarSchema, TimelineSchema, KbdSchema, - HtmlSchema, + // ⛔ `HtmlSchema` RETIRED with the `html` node type (objectui#13449). + // `RetiredHtmlNodeSchema` takes its place inside `DataDisplaySchema` so an + // authored `type: "html"` is refused BY NAME with its remedy; it is + // deliberately NOT exported — nothing outside this package parses against a + // refusal arm. BarChartSchema, DataDisplaySchema, } from './data-display.zod.js'; From 3567faff27f4529f880bd24fde9504a3c1175ce3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:11:51 +0000 Subject: [PATCH 2/6] test: move the counts and pins the html retirement shifted (objectui#13449) Lower the CLI's namespaced refused-at-type pin by `ui:html`, pin the strict face's by-name refusal of `html`, and re-derive the DOM-leak sweep's docblock counts without the `ui:html` target. Claude-Session: https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ Co-authored-by: Claude --- .../__tests__/widget-dom-leak-sweep.test.tsx | 39 ++++++++++--------- ...tered-types-validate-ratchet-10859.test.ts | 25 +++++++++++- .../__tests__/html-node-retired-13449.test.ts | 5 --- 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx b/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx index c09aa8b421..76d136d625 100644 --- a/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx +++ b/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx @@ -37,9 +37,9 @@ * | plugin-calendar | 3 | 0 | 0 | * | plugin-chatbot | 3 | 0 | 0 | * | plugin-dashboard | 7 | 0 | 0 | - * | components | 150 | 82 | 12 .. 15 | + * | components | 149 | 81 | 12 .. 15 | * - * **82 of 168 targets leak.** All of them are the `components` row, which is + * **81 of 167 targets leak.** All of them are the `components` row, which is * objectui#5574 and is covered in its own section below. The four packages * objectui#4425 named now read zero. Three migration steps have closed their * rows since the phase-1 measurement: @@ -128,7 +128,7 @@ * have failed. These two differ in exactly one thing: which namespace the extra * widget went into. * - * ### The reading — 119 of 158 ON ARRIVAL, in seven shapes; 82 today + * ### The reading — 119 of 158 ON ARRIVAL, in seven shapes; 81 today * * The card named four candidates (`flex`, `stack`, `container`, `text`) and was * careful to call them candidates. All four leaked. So did 115 others, and the @@ -145,9 +145,10 @@ * so their rows had to go for the gate to pass — the two-way expiry below, * working exactly once it had something to expire. Later slices took more, * and objectui#5632's `ui:sidebar-trigger` took the last shape that named a - * single renderer: **82 rows in FOUR shapes** remain, on a target set of 150 - * today (it grew past 158, and objectui#10859 batch 8 phase 2d took the ten - * retired `ui:sidebar-*` part keys out of it). What the arrival reading measured is preserved here in prose and in + * single renderer: **81 rows in FOUR shapes** remain, on a target set of 149 + * today (it grew past 158, objectui#10859 batch 8 phase 2d took the ten + * retired `ui:sidebar-*` part keys out of it, and objectui#13449 took the + * retired raw-HTML `ui:html`). What the arrival reading measured is preserved here in prose and in * the burn-down note on {@link COMPONENTS_LEAK_GROUPS}; what the gate asserts * is always current truth, which is the whole point of not writing dates into * a ledger. @@ -615,7 +616,7 @@ const CALENDAR_OBJECT_EXTRAS = { * an ordinary authored prop while doing it. * * Measured on the tree this landed on: 152 of 158 targets matched it. - * Today 147 of 150 targets match it; the other three are in + * Today 146 of 149 targets match it; the other three are in * {@link READY_OVERRIDE_REASONS}, each with the reason it cannot — * a recorded limitation with its own two-way assertion below, never a quiet * exemption (the `omitCanaries` discipline, applied to readiness). @@ -908,8 +909,8 @@ const TARGETS: Readonly> = { // key onto this package's own namespace; the renderer is the same one. { type: 'plugin-dashboard:dashboard', ready: '.grid.auto-rows-min' }, ], - // objectui#5574 — 150 targets, built above rather than spelled here because - // 133 of them need nothing but the shared readiness class. + // objectui#5574 — 149 targets, built above rather than spelled here because + // 132 of them need nothing but the shared readiness class. components: COMPONENTS_TARGETS, }; @@ -1002,7 +1003,7 @@ interface LedgerEntry { /* ── objectui#5574: the `packages/components` reading, as a LEDGER ─────────── */ /** - * 82 of the 150 `packages/components` targets leak, and they do it in exactly + * 81 of the 149 `packages/components` targets leak, and they do it in exactly * FOUR shapes (119 targets in seven shapes did on arrival; see the burn-down * note below — and note the arrival count of shapes read `eight` here until * objectui#5632 counted them: the groups were seven, and the card's own table @@ -1107,8 +1108,8 @@ interface LedgerEntry { * leaking a NINTH attribute the gate fails, because the measured set no longer * equals the recorded one; if it stops leaking, the gate ALSO fails until the * row goes. An allowlist has neither property. Nothing below is skipped, - * `it.skip`-ed, quarantined or excluded from the sweep — all 150 targets render - * and all 150 are scanned on every run, the 68 clean ones included. + * `it.skip`-ed, quarantined or excluded from the sweep — all 149 targets render + * and all 149 are scanned on every run, the 68 clean ones included. */ /** @@ -1221,7 +1222,7 @@ const LEAK_LEDGER: Readonly> = { // set, so a regression there is a NEW leak on an unledgered target and fails // on the empty-report branch below, naming every attribute. - /* ── packages/components: 82 of 150 targets, in four measured shapes ───── */ + /* ── packages/components: 81 of 149 targets, in four measured shapes ───── */ ...Object.fromEntries( COMPONENTS_LEAK_GROUPS.flatMap((group) => group.targets.map((type) => [ @@ -1783,11 +1784,11 @@ const wordForCount = (count: number): string => COUNT_WORDS[count] ?? String(cou */ const DOCBLOCK_COUNTS = { /** `packages/components` targets swept: the plain types plus the specials. */ - componentsTargets: 150, + componentsTargets: 149, /** Of those, the ones needing nothing but the shared readiness class. */ - componentsPlainTypes: 133, + componentsPlainTypes: 132, /** Ledgered `packages/components` rows — targets with a recorded leak. */ - componentsLedgered: 82, + componentsLedgered: 81, /** The complement: swept, scanned and clean. */ componentsClean: 68, /** Measured shapes the ledgered rows fall into. */ @@ -1795,15 +1796,15 @@ const DOCBLOCK_COUNTS = { /** Registry prefixes `packages/components` owns. */ componentsPrefixes: 5, /** Targets the shared readiness selector reaches. */ - componentsReadyMatched: 147, + componentsReadyMatched: 146, /** The rest, each with a recorded reason it cannot. */ componentsReadyOverrides: 3, /** Attributes leaked by the shape with the most members. */ commonestShapeAttributes: 14, /** Every target this sweep renders, all five packages. */ - allTargets: 168, + allTargets: 167, /** Every ledgered row, all five packages. */ - allLedgered: 82, + allLedgered: 81, } as const; type CountName = keyof typeof DOCBLOCK_COUNTS; diff --git a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts index e41bfebce9..b24788d653 100644 --- a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts +++ b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts @@ -173,8 +173,11 @@ const REFUSED_AT_TYPE = 0; * refused at `type` by design so the bare spelling stays the one authorable * key (maintainer ruling 6312301934), a named stopgap raise. The raise covers * these five keys only; any other rise is still a regression. + * objectui#13449: minus `ui:html`, the namespaced twin of the raw-HTML `html` + * node type, retired from the registry with its renderer; the armed bare `html` + * left with its arm, which became a refusal, so it moved nothing here. */ -const NAMESPACED_REFUSED_AT_TYPE = 307; +const NAMESPACED_REFUSED_AT_TYPE = 306; /** The bare registry keys — the population the card measured. */ const BARE_KEYS = KNOWN_SCHEMA_TYPES.filter((key) => !key.includes(':')); @@ -634,6 +637,26 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet expect(refusedAtType('cloud:no-such-widget-12954')).toBe(true); }); + it('refuses `html` BY NAME at `type` — its registration left and its arm became a refusal (objectui#13449)', () => { + // The bare key was armed and `ui:html` was not, so the bare count did not + // move and the namespaced pin above fell by one. The arm stays as + // `retiredNodeType()`'s refusal, so the strict face — the one `objectui + // validate` judges through — answers with a `custom` issue at `type`, not + // the generic `invalid_union` `refusedAtType` reads. + expect(BARE_KEYS).not.toContain('html'); + expect(NAMESPACED_KEYS).not.toContain('ui:html'); + const result = validateAuthoredDocument({ type: 'html', html: '

x

' }); + expect(result.success).toBe(false); + const named = (result.success ? [] : result.error.issues).filter( + (issue) => issue.code === 'custom' && issue.path.length === 1 && issue.path[0] === 'type', + ); + expect(named).toHaveLength(1); + expect(named[0]?.message).toContain('`html` is a RETIRED node type'); + expect(refusedAtType('html')).toBe(false); + // Control: an unknown bare literal still takes the generic path. + expect(refusedAtType('no-such-node-13449')).toBe(true); + }); + it('counts `cloud:workspace-timezone-notice` armed — it registered WITH its arm (objectui#11930)', () => { // One registry key (`skipFallback: true`, so no bare `workspace-timezone-notice`), // armed in `@object-ui/types/zod` in the same change, so the pin above did not move. diff --git a/packages/types/src/__tests__/html-node-retired-13449.test.ts b/packages/types/src/__tests__/html-node-retired-13449.test.ts index 36ef3e9b67..52ee7c7c9a 100644 --- a/packages/types/src/__tests__/html-node-retired-13449.test.ts +++ b/packages/types/src/__tests__/html-node-retired-13449.test.ts @@ -104,11 +104,6 @@ describe('suite 1 — a `type: "html"` node is REFUSED, by name (objectui#13449) it('refuses a bare node too, whatever else it carries', () => { expect(refusals({ type: 'html' }).filter((f) => f.code === 'custom' && f.path === 'type')).toHaveLength(1); }); - - it('refuses it nested inside a container, where a page would place it', () => { - const found = refusals({ type: 'flex', children: [RETIRED_NODE] }); - expect(found.some((f) => f.code === 'custom' && f.message.includes('`html` is a RETIRED node type'))).toBe(true); - }); }); describe('suite 2 — the refusal is not a validator that turned strict', () => { From 2b933896017c56e6a0e7c48b22e8cbdabdada0a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:15:47 +0000 Subject: [PATCH 3/6] chore: declare the html retirement and exempt the field-type doc literal (objectui#13449) Add the breaking (minor-marked) changeset with the FROM/TO migration, and declare the `html` FIELD type taught by content/docs/fields/rich-text.mdx as a field-vocabulary exemption in check:doc-types: it passed only because the retired node type registered the same bare key. Claude-Session: https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ Co-authored-by: Claude --- .changeset/13449-retire-html-renderer.md | 19 +++++++++++++++++++ scripts/check-doc-component-types.mjs | 6 ++++++ 2 files changed, 25 insertions(+) create mode 100644 .changeset/13449-retire-html-renderer.md diff --git a/.changeset/13449-retire-html-renderer.md b/.changeset/13449-retire-html-renderer.md new file mode 100644 index 0000000000..4b494a49db --- /dev/null +++ b/.changeset/13449-retire-html-renderer.md @@ -0,0 +1,19 @@ +--- +'@object-ui/components': minor +'@object-ui/types': minor +'@object-ui/core': minor +'@object-ui/cli': minor +--- + +The raw-HTML `html` node type is retired (objectui#13449). Its renderer wrote the authored `html` string into the page through `dangerouslySetInnerHTML` with no sanitiser, so anyone who could write metadata could put script-bearing markup in front of every viewer — a `kind: 'html'` page included, which compiled an `html` tag carrying an `html` attribute with no diagnostic. `@objectstack/spec` declares no `html` component, so it is removed rather than sanitised, at once, with no deprecation alias. This is breaking for a page that still names it; it is marked `minor` under this repository's version policy. + +- **SDUI node `html`**, also registered as `ui:html`. FROM: `@object-ui/components` registered both keys. TO: neither is registered. A JSON node that still names it draws the unknown-component panel, a `kind: 'html'` page that writes the tag fails to compile with `forbidden-tag` and `unknown-component`, and `objectui check` no longer counts either key among the known component types. +- **Public block `html`** in `PUBLIC_BLOCKS` (`@object-ui/core`). FROM: listed as the curated tier's escape hatch, so `getPublicConfigs()` and the published `sdui.manifest.json` carried it. TO: not listed, so neither names it. +- **Schema `HtmlSchema`** in `@object-ui/types`: the TypeScript interface from the package entry, with its `DataDisplaySchema` member and its `SchemaRegistry` row (so `ComponentType` no longer offers `'html'`), and the zod arm from `@object-ui/types/zod`. FROM: exported, and a `type: "html"` node validated against the arm. TO: there is no such export, so importing it fails to compile. `DataDisplaySchema` keeps a refusal arm for the literal, which is not exported, so `objectui validate` refuses a `type: "html"` node BY NAME at `type`, and the message names the remedy. + +The `html` FIELD type (`HtmlFieldMetadata`, drawn by `field:html`) is a different layer and stays: its value displays through the sanitised fields path. + +Fix for a page that still names it: in place of `{ "type": "html", "html": "…" }`, declare rich text a user edits as an `html` or `richtext` field on the object, whose value renders sanitised, and write authored page copy as a `kind: 'html'` page. + +Clause-②: no +Metadata-protocol: narrowed diff --git a/scripts/check-doc-component-types.mjs b/scripts/check-doc-component-types.mjs index ab0c81ef3d..4f90b7a6dd 100644 --- a/scripts/check-doc-component-types.mjs +++ b/scripts/check-doc-component-types.mjs @@ -1002,6 +1002,12 @@ const DOC_TYPE_EXEMPTIONS = { 'fallback, which is the only reason this value used to pass here as a registered key.', }, 'content/docs/fields/rich-text.mdx': { + html: + 'FIELD type documented by this page: `HtmlFieldMetadata` (`FieldMetadata`, ' + + 'packages/types/src/field-types.ts) declares the literal, and the form renders it through ' + + '`field:html`, whose value displays through the sanitised fields path. Not a node type. ' + + 'objectui#13449 retired the raw-HTML `html` NODE type (`ui:html` and its bare key), which is ' + + 'the only reason this value used to pass here as a registered key.', richtext: 'FIELD type documented by this page: `RichtextFieldMetadata` (`FieldMetadata`, ' + 'packages/types/src/field-types.ts) declares the literal, and the form renders it through ' + From 3c6faeacf0c8e37b0a94965f0b3a875617e5ab0b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:27:47 +0000 Subject: [PATCH 4/6] docs(guide): say there is no raw-HTML block on either page tier (objectui#13449) Claude-Session: https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ Co-authored-by: Claude --- content/docs/guide/react-pages.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/content/docs/guide/react-pages.md b/content/docs/guide/react-pages.md index ba0a1bd8f2..05c4fe5dc9 100644 --- a/content/docs/guide/react-pages.md +++ b/content/docs/guide/react-pages.md @@ -350,6 +350,12 @@ look uniform — `` is not registered, only The plain wrapper on this tier is ``. `
` is deprecated here as it is in JSON, and the page is refused at compile time with an error that names `box`. +There is no raw-HTML block on this tier, or on any other. The `html` node type, +which wrote an authored HTML string into the page unsanitised, is retired, so an +`` tag is refused at compile time as an unknown component. Rich text a user +edits belongs in an `html` or `richtext` field, whose value renders through the +sanitised fields path. + Use it for anything author- or AI-generated. Expressions are limited to what the schema supports (`${data.x}`), and there is no local state or event handling beyond the action system. From bfd37223da3589b7e724670f97fb4c09499a9841 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:22:39 +0000 Subject: [PATCH 5/6] test(types): anchor the ComponentType non-vacuity pin on a key the registry map declares (objectui#13449) Claude-Session: https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ Co-authored-by: Claude --- packages/types/src/__tests__/html-node-retired-13449.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/__tests__/html-node-retired-13449.test.ts b/packages/types/src/__tests__/html-node-retired-13449.test.ts index 52ee7c7c9a..2750a01d2b 100644 --- a/packages/types/src/__tests__/html-node-retired-13449.test.ts +++ b/packages/types/src/__tests__/html-node-retired-13449.test.ts @@ -55,7 +55,7 @@ type _DataDisplayHasNoHtmlArm = Assert>; // Non-vacuity on the same instruments: a surviving sibling IS selectable. type _DataDisplayKeepsKbd = Assert>, false>>; -type _ComponentTypeKeepsKbd = Assert>; +type _ComponentTypeKeepsStatistic = Assert>; // The FIELD layer is a different declaration and stays. type _HtmlFieldStays = Assert>; From 0841978a548e74c745e103544717496f1ddb7273 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:42:45 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix(changeset):=20declare=20the=20Clause-?= =?UTF-8?q?=E2=91=A1=20arm=20as=20no=20(narrowing)=20(objectui#13449)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ Co-authored-by: Claude --- .changeset/13449-retire-html-renderer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/13449-retire-html-renderer.md b/.changeset/13449-retire-html-renderer.md index 4b494a49db..d5cf2f6b1b 100644 --- a/.changeset/13449-retire-html-renderer.md +++ b/.changeset/13449-retire-html-renderer.md @@ -15,5 +15,5 @@ The `html` FIELD type (`HtmlFieldMetadata`, drawn by `field:html`) is a differen Fix for a page that still names it: in place of `{ "type": "html", "html": "…" }`, declare rich text a user edits as an `html` or `richtext` field on the object, whose value renders sanitised, and write authored page copy as a `kind: 'html'` page. -Clause-②: no +Clause-②: no (narrowing) Metadata-protocol: narrowed