diff --git a/.changeset/chatbot-dom-prop-whitelist-4431.md b/.changeset/chatbot-dom-prop-whitelist-4431.md new file mode 100644 index 000000000..026921466 --- /dev/null +++ b/.changeset/chatbot-dom-prop-whitelist-4431.md @@ -0,0 +1,15 @@ +--- +'@object-ui/core': minor +'@object-ui/plugin-chatbot': patch +'@object-ui/fields': patch +--- + +`chatbot` and `chatbot-enhanced` now pass only whitelisted DOM props to their host element (objectui#4431) + +Both registrations destructured `schema` and `className` and forwarded everything else. `SchemaRenderer` hands a registered component the authored node's own keys, the contents of its `props` container, the ARIA it resolved and the host's trailing props — so all of it became attributes on the chat root `div`, because React passes unknown lowercase attributes through in silence and stringifies object values. Measured through the real SDUI path with a data-source adapter attached: **14 non-DOM attributes on each widget**, including `datasource="[object Object]"` (the injected adapter, which only appears on a deployment that really loads data) and a camelCase `arialabel` sitting next to the resolved `aria-label`, so the element carried each ARIA value twice under two spellings — one of them meaningless to assistive technology. + +Both are now consume-or-whitelist: configuration is read off `schema` as before, the evaluated `disabled` verdict is consumed by name, and only `toDomProps`' output reaches the element. The resolved `aria-label` / `aria-describedby`, `role`, `id`, `tabIndex` and the `data-*` family still arrive — dropping them would have been an accessibility regression dressed as a leak fix, so the pin asserts the delivered set exactly, not just the absent one. `chatbot-floating` is untouched: its content mounts through a portal and its root never spread. + +`@object-ui/core` gains the shared executor this migration needs (`utils/dom-props.ts`): `toDomProps` for the SDUI widget contract, plus `pickDomProps` — the mechanism — for a package whose own contract declares a different key set. That is the objectui#4409 dependency direction: plugin packages declare `@object-ui/core` and must not grow a dependency on `@object-ui/fields` to reach a whitelist. + +`@object-ui/fields` keeps its own key list and its compile-time bindings, and now executes them through core's mechanism. Its behaviour is unchanged and its exported `DomProps

` is the same structural type. The two lists differ for measured reasons and no longer can drift silently: `name` and `disabled` are legal only on form controls, which is what every field widget renders and what `FieldWidgetComponentProps` declares, while `role` is resolved by `SchemaRenderer` for every SDUI node and is not part of the field contract. A new assertion binds every shared key in both directions, with `role` named as the single deliberate exception. 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 a9fc7a1f4..be881eb7d 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 @@ -508,34 +508,6 @@ interface LedgerEntry { } const LEAK_LEDGER: Readonly> = { - /* ── plugin-chatbot: the whole node, minus the two keys it destructures ──── */ - // - // Both registrations are `({ schema, className, ...props }) => … {...props}`, - // so everything except those two becomes an attribute — including the injected - // `datasource` adapter, which only a host that really loads data supplies. - // `chatbot-floating` is NOT here: it is clean. - 'plugin-chatbot:chatbot': { - attributes: [ - 'ariadescribedby', 'arialabel', 'bind', 'colorvariant', 'datasource', - 'events', 'name', 'props', 'reference_to', 'zzcanary', 'zzcanarycamel', - 'zzcanarynum', 'zzcanaryobj', 'zzcanaryprop', - ], - reason: - 'renderer.tsx destructures only `schema` and `className` before spreading ' + - 'the rest onto the chatbot root; every other injected and authored key ' + - 'becomes a DOM attribute.', - issue: 'objectui#4431', - }, - 'plugin-chatbot:chatbot-enhanced': { - attributes: [ - 'ariadescribedby', 'arialabel', 'bind', 'colorvariant', 'datasource', - 'events', 'name', 'props', 'reference_to', 'zzcanary', 'zzcanarycamel', - 'zzcanarynum', 'zzcanaryobj', 'zzcanaryprop', - ], - reason: 'the same spread shape as `plugin-chatbot:chatbot`, same 14 attributes.', - issue: 'objectui#4431', - }, - /* ── plugin-dashboard: the OPEN TAIL a deny-list cannot close ───────────── */ // // Read these two rows against what is NOT in them. Every one of the seven keys diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6dec0e32f..4938d6dbc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,12 @@ export * from './registry/PluginScopeImpl.js'; export * from './registry/WidgetRegistry.js'; export * from './validation/index.js'; export * from './builder/schema-builder.js'; +// The DOM pass-through whitelist of the SDUI widget prop contract +// (objectui#4425 phase 2): a registered widget's host element receives only +// what `toDomProps` passes — everything else is consumed or dropped. +// `@object-ui/fields` executes the SAME mechanism against its own declared key +// list (`pickDomProps`), so there is one judge, not two. +export * from './utils/dom-props.js'; export * from './utils/filter-converter.js'; export * from './utils/managedBy.js'; export * from './utils/extract-records.js'; diff --git a/packages/core/src/utils/dom-props.ts b/packages/core/src/utils/dom-props.ts new file mode 100644 index 000000000..923a44990 --- /dev/null +++ b/packages/core/src/utils/dom-props.ts @@ -0,0 +1,171 @@ +/** + * 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 RUNTIME EXECUTOR of the SDUI widget prop contract's DOM pass-through + * (objectui#4425 phase 2, promoting objectui#3291's whitelist from + * `packages/fields` to every registered SDUI widget). + * + * ## The defect this closes + * + * `SchemaRenderer` hands a registered component the authored node's own keys, + * the contents of its `props` container, the ARIA it resolved, and the host's + * trailing props — then a widget ending in a bare `{...props}` spread puts all + * of it on a DOM element. React passes unknown lowercase attributes through in + * complete silence and stringifies object values, so the failure is invisible: + * + * ``` + *

+ * ``` + * + * That is one real measurement, from `plugin-chatbot:chatbot` on the sweep gate + * (`packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx`). + * + * ## Why a whitelist, and not a list of keys to drop + * + * Measured, not assumed. objectui#4357/PR #4428 answered the same defect in + * `plugin-dashboard` with a deny-list: it closed every one of the seven keys it + * enumerated, and the sweep still found the OPEN TAIL leaking on the same two + * components — `zzcanary`, `reference_to`, an authored `props: { colorVariant }` + * — because a deny-list can only name what already exists. The set of keys an + * author may put on a node is unbounded; the set a widget may put on an element + * is declared. A deny-list bounded by enumeration cannot be finished; a + * whitelist bounded by declaration can. + * + * ## The set, and why each key is in it + * + * Every key here is either injected by `SchemaRenderer` for the whole SDUI + * contract, or a GLOBAL HTML attribute legal on whatever element a widget + * renders — this helper cannot know that element, so nothing element-specific + * belongs here: + * + * - `id` — global; `SchemaRenderer` carries the node's id. + * - `className` — global (`class`); the styling channel AGENTS.md #3 requires + * every widget to expose, and the carrier of ADR-0065's scope class. + * - `role` — the third member of `@objectstack/spec`'s `AriaPropsSchema`, + * resolved and injected by `SchemaRenderer.resolveAriaProps` alongside + * `aria-label` / `aria-describedby`. Withholding it would make an authored + * `role` type-check, read as supported, and silently never arrive — the + * DECLARED-BUT-NOT-DELIVERED failure this repo treats as first class. + * - `tabIndex`, `autoFocus` — global HTML attributes. + * - `onClick`, `onBlur`, `onFocus` — React synthetic handlers, which never + * become attributes at all. + * + * Plus the two OPEN families, matched by prefix so no per-attribute list is + * needed: `aria-*` (the resolved ARIA above — note the camelCase `ariaLabel` / + * `ariaDescribedBy` the author wrote do NOT match, which is the point: they are + * meaningless to assistive technology and the resolver already emitted the + * hyphenated forms) and `data-*` (open in HTML too, and how `data-obj-id` / + * `data-obj-type` / the debug attributes reach the element). + * + * ## Deliberately NOT here + * + * `name` and `disabled` are legal only on FORM CONTROLS, not on the container a + * typical SDUI widget renders — `name` on a `
` is exactly one of the 14 + * leaked attributes this closes. `@object-ui/fields` declares both, because + * every widget there renders a control and its contract + * (`FieldWidgetComponentProps`) says so; that package keeps its own declared key + * list and calls {@link pickDomProps} with it. One MECHANISM, two DECLARATIONS, + * each bound to the contract it executes — not two judges. + * + * A semantic prop is CONSUMED, never forwarded: a widget that can be disabled + * reads `disabled` and applies it (see `plugin-chatbot`'s registrations), which + * is what keeps `disabledOn` working without putting `disabled="true"` on a + * `
`. + * + * Deliberate DOM pass-through beyond this set stays available the objectui#4435 + * way — DECLARE it (`interface Props extends HTMLAttributes`) + * and forward it by name. Do not reopen the spread (AGENTS.md #0.1: fix the + * contract, never widen the consumer). + */ +export const SDUI_DOM_PASS_THROUGH_KEYS = [ + 'id', + 'className', + 'role', + 'tabIndex', + 'autoFocus', + 'onClick', + 'onBlur', + 'onFocus', +] as const; + +/** A key {@link toDomProps} forwards by name (the open families are prefixes). */ +export type SduiDomPassThroughKey = (typeof SDUI_DOM_PASS_THROUGH_KEYS)[number]; + +/** + * `aria-*` and `data-*` are open families — open in HTML, and open in the + * widget contract — so they are matched by prefix rather than enumerated. + * + * Note what this does NOT match: `dataSource`. The injected data-source ADAPTER + * is not a `data-` attribute, and a prefix test that accepted it would put + * `datasource="[object Object]"` on the element of every data-bound widget — + * the one leak a schema-only measurement cannot see, because it only appears + * when a host really supplies an adapter (objectui#4428). + */ +export function isOpenDomAttributeFamily(key: string): boolean { + return key.startsWith('data-') || key.startsWith('aria-'); +} + +/** The subset of `P` a whitelist of `K` forwards, with each key's declared type. */ +export type DomProps = Pick< + P, + Extract +> & { + [Key in Extract]: P[Key]; +}; + +/** + * The MECHANISM: keep the keys a caller's contract declares as DOM-safe plus + * the two open families, drop everything else. + * + * Exported for the one caller whose contract declares a different set — + * `@object-ui/fields`, whose widgets render form controls and whose + * `FieldWidgetComponentProps` therefore declares `name` and `disabled` as DOM + * pass-through (objectui#3291). Everything else should call {@link toDomProps}, + * which is this function with the SDUI contract's own set already applied. + */ +export function pickDomProps

( + props: P, + keys: readonly K[], +): DomProps { + const allowed: ReadonlySet = new Set(keys); + const domProps: Record = {}; + for (const key of Object.keys(props)) { + if (allowed.has(key) || isOpenDomAttributeFamily(key)) { + domProps[key] = (props as Record)[key]; + } + } + return domProps as DomProps; +} + +/** + * Keep only what may legitimately become a DOM attribute on a registered SDUI + * widget's host element, and drop everything else — the injected `schema`, the + * data-source adapter, the authored node's metadata, the `props` container, and + * any extra key an author put on the node. + * + * Replaces the bare `{...props}` spread in a widget registration: + * + * ```tsx + * // before — forwards whatever `SchemaRenderer` handed it + * ({ schema, className, ...props }) => + * + * // after + * ({ schema, className, ...props }) => ( + * + * ) + * ``` + * + * Semantic props a widget INTERPRETS (`disabled`, and everything it reads off + * `schema`) are read as before; this function only governs what gets spread. + */ +export function toDomProps

(props: P): DomProps

{ + return pickDomProps(props, SDUI_DOM_PASS_THROUGH_KEYS); +} diff --git a/packages/fields/src/widgets/toDomProps.ts b/packages/fields/src/widgets/toDomProps.ts index b9f04a568..5d3c2fd75 100644 --- a/packages/fields/src/widgets/toDomProps.ts +++ b/packages/fields/src/widgets/toDomProps.ts @@ -6,6 +6,11 @@ * LICENSE file in the root directory of this source tree. */ +import { + pickDomProps, + type SduiDomPassThroughKey, + type DomProps as CoreDomProps, +} from '@object-ui/core'; import type { FieldWidgetComponentProps, FieldWidgetDomProps } from './types'; /** @@ -62,6 +67,34 @@ import type { FieldWidgetComponentProps, FieldWidgetDomProps } from './types'; * open spread. If a field node should be able to author one, DECLARE it on * `FieldWidgetComponentProps` first and add it here — do not reopen the spread * (AGENTS.md #0.1: fix the contract, never widen the consumer). + * + * ## Where the mechanism lives now, and why the key list stayed here + * + * objectui#4425 phase 2 promoted this whitelist to the SDUI widget contract + * generally, so the MECHANISM — filter by a declared key list plus the `aria-*` + * / `data-*` open families — moved to `@object-ui/core` + * (`utils/dom-props.ts`, `pickDomProps`), where every plugin package can reach + * it without depending on `@object-ui/fields` (the objectui#4409 + * dependency-direction method: `plugin-chatbot` declares `@object-ui/core` and + * does not declare this package). + * + * The KEY LIST did not move, because it is not the same list. Measured on + * objectui#4431: + * + * - `name` and `disabled` are here and NOT in the SDUI set: both are legal + * only on form controls, which is what every widget in this package renders + * and what `FieldWidgetComponentProps` declares. `name` on the `

` an + * SDUI container widget renders is a leak — one of the 14 attributes #4431 + * closed — and two widgets here already hand-strip it when they spread onto + * something that is not a control (`ObjectRefField`'s trigger, + * `FileField`'s dropzone). + * - `role` is in the SDUI set and NOT here: `SchemaRenderer` resolves it for + * every node from `@objectstack/spec`'s `AriaPropsSchema`, while this + * contract deliberately does not declare it (see "Deliberately NOT + * forwarded" above). + * + * So: one mechanism, two declarations, each bound by the assertions below to + * the contract it executes. Not two judges. */ const DOM_PASS_THROUGH_KEYS = [ /* ── The contract's own "DOM pass-through" block, verbatim ─────────────── */ @@ -126,22 +159,24 @@ type EveryDeclaredDomKeyIsForwarded = const _everyDeclaredDomKeyIsForwarded: EveryDeclaredDomKeyIsForwarded = true; void _everyDeclaredDomKeyIsForwarded; -const DOM_PASS_THROUGH: ReadonlySet = new Set(DOM_PASS_THROUGH_KEYS); - /** - * `aria-*` is declared on the contract as React's `AriaAttributes`; `data-*` - * is declared as an open template-literal family (open in HTML too, and the - * only open family the contract has). Both are matched by prefix so the helper - * needs no per-attribute list. + * Direction 3, added with the core lift (objectui#4431): every key of the SDUI + * contract's own whitelist that this contract also declares must be forwarded + * here too, so the two lists cannot silently drift apart. `role` is the ONE + * measured exception — the SDUI contract declares it, this one does not — and + * naming it in the `Exclude` is what makes the divergence a deliberate, + * reviewable act rather than an omission. + * + * Catches: a key added to the shared SDUI set that field widgets quietly stop + * delivering. */ -function isOpenDomFamily(key: string): boolean { - return key.startsWith('data-') || key.startsWith('aria-'); -} +type SharedSduiKey = Exclude; +type EverySharedSduiKeyIsForwarded = SharedSduiKey extends DomPassThroughKey ? true : never; +const _everySharedSduiKeyIsForwarded: EverySharedSduiKeyIsForwarded = true; +void _everySharedSduiKeyIsForwarded; /** The subset of `P` this helper forwards, with each key's declared type. */ -export type DomProps

= Pick> & { - [K in Extract]: P[K]; -}; +export type DomProps

= CoreDomProps; /** * Keep only what may legitimately become a DOM attribute, and drop everything @@ -165,11 +200,5 @@ export type DomProps

= Pick> & { * governs what gets spread. */ export function toDomProps

(props: P): DomProps

{ - const domProps: Record = {}; - for (const key of Object.keys(props)) { - if (DOM_PASS_THROUGH.has(key) || isOpenDomFamily(key)) { - domProps[key] = (props as Record)[key]; - } - } - return domProps as DomProps

; + return pickDomProps(props, DOM_PASS_THROUGH_KEYS); } diff --git a/packages/plugin-chatbot/src/__tests__/renderer.domProps.test.tsx b/packages/plugin-chatbot/src/__tests__/renderer.domProps.test.tsx new file mode 100644 index 000000000..0572782c8 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/renderer.domProps.test.tsx @@ -0,0 +1,215 @@ +/** + * 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 DOM pass-through contract of the two spreading chat registrations + * (objectui#4431, the first migration step of objectui#4425 phase 2). + * + * ## What these pin that the sweep gate cannot + * + * `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx` is the + * cross-package ratchet: it proves NOTHING unexplained arrives. These cases are + * the other half, in this package, next to the code: + * + * - the exact attribute SET on the host element — so a key that stops being + * delivered (`role`, `aria-label`, `data-obj-id`) is as red as a key that + * leaks. The sweep structurally cannot see that direction: it looks for + * attributes that arrive, not for ones that go missing. + * - the two items objectui#4431's triage flagged BY NAME, each asserted as a + * pair so neither half can drift: `datasource` (the injected adapter) gone + * while the widget still renders on its real path, and the camelCase + * `arialabel` / `ariadescribedby` gone while the RESOLVED `aria-label` / + * `aria-describedby` — the spellings assistive technology actually reads — + * stay. + * + * ## Why the adapter is attached + * + * `dataSource` is not a schema key: `SchemaRenderer` strips the node's own + * `dataSource` BINDING by name, and this is the injected ADAPTER a host hands + * the renderer. It therefore only reaches a widget on a deployment that really + * loads data — which is why PR #4428 shipped a six-key first pass measured + * against a schema-only fixture and missed exactly this key. A test that + * renders these widgets bare would repeat that mistake, so every render here + * goes through `SchemaRendererProvider` WITH an adapter. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Side-effect import: this is what registers the chat components. +import '../renderer'; + +/** The injected adapter. Answers empty so nothing lands in an error state. */ +const FAKE_ADAPTER = { + find: async () => [], + findOne: async () => null, + aggregate: async () => [], + count: async () => 0, + getObject: async () => null, +}; + +/** + * One node carrying every family the renderer hands a widget: authored SDUI + * metadata, the camelCase ARIA the resolver converts, the DOM identity keys + * that must survive, an open tail of keys no component declares, and a `props` + * container whose contents the renderer spreads separately. + */ +function canaryNode(type: string): Record { + return { + type, + id: 'chat-node', + name: 'canary_node', + className: 'zz-authored-class', + role: 'log', + tabIndex: 0, + bind: 'data.revenue', + events: { onClick: [{ action: 'navigate', params: { url: '/x' } }] }, + ariaLabel: 'Canary label', + ariaDescribedBy: 'canary-desc', + autoResponse: false, + zzcanary: 'CANARY-STR', + zzcanaryobj: { nested: true }, + reference_to: 'contacts', + props: { colorVariant: 'success', zzcanaryprop: 'CANARY-PROP' }, + }; +} + +/** Renders one registration through the real SDUI host and returns its root. */ +async function renderNode(type: string, ready: string): Promise { + render( + + + , + ); + await waitFor(() => { + if (!document.body.querySelector(ready)) { + throw new Error( + `${type}: \`${ready}\` never matched — the widget never reached its real ` + + `markup, so scanning it would measure nothing. Body was:\n` + + document.body.innerHTML.slice(0, 600), + ); + } + }); + const root = document.body.querySelector('[data-obj-id="chat-node"]'); + if (!root) { + throw new Error( + `${type}: no element carries data-obj-id — either the widget stopped ` + + `forwarding the renderer's own identity attributes, or it never rendered.`, + ); + } + return root; +} + +function attributeNames(element: Element): string[] { + return Array.from(element.attributes) + .map((attribute) => attribute.name) + .sort(); +} + +beforeAll(() => { + // `use-stick-to-bottom` (the enhanced composer's scroller) measures through + // ResizeObserver, which happy-dom does not implement. + (globalThis as Record).ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + }; + if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) { + (Element.prototype as unknown as { scrollIntoView: () => void }).scrollIntoView = () => {}; + } +}); + +afterEach(() => { + cleanup(); +}); + +const TARGETS: ReadonlyArray = [ + ['plugin-chatbot:chatbot', 'input'], + ['plugin-chatbot:chatbot-enhanced', 'textarea'], +]; + +describe.each(TARGETS)('%s host element (objectui#4431)', (type, ready) => { + it('carries EXACTLY the whitelisted DOM attributes — no more, no fewer', async () => { + const root = await renderNode(type, ready); + + // Exact set equality, the ledger's own discipline applied locally: a new + // leak fails, and so does a DOM key that silently stops being delivered. + // `style` is the component's own `max-height`, not pass-through. + expect(attributeNames(root)).toEqual([ + 'aria-describedby', + 'aria-label', + 'class', + 'data-obj-id', + 'data-obj-type', + 'id', + 'role', + 'style', + 'tabindex', + ]); + + // The whitelisted keys carry their real values — an empty pass-through + // would satisfy the set above only by also losing these. + expect(root).toHaveAttribute('id', 'chat-node'); + expect(root).toHaveAttribute('data-obj-type', type); + expect(root).toHaveAttribute('role', 'log'); + expect(root).toHaveAttribute('tabindex', '0'); + expect(root.className).toContain('zz-authored-class'); + }); + + it('drops the injected adapter — `datasource` never reaches the DOM', async () => { + const root = await renderNode(type, ready); + + // The named half… + expect(root).not.toHaveAttribute('datasource'); + // …and the mechanism half: an object that reaches an attribute is + // `String()`-ed, so a stringified object anywhere on the element means + // something non-DOM got through under some other name. + const stringified = Array.from(root.attributes).filter((attribute) => + attribute.value.includes('[object Object]'), + ); + expect(stringified.map((attribute) => `${attribute.name}="${attribute.value}"`)).toEqual([]); + }); + + it('drops the camelCase ARIA and keeps the resolved spellings', async () => { + const root = await renderNode(type, ready); + + // Meaningless to assistive technology — these are the authored keys, and + // the resolver already emitted the hyphenated forms from them. + expect(root).not.toHaveAttribute('arialabel'); + expect(root).not.toHaveAttribute('ariadescribedby'); + // The half that MUST survive: dropping these would be an a11y regression + // dressed as a leak fix. + expect(root).toHaveAttribute('aria-label', 'Canary label'); + expect(root).toHaveAttribute('aria-describedby', 'canary-desc'); + }); + + it('drops the SDUI metadata, the `props` container and the open tail', async () => { + const root = await renderNode(type, ready); + + for (const attribute of [ + // Injected / authored SDUI metadata. + 'bind', + 'events', + 'props', + 'schema', + // `name` is legal on a form control, not on the container these widgets + // render — it is one of the 14 attributes this card closed. + 'name', + // The open tail a deny-list structurally cannot enumerate. + 'zzcanary', + 'zzcanaryobj', + 'reference_to', + // The authored `props` container's contents. + 'colorvariant', + 'zzcanaryprop', + ]) { + expect(root, `${type} leaked ${attribute}`).not.toHaveAttribute(attribute); + } + }); +}); diff --git a/packages/plugin-chatbot/src/renderer.tsx b/packages/plugin-chatbot/src/renderer.tsx index 5b68db740..63b596f96 100644 --- a/packages/plugin-chatbot/src/renderer.tsx +++ b/packages/plugin-chatbot/src/renderer.tsx @@ -7,7 +7,7 @@ */ import { useMemo } from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, toDomProps } from '@object-ui/core'; import type { ChatbotSchema } from '@object-ui/types'; import { Chatbot } from './index'; import { ChatbotEnhanced } from './ChatbotEnhanced'; @@ -37,9 +37,29 @@ import { toRuntimeMessages } from './chatMessageAdapter'; * — the hook's own message shape (objectui#4424). A host callback that * declares `@object-ui/types`' `ChatMessage[]` still type-checks; naming * `ObjectChatMessage` is what lets it read the render-only keys. + * + * ## What reaches the host element (objectui#4431) + * + * These registrations receive far more than they author: `SchemaRenderer` hands + * a registered component the node's own keys, the contents of its `props` + * container, the ARIA it resolved, the evaluated `disabled` verdict, and the + * host's trailing props — including the injected data-source ADAPTER. Both + * registrations below used to destructure `schema` and `className` and forward + * ALL of the rest into `` / ``, whose props extend + * `HTMLAttributes` and spread the leftovers onto their root + * `

`. Measured: 14 non-DOM attributes each, `datasource="[object Object]"` + * and a meaningless camelCase `arialabel` among them. + * + * Per objectui#4425 phase 2, the fix is the whitelist, not another deny-list: + * everything is CONSUMED (read by name — the config off `schema`, the evaluated + * `disabled` off the injected props) or WHITELISTED (`toDomProps`, which keeps + * `id` / `className` / `role` / `tabIndex` / `aria-*` / `data-*` and drops the + * rest, including the adapter). `chatbot-floating` below is untouched: its + * content mounts through a portal and its root does not spread onto a host + * element, which the sweep gate measured as clean. */ -ComponentRegistry.register('chatbot', - ({ schema, className, ...props }: { schema: ChatbotSchema & { +ComponentRegistry.register('chatbot', + ({ schema, className, disabled: hostDisabled, ...props }: { schema: ChatbotSchema & { showTimestamp?: boolean; disabled?: boolean; userAvatarUrl?: string; @@ -51,7 +71,7 @@ ComponentRegistry.register('chatbot', autoResponseText?: string; autoResponseDelay?: number; onSend?: (content: string, messages: ObjectChatMessage[]) => void; - }; className?: string; [key: string]: any }) => { + }; className?: string; disabled?: boolean; [key: string]: any }) => { const { messages, isLoading, @@ -89,10 +109,17 @@ ComponentRegistry.register('chatbot', return ( ); }, @@ -242,8 +268,8 @@ ComponentRegistry.register('chatbot', ); // Register Enhanced Chatbot -ComponentRegistry.register('chatbot-enhanced', - ({ schema, className, ...props }: { schema: ChatbotSchema & { +ComponentRegistry.register('chatbot-enhanced', + ({ schema, className, disabled: hostDisabled, ...props }: { schema: ChatbotSchema & { enableMarkdown?: boolean; enableFileUpload?: boolean; showTimestamp?: boolean; @@ -258,7 +284,7 @@ ComponentRegistry.register('chatbot-enhanced', autoResponseDelay?: number; onSend?: (content: string, messages: ObjectChatMessage[]) => void; onClear?: () => void; - }; className?: string; [key: string]: any }) => { + }; className?: string; disabled?: boolean; [key: string]: any }) => { const { messages, isLoading, @@ -300,13 +326,16 @@ ComponentRegistry.register('chatbot-enhanced', return ( ); },