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 (
);
},