Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit a600924

Browse files
fix(plugin-dashboard): metric cards spread only the toDomProps whitelist on the renderer door (objectui#4425) (#11668)
Part of #4425. This PR lands the DOM half of the card and deletes both leak-ledger rows that name it. The authored-`label` half goes back to the decision inbox, measured, with options (see "Open: the authored `label` half" below). That is why this PR does not close the card. Clause-②: no ## What changed - `MetricWidget` and `MetricCard` (`@object-ui/plugin-dashboard`) now decide their host spread by **door**, through one internal helper, `hostDomProps`, in `src/schemaHostProps.ts`: - **Renderer door.** This is a render through `SchemaRenderer`, which injects `schema` on every render. It covers every dashboard KPI tile, and an app that registers the exported component under its own key. The `Card` receives only what `toDomProps` from `@object-ui/core` passes. That is the phase-2 widget contract (ruling comment 5270759246). It is the same executor `DashboardRenderer`'s grid and `plugin-chatbot`'s registrations use, and it adds no new list. - **Direct React door.** The exported components rendered directly keep the declared `React.HTMLAttributes` pass-through (objectui#4426), unchanged. - `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx`: the `plugin-dashboard:metric` and `plugin-dashboard:metric-card` rows are deleted. The docblock reading moves with them: the plugin-dashboard row reads 0 targets leaking, the whole sweep reads 82 of 168, and `DOCBLOCK_COUNTS.allLedgered` is 82. No new row was added. - New pins: `MetricWidget.sduiDomWhitelist-4425.test.tsx`. On the renderer door, every attribute on the host must be whitelist-shaped, and no planted key may arrive: the seven #4357 keys, `name`, the `zzcanary` open tail, `props` contents, or a card's `label`. The deliberate pass-through must still arrive: `id`, `data-testid` from `testId`, `aria-label` from `ariaLabel`, and `className`. On the direct door, `title` and `lang` must still reach the element. A control case shows the same keys stopping on the renderer door. `MetricWidget.domProps.test.tsx` case (e) now asserts that `name` does NOT reach a `div`. It used to pin the deny-list's leak as a feature. - Docs: `content/docs/guide/plugin-development.md` gains "What Reaches the DOM". It states the plugin-widget contract once, cites the ruling and names the sweep as the gate, and holds a compiled `tsx` example. `content/docs/fields/widget-props.mdx` points there from its DOM pass-through paragraph. - Changeset `.changeset/4425-metric-dom-whitelist.md`: `@object-ui/plugin-dashboard` patch, with the behaviour change written out. ## Why door-discriminated, and not the `DashboardRenderer` shape The suggested route was to call `toDomProps` unconditionally, as `DashboardRenderer` does. `DashboardRenderer` could do that because objectui#4432 also narrowed its props interface to `Pick` of `HTMLAttributes` over `SduiDomPassThroughKey`, so its declaration equals what it delivers. This card's fence forbids that narrowing for `MetricWidgetProps` and `MetricCardProps`, which both extend `React.HTMLAttributes`. An unconditional whitelist would therefore type-check a direct consumer's `title`, `lang` or `onMouseEnter` and then drop it. That is declared but not delivered, created by this change. The door split gives the renderer door exactly the ruled contract and leaves the direct door's declaration true. `plugin-chatbot`'s conversion (objectui#4431) behaves the same way: its registrations whitelist, while the exported `Chatbot` keeps its declared pass-through. That conversion does it with a registration wrapper. A wrapper would have changed the `dashboardComponents` map values and `src/index.tsx`, which is outside the claim's file surface. It would also have left the README's documented "register the exported component under your own key" path unwhitelisted. The discriminator `schema` is the one key the renderer injects on every render, and it is declared only on `SchemaHostProps`, the renderer's private door. If it ever stopped firing, the sweep's canaries would land again and the gate would go red. ## Evidence (all on final head `9e36c2d` unless stated) - **Ledger expiry, measured, before the rows were deleted** (component fix committed, rows still present). The sweep went red on exactly the two targets: `plugin-dashboard:metric` expected 7 attributes (`name`, `reference_to`, `zzcanary`, `zzcanarycamel`, `zzcanarynum`, `zzcanaryobj`, `zzcanaryprop`) and received `[]`; `plugin-dashboard:metric-card` expected 9 (the same 7 plus `colorvariant` and `label`) and received `[]`. With the rows deleted: `Tests 215 passed (215)`. - **Ablations** go through objectstack's `scripts/ablation-replace.mjs`, which verifies the anchor hit, the blob change and a restore to `HEAD` (empty `git diff HEAD`) on every leg: - `hostDomProps` returning the raw rest is the pre-change runtime. Red: domProps (e), whitelist pins (a)x2, (c) and (e), and sweep `metric` and `metric-card`, 7 failed in total. Green: (b) and (d). - `hostDomProps` whitelisting both doors. Red: (d) only, so `MetricWidget.domPassthrough.test.tsx` does not see the direct door's beyond-the-whitelist half. - The docs snippet's import renamed to a non-export: `check-doc-snippet-types` reports `plugin-development.md` TS2305, 1 failed of 778. So the new block is compiled. - **Exported surface.** `plugin-dashboard` was built at the base sources (`fc3c2cc`, restore proven by blob hash) and at head. `dist/index.d.ts` is byte-identical. `MetricWidget.d.ts` and `MetricCard.d.ts` differ in doc comments only. `schemaHostProps.d.ts` gains the internal `hostDomProps` declaration, which is not re-exported from the entry; the package `exports` map serves `.` only. - **Gates.** Each was run on head `9e36c2d` with the exit code captured before any pipe: - `pnpm --filter '@object-ui/plugin-dashboard^...'` closure build: exit 0 - `pnpm --filter @object-ui/plugin-dashboard build`: exit 0 - `type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0 - `lint`: exit 0, with 0 errors - `pnpm exec vitest run packages/plugin-dashboard/`: exit 0, `Test Files 171 passed (171)`, `Tests 1691 passed, 6 skipped (1697)` - the leak sweep: `215 passed (215)` - `check:doc-snippets`, after building its `--build-filter` closure: exit 0, `778 of 778 block(s) judged, 0 failed` - lockless gates, all exit 0: `check:new-line-citations` (0 new), `check:control-bytes`, `check:doc-types`, `check:doc-fences`, `check:doc-example-ids`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:test-path-roots`, `check:phantom-deps`, `check:self-import`, `check:component-surface-parity`, `check:unreferenced-sources`, `check:vi-mock-specifiers`, `check:handler-key-reads`, `check:doc-example-readers`, `check:i18n-keys`, `check-doc-links`, `check-doc-expression-carriage`, `check-changeset-no-major`, `check-changeset-fixed`, `check-changeset-presence` - `origin/main` (`f9f4a62`) was merged in once, as a merge commit. Everything above ran after that merge. ## Open: the authored `label` half (needs a decision) The triage line reads: "An authored `label` on a metric card either renders as the heading or is refused loudly at authoring. ⛔ Never a DOM attribute." This PR delivers the third sentence and stops on the first. Measured: - `objectui validate` on `{ type: 'dashboard', widgets: [{ type: 'metric-card', id: 'kpi', value: '42', label: 'Revenue' }] }` exits 0 with "Schema is valid!". The control, the same card without `value`, is refused at `widgets → 0 → value` with exit 1. The strict authoring face accepts the card too. The TypeScript twin `DashboardWidgetSlotComponentSchema` accepts it as well. On both Zod faces, `label` is a `BaseSchema` member the slot arm inherits, so the strict face's unknown-key closing never sees it. On the TypeScript face, it is `BaseSchema.label`. - At render, before this PR, the card drew no heading and carried `label="Revenue"` on its element. After this PR it draws no heading and carries no attribute. The key is silently dropped. - `@objectstack/spec` 17.6.0 declares no `metric-card` (its two `MetricCard` mentions are code comments). The registration declares `title` as the heading input. Every in-repo producer writes `title`: all eleven `metric-card` entries in `examples/schema-catalog` do. Either way the triage line asks for, the fix leaves this card's fence: - **A. Refuse it by name.** Add a `label` refusal tombstone on the slot's component arm in `@object-ui/types` (zod), naming `title` as the heading. This is the objectui#9256 pattern for `body` / `children` on the same arm. It also needs `label?: never` on the exported `DashboardWidgetSlotComponentSchema`. That makes it an exported-type change, Clause-② yes, and a narrowing of the tolerant face that `objectui validate` runs. - **B. Render it as the heading.** This would add `label` as a second heading input on the registration, `MetricCardProps` and the slot arm. It is a new prop and input, and two spellings for one heading. - **C. Leave it.** The key would stay accepted and silently dropped. | Axis | A (refuse) | B (render) | C (leave) | |---|---|---|---| | Measured business need | Real error class. The sibling `metric` node takes `label`, so a mixed-up author writes it on a card. | No producer writes `label` on a card, so this would be a speculative second spelling. | — | | Long-term soundness | One spelling (contract-first), refused at the producer | An alias accreted in the consumer (AGENTS.md #0.1) | A declared-but-inert key | | Keeps an AI author from writing it wrong | Loud refusal at `objectui validate` that names `title` | Tolerance that hides the mix-up | A silent drop: the card renders headless with no warning | | Startup-stage scope | Narrows the surface, adds no capability and no new gate | Widens the surface | Nothing | Recommendation: **A**. A wider variant of A is a scoping question for the same decision. It would also refuse the other `BaseSchema` members `MetricCard` never reads, as objectui#9256 did for `body` / `children`. After this PR those members are equally silent on the renderer door: `name`, `placeholder`, `style` and `data`. ## Acceptance notes - **Behaviour change** (also in the changeset). An authored `style` or `name` on either node, or a `title` on a `metric` node, no longer reaches the card. This follows the ruling: everything the renderer hands a widget is consumed or whitelisted. It matches what objectui#4432 did to the dashboard grid. No in-repo producer writes `style`, `name` or `title` onto these nodes. That was checked against the catalog `metric-card` entries; the widget→node mapping in `DashboardRenderer` builds `metric` nodes from `options` plus `label`, `description` and `value`. - Dispatch assumption corrected: `toDomProps` for SDUI widgets is `@object-ui/core`'s (lifted there by objectui#4431). The `@object-ui/fields` export is the field-widget twin, which also passes `name` and `disabled`. This PR imports the `core` one, as `DashboardRenderer` does. - Observation, not filed: `MetricWidget`'s `variant: 'bare'` root spreads nothing on either door. `id`, `aria-*` and `data-obj-*` never reach it, although `MetricWidgetProps` declares the pass-through. Its reach is zero: no producer emits `variant: 'bare'` outside `MetricWidget` and its `ObjectMetricWidget` pass-through. Owner: none. - NOT MEASURED: live console dashboard in Chromium (optional per dispatch; the sweep is the binding reading). Also not measured: `check:docs-route-closure` and the repo-wide `pnpm lint` / `pnpm test` farm, which CI runs. --- _Generated by [Claude Code](https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f9f4a62 commit a600924

9 files changed

Lines changed: 434 additions & 83 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/plugin-dashboard': patch
3+
---
4+
5+
The metric cards no longer write authored keys they do not read onto the page (objectui#4425). Rendered through `SchemaRenderer`, which is how every dashboard draws its KPI tiles (`plugin-dashboard:metric` and `plugin-dashboard:metric-card` nodes), `MetricWidget` and `MetricCard` now spread onto their card element only what `toDomProps` from `@object-ui/core` passes: `id`, `className`, `role`, `tabIndex`, the `aria-*` and `data-*` families and the rest of that whitelist. They used to strip seven named non-DOM props and spread everything else, so `name`, and any key an author wrote that the card does not read (a `colorVariant` or a `label` on a `metric-card`, an extra key in a `metric` widget's `options`), landed on the element as an attribute, with objects written as `[object Object]`.
6+
7+
**Behaviour change.** On a dashboard node, an HTML attribute outside that whitelist no longer reaches the card either: an authored `name` or `style` on either node, or a `title` on a `metric` node (a card reads `title` as its heading), is now dropped. An authored `label` on a `metric-card` is no longer a `label="…"` attribute. It is still not the card's heading, which is `title`.
8+
9+
Rendering the components directly as React components is unchanged: `<MetricWidget …>` and `<MetricCard …>` still forward every `HTMLAttributes` key their props interfaces declare.
10+
11+
**Clause-②: no.** No exported type, prop, registration input or export changes. The helper that tells the two paths apart is internal and is not exported from the package entry.

‎content/docs/fields/widget-props.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@ who supplies it:
7373
on the element a widget renders — the field's `id`, the `aria-describedby` the form
7474
control minted, and so on. `toDomProps` is this group's runtime executor, bound to
7575
the declaration in both directions by compile-time assertions, so a key cannot be
76-
declared here and silently never delivered.
76+
declared here and silently never delivered. Every other registered widget is held
77+
to the same rule with the SDUI contract's own key set — the plugin-widget contract
78+
is stated once, in [What Reaches the DOM](/docs/guide/plugin-development#what-reaches-the-dom).
7779
4. **The ARIA attribute family**, intersected in whole from React's `AriaAttributes`.
7880
This group is the bulk of the member count, which is why the count is not a useful
7981
thing to quote.

‎content/docs/guide/plugin-development.md‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,66 @@ export type { BoardSchema, BoardProps, BoardColumn, BoardItem } from './types';
176176

177177
Now any schema with `"type": "board"` will resolve to your component.
178178

179+
## What Reaches the DOM
180+
181+
`SchemaRenderer` hands a registered component far more than the keys it
182+
declares: the node itself as `schema`, every other key the author wrote on the
183+
node, the contents of its `props` bag, the runtime props it injects (`bind`,
184+
`events`, the camelCase `ariaLabel` the author wrote, the data-source adapter)
185+
and whatever its own host passed. React writes unknown lowercase props straight
186+
onto an element and stringifies objects, so a component that ends in a bare
187+
`{...props}` spread onto an element puts all of it on the page —
188+
`schema="[object Object]"`, `datasource="[object Object]"`, a `name` on a `div`,
189+
and every authored key the widget never declared. Nothing warns: React stays
190+
silent for lowercase names.
191+
192+
**The contract for a registered widget's host element** (objectui#4425,
193+
phase-2 ruling, comment 5270759246): it receives only what `toDomProps` from `@object-ui/core`
194+
passes — the SDUI contract's declared DOM keys (`id`, `className`, `role`,
195+
`tabIndex` and the rest of `SDUI_DOM_PASS_THROUGH_KEYS`) and the open `aria-*`
196+
and `data-*` families. Everything else the renderer hands a widget is either
197+
**consumed** — read by name and turned into behaviour — or dropped. It is a
198+
whitelist rather than a list of keys to strip, because the set of keys an author
199+
may write is unbounded and a strip list can only name the ones that exist today.
200+
201+
```tsx
202+
import type { HTMLAttributes, ReactNode } from 'react';
203+
import { toDomProps, type SduiDomPassThroughKey } from '@object-ui/core';
204+
205+
// The DOM pass-through this widget declares is exactly what the whitelist
206+
// forwards — named from the whitelist's own key type, so the two cannot drift.
207+
interface BoardFrameProps extends Pick<HTMLAttributes<HTMLDivElement>, SduiDomPassThroughKey> {
208+
children?: ReactNode;
209+
}
210+
211+
export function BoardFrame({ className, children, ...props }: BoardFrameProps) {
212+
return (
213+
// Spread FIRST, so the attributes the widget computes stay authoritative.
214+
<div {...toDomProps(props)} className={className}>
215+
{children}
216+
</div>
217+
);
218+
}
219+
```
220+
221+
A widget that really means to forward something beyond that set **declares**
222+
it on its props type and forwards it **by name** — the way `style` reaches the
223+
layout containers. Never reopen the spread to get it. A component that is both
224+
registered and exported for direct React use may keep a wider declared
225+
`HTMLAttributes` pass-through on its direct door, as `@object-ui/plugin-dashboard`'s
226+
metric cards do; what the renderer hands it still goes through the whitelist.
227+
228+
Field widgets follow the same rule with their own declared set: `@object-ui/fields`
229+
exports a `toDomProps` that adds the form-control keys `name` and `disabled`
230+
(see [Field Widget Props](/docs/fields/widget-props)).
231+
232+
**The gate.** `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx`
233+
renders every registered widget of the packages it covers through the real
234+
`SchemaRenderer`, with planted canary keys from every family above, and fails
235+
on any attribute HTML does not define. A known leak is a ledger row in that
236+
file naming the issue that owns its fix, and the ledger is a two-way ratchet: a
237+
new leak fails the gate, and so does a fixed one until its row is deleted.
238+
179239
## Implementing a Custom Field Widget
180240

181241
Field widgets follow the `FieldWidgetComponentProps` interface from `@object-ui/fields`.

‎packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx‎

Lines changed: 45 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -36,29 +36,34 @@
3636
* | plugin-charts | 5 | 0 | 0 |
3737
* | plugin-calendar | 3 | 0 | 0 |
3838
* | plugin-chatbot | 3 | 0 | 0 |
39-
* | plugin-dashboard | 7 | 2 | 7 / 9 |
39+
* | plugin-dashboard | 7 | 0 | 0 |
4040
* | components | 150 | 82 | 12 .. 15 |
4141
*
42-
* **84 of 168 targets leak.** The `components` row is objectui#5574 and is
43-
* covered in its own section below; the two `plugin-dashboard` rows are the
44-
* older tail. Both are in {@link LEAK_LEDGER}:
45-
* `plugin-dashboard:metric` and `plugin-dashboard:metric-card`, the open tail
46-
* objectui#4425 owns directly. Two migration steps have closed their rows since
47-
* the phase-1 measurement:
42+
* **82 of 168 targets leak.** All of them are the `components` row, which is
43+
* objectui#5574 and is covered in its own section below. The four packages
44+
* objectui#4425 named now read zero. Three migration steps have closed their
45+
* rows since the phase-1 measurement:
4846
*
4947
* - `plugin-chatbot:chatbot` / `chatbot-enhanced` — 14 attributes each,
5048
* objectui#4431 / PR #4485, which also lifted `toDomProps` to
5149
* `@object-ui/core` so later cards consume one executor.
5250
* - `plugin-dashboard:dashboard` (spelled `view:dashboard` until
5351
* objectui#9533) — `DashboardRenderer`'s widget-grid container, 13
54-
* attributes, objectui#4432 / this file's most recent edit.
52+
* attributes, objectui#4432.
53+
* - `plugin-dashboard:metric` / `metric-card` — the KPI cards' open tail, 7
54+
* and 9 attributes, objectui#4425 itself: the last two rows inside these
55+
* four packages. See "what is left of it" below.
5556
*
56-
* The three packages now reading 0 are NOT clean for the same reason, and the
57+
* The four packages now reading 0 are NOT clean for the same reason, and the
5758
* difference is worth keeping straight: `plugin-charts` never spreads the node
5859
* onto its container at all; `plugin-calendar`'s components take a declared prop
5960
* list and drop what they do not name, so the node's keys never reach an
60-
* element; `plugin-chatbot` and `DashboardRenderer`'s grid reach zero by
61-
* FILTERING — they still spread, through `toDomProps`.
61+
* element; `plugin-chatbot`, `DashboardRenderer`'s grid and the two KPI cards
62+
* reach zero by FILTERING — they still spread, through `toDomProps`. The KPI
63+
* cards filter on the renderer's door only: rendered directly as React
64+
* components they keep their declared `HTMLAttributes` pass-through
65+
* (objectui#4426), and this gate, which renders every target through
66+
* `SchemaRenderer`, never sees that door.
6267
*
6368
* `calendar-view` was originally swept with the `events` canary WITHHELD, because
6469
* authoring it crashed the component outright (objectui#4433) — a worse failure
@@ -81,23 +86,24 @@
8186
* - a **deny-list** (`plugin-dashboard/src/schemaHostProps.ts`, #4357/PR
8287
* #4428): destructure seven measured non-DOM props out, spread the rest.
8388
*
84-
* The deny-list is correct for the seven props it enumerates — this sweep
85-
* confirms all seven are gone from `metric` and `metric-card`. What it cannot
86-
* close is the **open tail**, exactly as `toDomProps`' docblock predicted: an
87-
* authored key the component does not declare still reaches the DOM. That is not
88-
* a hypothetical here; it is ledger rows {@link LEAK_LEDGER} `metric` and
89-
* `metric-card`, where `zzcanary` / `reference_to` / an authored
90-
* `props: { colorVariant }` all land as attributes while every one of the seven
91-
* named keys is correctly stripped. A deny-list bounded by enumeration cannot be
89+
* The deny-list was correct for the seven props it enumerates — this sweep
90+
* confirmed all seven gone from `metric` and `metric-card`. What it could not
91+
* close was the **open tail**, exactly as `toDomProps`' docblock predicted: an
92+
* authored key the component does not declare still reached the DOM. That was
93+
* not a hypothetical here; it was two {@link LEAK_LEDGER} rows, `metric` (7
94+
* attributes) and `metric-card` (9), where `zzcanary` / `reference_to` / `name`
95+
* / an authored `props: { colorVariant }` — and, on `metric-card`, an authored
96+
* `label` — all landed as attributes while every one of the seven named keys
97+
* was correctly stripped. A deny-list bounded by enumeration cannot be
9298
* finished; a whitelist bounded by declaration can. That contrast IS the
9399
* measurement phase 2 waited for, and the ruling went to the whitelist on it.
94100
*
95-
* So the divergence is no longer a standing state of the repo — it is a
96-
* migration in progress, and the two rows still below are the last of it inside
97-
* these four packages. `plugin-dashboard` is now MIXED by design: its
98-
* `DashboardRenderer` grid container filters through `toDomProps` (#4432) while
99-
* the two KPI components still run the deny-list, and the two surviving rows are
100-
* precisely that difference, measured.
101+
* The divergence is now gone from these four packages. objectui#4425 moved the
102+
* two KPI components' renderer door onto `toDomProps` and deleted both rows in
103+
* the same change — the two-way expiry below, which turned this gate red on
104+
* exactly those two targets (each measured `[]` against its row) until they
105+
* went. The deny-list stays in `schemaHostProps.ts` as the direct React door's
106+
* filter, which this gate does not render.
101107
*
102108
* ## objectui#5574 — the family this sweep could not see, and what it found
103109
*
@@ -440,7 +446,8 @@ const AUTHORED_EXTRAS = {
440446

441447
/**
442448
* Authored `props: { … }`. `colorVariant` is objectui#4425's own measured
443-
* example — `metric-card` has no such prop, so it lands as `colorvariant`.
449+
* example — `metric-card` has no such prop, so it landed as `colorvariant`
450+
* until that card moved onto `toDomProps`.
444451
*/
445452
const AUTHORED_PROPS = {
446453
colorVariant: 'success',
@@ -885,6 +892,10 @@ const TARGETS: Readonly<Record<string, readonly Target[]>> = {
885892
],
886893
'plugin-dashboard': [
887894
{ type: 'plugin-dashboard:metric', schemaExtras: { label: 'Revenue', value: 42 }, ready: '.rounded-lg.border' },
895+
// `label` is deliberately NOT this card's heading (`MetricCardProps` spells
896+
// it `title`): authored here it is one more open-tail key, and it once
897+
// leaked as `label="Revenue"` (objectui#4425). Kept so the gate keeps
898+
// proving it stops at the component.
888899
{ type: 'plugin-dashboard:metric-card', schemaExtras: { label: 'Revenue', value: 42 }, ready: '.rounded-lg.border' },
889900
{ type: 'plugin-dashboard:object-metric', schemaExtras: { objectName: 'accounts' }, ready: '.rounded-lg.border' },
890901
{ type: 'plugin-dashboard:pivot', ready: '[data-testid="pivot-empty-state"]' },
@@ -1201,40 +1212,12 @@ const COMPONENTS_LEAK_GROUPS: readonly LedgerGroup[] = [
12011212
];
12021213

12031214
const LEAK_LEDGER: Readonly<Record<string, LedgerEntry>> = {
1204-
/* ── plugin-dashboard: the OPEN TAIL a deny-list cannot close ───────────── */
1205-
//
1206-
// Read these two rows against what is NOT in them. Every one of the seven keys
1207-
// `schemaHostProps.ts` enumerates (#4357 / PR #4428) is absent — `schema`,
1208-
// `bind`, `events`, `props`, `ariaLabel`, `ariaDescribedBy`, `dataSource` are
1209-
// all correctly stripped, on both components, including the adapter. The
1210-
// deny-list does exactly what it claims.
1211-
//
1212-
// What remains is the half it cannot reach: keys the component does not
1213-
// declare and the list never names. That is `toDomProps`' argument, measured
1214-
// rather than predicted, and it is the reading objectui#4425 phase 2 is for.
1215-
'plugin-dashboard:metric': {
1216-
attributes: [
1217-
'name', 'reference_to', 'zzcanary', 'zzcanarycamel', 'zzcanarynum',
1218-
'zzcanaryobj', 'zzcanaryprop',
1219-
],
1220-
reason:
1221-
'the seven deny-listed props are gone; the open tail of authored keys ' +
1222-
'`MetricWidget` does not declare still reaches the Card.',
1223-
issue: 'objectui#4425',
1224-
},
1225-
'plugin-dashboard:metric-card': {
1226-
attributes: [
1227-
'colorvariant', 'label', 'name', 'reference_to', 'zzcanary',
1228-
'zzcanarycamel', 'zzcanarynum', 'zzcanaryobj', 'zzcanaryprop',
1229-
],
1230-
reason:
1231-
'the same open tail as `metric`, plus two keys that are the tail in ' +
1232-
'miniature: `colorvariant` is objectui#4425\'s own measured example, and ' +
1233-
'`label` leaks because `MetricCardProps` spells its heading `title` — so ' +
1234-
'authoring the key its sibling `MetricWidget` takes puts the heading on ' +
1235-
'the DOM as an attribute instead of rendering it.',
1236-
issue: 'objectui#4425',
1237-
},
1215+
// ⛔ No `plugin-dashboard` rows. The last two — `metric` and `metric-card`,
1216+
// the open tail `schemaHostProps.ts`' deny-list could not close — were
1217+
// deleted by objectui#4425 when both KPI components moved their renderer door
1218+
// onto `toDomProps`. Both targets are still swept above with the full canary
1219+
// set, so a regression there is a NEW leak on an unledgered target and fails
1220+
// on the empty-report branch below, naming every attribute.
12381221

12391222
/* ── packages/components: 82 of 150 targets, in four measured shapes ───── */
12401223
...Object.fromEntries(
@@ -1817,8 +1800,8 @@ const DOCBLOCK_COUNTS = {
18171800
commonestShapeAttributes: 14,
18181801
/** Every target this sweep renders, all five packages. */
18191802
allTargets: 168,
1820-
/** Every ledgered row, `plugin-dashboard`'s open tail included. */
1821-
allLedgered: 84,
1803+
/** Every ledgered row, all five packages. */
1804+
allLedgered: 82,
18221805
} as const;
18231806

18241807
type CountName = keyof typeof DOCBLOCK_COUNTS;

‎packages/plugin-dashboard/src/MetricCard.tsx‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { cn } from '@object-ui/components';
1212
import { useObjectTranslation, pickLocalized } from '@object-ui/i18n';
1313
import type { I18nLabel } from '@object-ui/types';
1414
import { ArrowDownIcon, ArrowUpIcon, MinusIcon, AlertCircle, Loader2 } from 'lucide-react';
15-
import type { SchemaHostProps } from './schemaHostProps';
15+
import { hostDomProps, type SchemaHostProps } from './schemaHostProps';
1616

1717
/**
1818
* DOM PASS-THROUGH (objectui#4426) — see `MetricWidget.tsx`'s interface header
@@ -31,6 +31,12 @@ import type { SchemaHostProps } from './schemaHostProps';
3131
* whole. The repo's spelling for this carve-out is `ComboboxProps`
3232
* (`extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "value" | "onChange">`),
3333
* omitted there for the same reason: the component's own contract owns the name.
34+
*
35+
* This declaration is the DIRECT React door's. Rendered through
36+
* `SchemaRenderer`, the card's element receives only what `toDomProps` passes
37+
* (objectui#4425, phase 2) — see `hostDomProps` in `./schemaHostProps`. That is
38+
* also why an authored `label` stops at the component: it is not this card's
39+
* heading (`title` is) and not an attribute either.
3440
*/
3541
export interface MetricCardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
3642
/**
@@ -68,8 +74,8 @@ export const MetricCard: React.FC<MetricCardProps & SchemaHostProps> = ({
6874
// Schema-shaped props `SchemaRenderer` injects, destructured out so the
6975
// spread below cannot write them to the DOM (objectui#4357). Named and
7076
// measured in `./schemaHostProps`; `schema` alone put a
71-
// `schema="[object Object]"` attribute on every card. The rest spread
72-
// survives — it is the component's genuine DOM/aria passthrough.
77+
// `schema="[object Object]"` attribute on every card.
78+
// READ now, as the door discriminator below (same binding as `MetricWidget`).
7379
schema: _schema,
7480
bind: _bind,
7581
events: _events,
@@ -79,13 +85,16 @@ export const MetricCard: React.FC<MetricCardProps & SchemaHostProps> = ({
7985
dataSource: _dataSource,
8086
...domProps
8187
}) => {
88+
// What the `Card` may carry (objectui#4425): the `toDomProps` whitelist on
89+
// the renderer's door, the declared pass-through on the direct React door.
90+
const hostProps = hostDomProps(_schema, domProps);
8291
// Resolve icon via lazy resolver — each icon ships as its own micro-chunk
8392
const IconComponent = icon ? getLazyIcon(icon) : null;
8493
// Label text follows the active UI language (not the tenant's number locale).
8594
const { language } = useObjectTranslation();
8695

8796
return (
88-
<Card className={cn("h-full", className)} {...domProps}>
97+
<Card className={cn("h-full", className)} {...hostProps}>
8998
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
9099
<CardTitle className="text-sm font-medium">
91100
{pickLocalized(title, language)}

0 commit comments

Comments
 (0)