diff --git a/.changeset/field-widget-dom-prop-whitelist.md b/.changeset/field-widget-dom-prop-whitelist.md
new file mode 100644
index 000000000..1fe444442
--- /dev/null
+++ b/.changeset/field-widget-dom-prop-whitelist.md
@@ -0,0 +1,90 @@
+---
+"@object-ui/fields": minor
+---
+
+Field widgets no longer spread renderer-only props — or arbitrary keys from a
+field config — onto the DOM element they render (objectui#3291).
+
+**Behaviour change:** an unknown key written on a field configuration (or on an
+SDUI `field:*` node) stops becoming an HTML attribute on the rendered control.
+Nothing reads those attributes, but they were serialized into the DOM, into
+snapshots, and into anything scraping rendered markup.
+
+## What was happening
+
+Widgets forwarded their leftover props with a bare spread, so whatever a host
+handed them became an attribute. Measured on a real form with a real widget:
+
+```
+
+```
+
+`zzcanaryobj="[object Object]"` is an ordinary extra key on the field config
+being `String()`-ed onto an attribute. React 19 does not warn about any of it:
+an all-lowercase unknown attribute is passed through in complete silence, which
+is why this went unnoticed.
+
+Eleven widgets carried a line that looked like it prevented exactly this:
+
+```ts
+const { inputType, ...domProps } = props as any; // "Filter out non-DOM props"
+```
+
+`inputType` is already stripped by the form renderer before a widget sees it,
+so that line filtered nothing — the comment actively misled. It is gone.
+
+## What changed
+
+- New `toDomProps(props)`, exported from `@object-ui/fields`. It keeps only
+ what may legitimately become a DOM attribute and drops the rest.
+- The 14 field widgets that spread onto a host element now go through it:
+ `text`, `textarea`, `number`, `boolean`, `date`, `datetime`, `time`, `email`,
+ `phone`, `url`, `password`, `currency`, `percent`, and `select`.
+
+**It is a whitelist, not a list of keys to drop.** The largest leak source is
+not any named renderer prop — it is the open tail of author-supplied keys. The
+form renderer destructures a fixed set of known keys and forwards the rest
+verbatim, and `SchemaRenderer` is wider still: it spreads the whole authored
+node as props with no strip layer at all, so on that path a widget's own spread
+is the only line of defence. A blacklist of today's renderer-only props would
+pass every canary above and would not stop the next authored key either.
+
+The forwarded set is the one `FieldWidgetComponentProps` already **declares**:
+`id`, `name`, `autoFocus`, `tabIndex`, `onBlur`, `onFocus`, `onClick`,
+`className`, `disabled`, plus `aria-*` and the `data-*` family. Until now that
+was a type-level claim a widget could violate at runtime just by spreading;
+`toDomProps` is its executable form.
+
+Two compile-time assertions tie the helper to the declaration, and it is worth
+being exact about which drift each one prevents:
+
+- the contract's DOM pass-through block is now a named type
+ (`FieldWidgetDomProps`), and **both directions are compiler-bound**:
+ forwarding a key the contract does not declare fails to compile, and
+ declaring a DOM key the helper does not forward fails to compile too. The
+ second direction guards *declared but not delivered* — a key that
+ type-checks, reads as supported, and silently never reaches the element. The
+ leak test structurally cannot see that class of bug: it looks for attributes
+ that arrive, not for ones that go missing.
+- `className` and `disabled` are bound in the **forward direction only**. They
+ are DOM-legal and are forwarded, but they live in the controlled-input block
+ because widgets also interpret them, so they are deliberately outside
+ `FieldWidgetDomProps`.
+
+An HTML global attribute the contract does not declare (`role`, say) is no
+longer forwarded. It only ever arrived through the open spread. If a field node
+should be able to author one, declare it on `FieldWidgetComponentProps` and add
+it to the whitelist — the fix belongs at the contract, not in a wider spread.
+
+## Regression gate
+
+A new contract test renders **every** registered field widget through **both**
+hosts — the real form renderer and `SchemaRenderer` — and fails on any
+attribute HTML does not define for that element. It walks real DOM attributes
+rather than listening for React warnings (React 19 is silent for the exact case
+that leaked), asserts a validation error genuinely rendered before scanning the
+error variant, and calibrates its own judge against two fixtures: standard
+markup that must produce zero findings, and planted fake attributes that must
+all be found. A new widget type is covered automatically — the sweep is derived
+from the widget registry, so adding one without covering it fails the test.
diff --git a/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx b/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx
new file mode 100644
index 000000000..1d898dd77
--- /dev/null
+++ b/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx
@@ -0,0 +1,598 @@
+/**
+ * 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.
+ */
+
+/**
+ * GATE: no field widget may leak a non-DOM prop onto a DOM element
+ * (objectui#3291).
+ *
+ * Every registered field widget is rendered through BOTH hosts, and every
+ * attribute of every element it produced is checked against what HTML actually
+ * defines. An attribute nobody can explain is a leak.
+ *
+ * ## What this catches that a unit test cannot
+ *
+ * The leak was never in one widget. It was structural: a widget spread
+ * `{...props}` onto its control, and the hosts hand a widget more than the DOM
+ * can take. Measured on `origin/main`, a real form, a real widget:
+ *
+ * ```
+ *
+ * ```
+ *
+ * `zzcanaryobj="[object Object]"` is an ordinary key an author wrote on the
+ * field config. That is why the fix is a WHITELIST (`toDomProps`) and why this
+ * test plants canaries rather than checking a list of known-bad names: a
+ * blacklist of today's renderer props would pass all three canaries above.
+ *
+ * ## Four things that silently defeat a test like this
+ *
+ * 1. **React warnings prove nothing.** React 19 passes an all-lowercase
+ * unknown attribute through in COMPLETE silence. In the audit sweep the
+ * only warning came from a camelCase canary — which was written to the DOM
+ * anyway. So this walks real DOM attributes and never listens for console
+ * output.
+ * 2. **Both hosts, or the result is a false pass.** The form renderer strips a
+ * known set before forwarding; `SchemaRenderer` spreads the whole authored
+ * node with NO strip layer, so it leaks strictly more (`label` is only
+ * visible there). On that path the widget's own spread is the only defence.
+ * 3. **An error variant that produces no error tests nothing.** `required` +
+ * a `false` boolean does NOT fail here — this repo made `required` a
+ * presence check, so `false` is a value (cloud#972). The audit's first pass
+ * under-reported the `error` leak by one widget for exactly this reason.
+ * Every error variant therefore ASSERTS the message rendered before it
+ * scans; a variant that silently produces no error fails the test.
+ * 4. **This repo runs happy-dom, not jsdom** (`vitest.config.mts`), whose IDL
+ * coverage has real gaps — `select[size]`, `option[label]`, `textarea[wrap]`
+ * and `col[span]` are all standard HTML that happy-dom does not reflect.
+ * See {@link isKnownAttribute} for how the judge is built, and
+ * {@link HAPPY_DOM_IDL_GAPS} for each measured exception and its reason.
+ * Every one of those four was found BY the calibration fixture below, not
+ * by guesswork.
+ *
+ * ## The judge proves itself
+ *
+ * Two fixtures run before the sweep: standard markup that must yield ZERO
+ * findings, and markup with planted fake attributes that must ALL be found.
+ * When a happy-dom upgrade changes IDL coverage, those fail loudly instead of
+ * the sweep going quietly blind.
+ *
+ * ## Deliberate coverage boundary
+ *
+ * Popovers are NOT opened. Radix needs pointer-capture APIs happy-dom does not
+ * implement. Every widget's props spread lands on its inline control (the
+ * trigger for a picker), which always renders — so the spread site IS covered,
+ * but content that exists only inside an open dropdown is NOT scanned by this
+ * test.
+ *
+ * Widgets are registered from STATIC imports wrapped in `withFieldCarrier`,
+ * never via `registerAllFields()`, which wraps every loader in `React.lazy` —
+ * an unbounded module load inside a bounded `waitFor` is this repo's known
+ * flake generator (AGENTS.md 测试纪律 / objectui#3010).
+ */
+
+import type { ComponentType } from 'react';
+import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { ComponentRegistry } from '@object-ui/core';
+// Module scope: pulls in the form renderer's registration side effect.
+import '@object-ui/components';
+import { SchemaRenderer } from '@object-ui/react';
+
+import { withFieldCarrier } from '../withFieldCarrier';
+import { FORM_FIELD_TYPES } from '../index';
+
+import { TextField } from '../widgets/TextField';
+import { TextAreaField } from '../widgets/TextAreaField';
+import { NumberField } from '../widgets/NumberField';
+import { BooleanField } from '../widgets/BooleanField';
+import { SelectField } from '../widgets/SelectField';
+import { DateField } from '../widgets/DateField';
+import { DateTimeField } from '../widgets/DateTimeField';
+import { TimeField } from '../widgets/TimeField';
+import { EmailField } from '../widgets/EmailField';
+import { PhoneField } from '../widgets/PhoneField';
+import { UrlField } from '../widgets/UrlField';
+import { MultiSelectField } from '../widgets/MultiSelectField';
+import { RadioField } from '../widgets/RadioField';
+import { CheckboxesField } from '../widgets/CheckboxesField';
+import { TagsField } from '../widgets/TagsField';
+import { CurrencyField } from '../widgets/CurrencyField';
+import { PercentField } from '../widgets/PercentField';
+import { PasswordField } from '../widgets/PasswordField';
+import { RichTextField } from '../widgets/RichTextField';
+import { LookupField } from '../widgets/LookupField';
+import { FileField } from '../widgets/FileField';
+import { ImageField } from '../widgets/ImageField';
+import { LocationField } from '../widgets/LocationField';
+import { FormulaField } from '../widgets/FormulaField';
+import { SummaryField } from '../widgets/SummaryField';
+import { AutoNumberField } from '../widgets/AutoNumberField';
+import { UserField } from '../widgets/UserField';
+import { ObjectField } from '../widgets/ObjectField';
+import { VectorField } from '../widgets/VectorField';
+import { GridField } from '../widgets/GridField';
+import { ColorField } from '../widgets/ColorField';
+import { SliderField } from '../widgets/SliderField';
+import { RatingField } from '../widgets/RatingField';
+import { CodeField } from '../widgets/CodeField';
+import { AvatarField } from '../widgets/AvatarField';
+import { AddressField } from '../widgets/AddressField';
+import { GeolocationField } from '../widgets/GeolocationField';
+import { SignatureField } from '../widgets/SignatureField';
+import { QRCodeField } from '../widgets/QRCodeField';
+import { ObjectRefField } from '../widgets/ObjectRefField';
+import { FilterConditionField } from '../widgets/FilterConditionField';
+import { RecipientPickerField } from '../widgets/RecipientPickerField';
+
+/* ════════════════════════════════════════════════════════════════════════════
+ * The judge: is this attribute one HTML actually defines?
+ * ══════════════════════════════════════════════════════════════════════════ */
+
+/** Open families. `data-*` is the one open family the widget contract declares. */
+const OPEN_PREFIXES = [
+ 'data-',
+ 'aria-',
+ // `cmdk-root` / `cmdk-input` / `cmdk-list` … are marks the cmdk library puts
+ // on ITS OWN DOM. Not prop pass-through, and present on every cmdk-based
+ // picker (`object-ref`, `lookup`, …) the moment it renders.
+ 'cmdk-',
+];
+
+/**
+ * Global HTML attributes. Most are also IDL properties and would be caught by
+ * the reflection check below; they are listed because a missing IDL for a
+ * genuinely global attribute would otherwise read as a leak.
+ */
+const GLOBAL_HTML_ATTRIBUTES = new Set([
+ 'id', 'class', 'style', 'title', 'lang', 'dir', 'hidden', 'tabindex', 'role',
+ 'slot', 'part', 'exportparts', 'itemid', 'itemprop', 'itemref', 'itemscope',
+ 'itemtype', 'translate', 'draggable', 'spellcheck', 'autocapitalize',
+ 'autocorrect', 'contenteditable', 'enterkeyhint', 'inputmode', 'accesskey',
+ 'nonce', 'is', 'popover', 'inert', 'autofocus',
+]);
+
+/**
+ * Attributes whose IDL property is spelled differently enough that the
+ * case-insensitive reflection match below cannot find them.
+ */
+const ATTRIBUTE_TO_IDL_ALIAS: Record = {
+ 'class': 'className',
+ 'for': 'htmlFor',
+ 'accept-charset': 'acceptCharset',
+ 'http-equiv': 'httpEquiv',
+};
+
+/**
+ * MEASURED gaps in happy-dom's IDL, kept deliberately tiny — each entry is an
+ * attribute HTML defines that happy-dom's element does not reflect as a
+ * property, so reflection alone would report it as a leak.
+ */
+const HAPPY_DOM_IDL_GAPS: Record> = {
+ // `HTMLSelectElement.size` is standard; happy-dom does not define it.
+ select: new Set(['size']),
+ // `HTMLOptionElement.label` is standard; happy-dom does not define it.
+ option: new Set(['label']),
+ // `HTMLTextAreaElement.wrap` is standard; happy-dom does not define it.
+ textarea: new Set(['wrap']),
+ // `HTMLTableColElement.span` is standard; happy-dom does not define it.
+ col: new Set(['span']),
+ colgroup: new Set(['span']),
+};
+
+/**
+ * SVG needs its own list: the reflection trick does NOT hold for SVG under
+ * happy-dom (`SVGElement` reflects almost nothing), and lucide icons put a
+ * fixed set of presentation attributes on every icon they render.
+ */
+const SVG_ATTRIBUTES = new Set([
+ 'xmlns', 'xmlns:xlink', 'version', 'viewbox', 'preserveaspectratio',
+ 'width', 'height', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx',
+ 'ry', 'd', 'points', 'transform', 'fill', 'fill-rule', 'fill-opacity',
+ 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
+ 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'opacity',
+ 'clip-path', 'clip-rule', 'mask', 'offset', 'stop-color', 'stop-opacity',
+ 'gradientunits', 'gradienttransform', 'patternunits', 'text-anchor',
+ 'dominant-baseline', 'font-size', 'font-family', 'font-weight', 'vector-effect',
+ 'shape-rendering', 'focusable', 'overflow', 'color',
+]);
+
+/** Lowercased IDL property names on a tag's prototype chain, cached per tag. */
+const idlCache = new Map>();
+
+function idlPropertiesFor(tagName: string): Set {
+ const tag = tagName.toLowerCase();
+ const cached = idlCache.get(tag);
+ if (cached) return cached;
+
+ const names = new Set();
+ const element = document.createElement(tag);
+ for (const own of Object.getOwnPropertyNames(element)) names.add(own.toLowerCase());
+ for (
+ let proto = Object.getPrototypeOf(element);
+ proto && proto !== Object.prototype;
+ proto = Object.getPrototypeOf(proto)
+ ) {
+ for (const name of Object.getOwnPropertyNames(proto)) names.add(name.toLowerCase());
+ }
+ idlCache.set(tag, names);
+ return names;
+}
+
+/**
+ * The rule: an attribute is legitimate when HTML/SVG defines it for that
+ * element, or when it belongs to an open family.
+ *
+ * The reflection check ("does the element's prototype chain carry a property
+ * with this name, case-insensitively?") is what makes this maintainable — it
+ * covers `readonly→readOnly`, `maxlength→maxLength`, `colspan→colSpan` and
+ * every other per-tag attribute automatically, instead of a hand-kept table
+ * per element type that would rot.
+ */
+function isKnownAttribute(element: Element, attribute: string): boolean {
+ const name = attribute.toLowerCase();
+
+ if (OPEN_PREFIXES.some((prefix) => name.startsWith(prefix))) return true;
+
+ // Inline event handlers (`onclick`) reflect as IDL properties on every
+ // element; React never emits them as attributes, so reaching one means a
+ // handler-shaped prop was stringified onto the DOM. Treat as a leak.
+ if (name.startsWith('on')) return false;
+
+ const tag = element.tagName.toLowerCase();
+
+ if (element.namespaceURI === 'http://www.w3.org/2000/svg') {
+ return SVG_ATTRIBUTES.has(name) || GLOBAL_HTML_ATTRIBUTES.has(name);
+ }
+
+ if (GLOBAL_HTML_ATTRIBUTES.has(name)) return true;
+ if (HAPPY_DOM_IDL_GAPS[tag]?.has(name)) return true;
+
+ const idl = idlPropertiesFor(tag);
+ const alias = ATTRIBUTE_TO_IDL_ALIAS[name];
+ if (alias && idl.has(alias.toLowerCase())) return true;
+ return idl.has(name);
+}
+
+interface Leak {
+ tag: string;
+ attribute: string;
+ value: string;
+ outerHTML: string;
+}
+
+/** Every unexplained attribute on `root` and its descendants. */
+function findLeaks(root: Element): Leak[] {
+ const leaks: Leak[] = [];
+ const elements: Element[] = [root, ...Array.from(root.querySelectorAll('*'))];
+ for (const element of elements) {
+ for (const attribute of Array.from(element.attributes)) {
+ if (isKnownAttribute(element, attribute.name)) continue;
+ leaks.push({
+ tag: element.tagName.toLowerCase(),
+ attribute: attribute.name,
+ value: attribute.value,
+ outerHTML: element.outerHTML.slice(0, 400),
+ });
+ }
+ }
+ return leaks;
+}
+
+/**
+ * Renders the finding as the assertion's "actual" value, so a failure names
+ * the widget, the element, the attribute, its value and the markup. A 46-widget
+ * sweep failing as `expected [] to equal [ …47 items ]` is unusable.
+ */
+function leakReport(widgetType: string, variant: string, leaks: Leak[]): string {
+ if (leaks.length === 0) return '';
+ const lines = leaks.map(
+ (leak) =>
+ ` <${leak.tag}> leaked ${leak.attribute}="${leak.value}"\n` +
+ ` in: ${leak.outerHTML}`,
+ );
+ return (
+ `field:${widgetType} [${variant}] leaked ${leaks.length} non-DOM ` +
+ `attribute(s):\n${lines.join('\n')}`
+ );
+}
+
+/* ════════════════════════════════════════════════════════════════════════════
+ * The judge proves itself, BEFORE it is trusted on 46 widgets
+ * ══════════════════════════════════════════════════════════════════════════ */
+
+/**
+ * Ordinary, correct markup. Every attribute here is one HTML defines, several
+ * chosen precisely because their IDL name differs from the attribute
+ * (`readonly`/`maxlength`/`colspan`/`class`/`for`), plus the two happy-dom IDL
+ * gaps (`select[size]`, `option[label]`) and a cmdk mark.
+ */
+const CLEAN_FIXTURE = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+/** Every planted attribute here MUST be reported. */
+const PLANTED_LEAKS: ReadonlyArray = [
+ // The renderer-only props that reached the DOM in the audit.
+ ['schema', '[object Object]'],
+ ['error', 'Title is required'],
+ ['emptyhint', 'Select country first'],
+ ['datasource', '[object Object]'],
+ ['dependentvalues', '[object Object]'],
+ ['dependson', 'country'],
+ ['inputtype', 'text'],
+ ['compact', 'true'],
+ ['onselectrecord', 'function'],
+ // The SDUI-only extra.
+ ['label', 'Title'],
+ // The open tail: arbitrary keys an author wrote on the field config.
+ ['zzcanary', 'CANARY-STR'],
+ ['zzcanaryobj', '[object Object]'],
+ ['zzcanarynum', '42'],
+ ['zzcanarycamel', 'CANARY-CAMEL'],
+ ['reference_to', 'contacts'],
+];
+
+describe('the leak judge is calibrated (objectui#3291)', () => {
+ it('reports NOTHING on standard markup — no false positives', () => {
+ const host = document.createElement('div');
+ host.innerHTML = CLEAN_FIXTURE;
+ document.body.appendChild(host);
+ try {
+ const leaks = findLeaks(host);
+ expect(
+ leaks.map((l) => `<${l.tag}> ${l.attribute}="${l.value}"`).join('\n'),
+ ).toBe('');
+ } finally {
+ host.remove();
+ }
+ });
+
+ it('reports EVERY planted fake attribute — no false negatives', () => {
+ const host = document.createElement('div');
+ const planted = PLANTED_LEAKS.map(([name, value]) => `${name}="${value}"`).join(' ');
+ // On an , so nothing can be excused by a permissive container.
+ host.innerHTML = ``;
+ document.body.appendChild(host);
+ try {
+ const found = new Set(findLeaks(host).map((leak) => leak.attribute));
+ const missed = PLANTED_LEAKS.map(([name]) => name).filter((name) => !found.has(name));
+ expect(missed).toEqual([]);
+ expect(found.size).toBe(PLANTED_LEAKS.length);
+ } finally {
+ host.remove();
+ }
+ });
+});
+
+/* ════════════════════════════════════════════════════════════════════════════
+ * Every registered field widget, both hosts
+ * ══════════════════════════════════════════════════════════════════════════ */
+
+/**
+ * Static components for the widget map's keys. Kept as one object so the
+ * parity assertion below can prove it covers the whole registry: a NEW field
+ * type added to `fieldWidgetMap` without a line here fails loudly rather than
+ * quietly going unscanned.
+ */
+const WIDGETS: Record> = {
+ text: TextField,
+ textarea: TextAreaField,
+ number: NumberField,
+ boolean: BooleanField,
+ select: SelectField,
+ date: DateField,
+ datetime: DateTimeField,
+ time: TimeField,
+ email: EmailField,
+ phone: PhoneField,
+ url: UrlField,
+ multiselect: MultiSelectField,
+ radio: RadioField,
+ checkboxes: CheckboxesField,
+ tags: TagsField,
+ currency: CurrencyField,
+ percent: PercentField,
+ password: PasswordField,
+ markdown: RichTextField,
+ html: RichTextField,
+ richtext: RichTextField,
+ lookup: LookupField,
+ master_detail: LookupField,
+ file: FileField,
+ image: ImageField,
+ location: LocationField,
+ formula: FormulaField,
+ summary: SummaryField,
+ auto_number: AutoNumberField,
+ user: UserField,
+ owner: UserField,
+ object: ObjectField,
+ vector: VectorField,
+ grid: GridField,
+ color: ColorField,
+ slider: SliderField,
+ rating: RatingField,
+ code: CodeField,
+ avatar: AvatarField,
+ address: AddressField,
+ geolocation: GeolocationField,
+ signature: SignatureField,
+ qrcode: QRCodeField,
+ 'object-ref': ObjectRefField,
+ 'filter-condition': FilterConditionField,
+ 'recipient-picker': RecipientPickerField,
+};
+
+/** Option widgets render an "unfillable" placeholder unless offered a list. */
+const OPTION_TYPES = new Set(['select', 'multiselect', 'radio', 'checkboxes', 'tags']);
+const OPTIONS = [
+ { label: 'Alpha', value: 'alpha' },
+ { label: 'Beta', value: 'beta' },
+];
+
+/**
+ * The value each variant starts from: `null` is MISSING for the required check
+ * this repo implements (presence, not truthiness — `false` and `0` are values,
+ * cloud#972), so one value drives a real validation failure for every type,
+ * including `boolean`. Trap 3 above is why that matters, and why every error
+ * variant asserts the message before it scans.
+ */
+const MISSING_VALUE = null;
+
+/** Author-written extras — the open tail that a blacklist cannot close. */
+const AUTHORED_EXTRAS = {
+ zzcanary: 'CANARY-STR',
+ zzcanaryobj: { nested: true },
+ zzcanarynum: 42,
+ zzcanaryCamel: 'CANARY-CAMEL',
+ reference_to: 'contacts',
+};
+
+function fieldConfig(type: string, extras: Record = {}) {
+ return {
+ name: 'f',
+ label: 'F',
+ type: `field:${type}`,
+ ...(OPTION_TYPES.has(type) ? { options: OPTIONS } : {}),
+ ...extras,
+ };
+}
+
+function renderForm(field: Record, required: boolean) {
+ const Form = ComponentRegistry.get('form')!;
+ return render(
+