Repository navigation
fix(plugin-dashboard): metric cards spread only the toDomProps whitelist on the renderer door (objectui#4425) - #11668
Conversation
…ist on the renderer door (objectui#4425) `MetricWidget` and `MetricCard` destructured seven measured non-DOM props and spread the rest onto the Card, so the open tail of authored keys neither declares (and `name`) still reached the DOM. Rendered through SchemaRenderer (the `schema` prop the renderer injects on every render), the host now receives only what `@object-ui/core`'s `toDomProps` passes. A direct React render keeps the declared HTMLAttributes pass-through unchanged, so no exported prop surface changes. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
…i#4425) Both `plugin-dashboard:metric` and `plugin-dashboard:metric-card` now measure an empty leak set through the real SDUI path, so the two-way ratchet held the gate red on exactly those targets until their rows went. The docblock reading moves with them: 82 of 168 targets leak, all in the components family. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
…ad (objectui#4425) The renderer door spreads only whitelist-shaped attributes (no open tail, no `name`, no authored `label` on a card) while the deliberate pass-through (`id`, `data-testid`, `aria-label`, `className`) still arrives; the direct React door keeps delivering declared attributes beyond the whitelist. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
…e door pins (objectui#4425) Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
…e (objectui#4425) The custom plugin guide now says what a registered widget's host element may receive (the `toDomProps` whitelist from `@object-ui/core`), how deliberate pass-through is declared, and which gate holds it; the field-widget page points there. Adds the plugin-dashboard changeset. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
…nding name (objectui#4425) `MetricWidget`'s emitted declaration prints its parameter's binding pattern, so reading the injected `schema` under its old `_schema` binding keeps the exported signature's declaration text identical to the base build's. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
ACCEPT: PR objectui#11668, head
|
Part of #4425. This PR lands the DOM half of the card and deletes both leak-ledger rows that name it. The authored-
labelhalf goes back to the decision inbox, measured, with options (see "Open: the authoredlabelhalf" below). That is why this PR does not close the card.Clause-②: no
What changed
MetricWidgetandMetricCard(@object-ui/plugin-dashboard) now decide their host spread by door, through one internal helper,hostDomProps, insrc/schemaHostProps.ts:SchemaRenderer, which injectsschemaon every render. It covers every dashboard KPI tile, and an app that registers the exported component under its own key. TheCardreceives only whattoDomPropsfrom@object-ui/corepasses. That is the phase-2 widget contract (ruling comment 5270759246). It is the same executorDashboardRenderer's grid andplugin-chatbot's registrations use, and it adds no new list.React.HTMLAttributespass-through (objectui#4426), unchanged.packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx: theplugin-dashboard:metricandplugin-dashboard:metric-cardrows are deleted. The docblock reading moves with them: the plugin-dashboard row reads 0 targets leaking, the whole sweep reads 82 of 168, andDOCBLOCK_COUNTS.allLedgeredis 82. No new row was added.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 sevenMetricWidget/MetricCardspread...propsonto the DOM, emitting aschema="[object Object]"attribute on every KPI card #4357 keys,name, thezzcanaryopen tail,propscontents, or a card'slabel. The deliberate pass-through must still arrive:id,data-testidfromtestId,aria-labelfromariaLabel, andclassName. On the direct door,titleandlangmust still reach the element. A control case shows the same keys stopping on the renderer door.MetricWidget.domProps.test.tsxcase (e) now asserts thatnamedoes NOT reach adiv. It used to pin the deny-list's leak as a feature.content/docs/guide/plugin-development.mdgains "What Reaches the DOM". It states the plugin-widget contract once, cites the ruling and names the sweep as the gate, and holds a compiledtsxexample.content/docs/fields/widget-props.mdxpoints there from its DOM pass-through paragraph..changeset/4425-metric-dom-whitelist.md:@object-ui/plugin-dashboardpatch, with the behaviour change written out.Why door-discriminated, and not the
DashboardRenderershapeThe suggested route was to call
toDomPropsunconditionally, asDashboardRendererdoes.DashboardRenderercould do that because objectui#4432 also narrowed its props interface toPickofHTMLAttributesoverSduiDomPassThroughKey, so its declaration equals what it delivers. This card's fence forbids that narrowing forMetricWidgetPropsandMetricCardProps, which both extendReact.HTMLAttributes. An unconditional whitelist would therefore type-check a direct consumer'stitle,langoronMouseEnterand 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 exportedChatbotkeeps its declared pass-through. That conversion does it with a registration wrapper. A wrapper would have changed thedashboardComponentsmap values andsrc/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 discriminatorschemais the one key the renderer injects on every render, and it is declared only onSchemaHostProps, 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
9e36c2dunless stated)plugin-dashboard:metricexpected 7 attributes (name,reference_to,zzcanary,zzcanarycamel,zzcanarynum,zzcanaryobj,zzcanaryprop) and received[];plugin-dashboard:metric-cardexpected 9 (the same 7 pluscolorvariantandlabel) and received[]. With the rows deleted:Tests 215 passed (215).scripts/ablation-replace.mjs, which verifies the anchor hit, the blob change and a restore toHEAD(emptygit diff HEAD) on every leg:hostDomPropsreturning the raw rest is the pre-change runtime. Red: domProps (e), whitelist pins (a)x2, (c) and (e), and sweepmetricandmetric-card, 7 failed in total. Green: (b) and (d).hostDomPropswhitelisting both doors. Red: (d) only, soMetricWidget.domPassthrough.test.tsxdoes not see the direct door's beyond-the-whitelist half.check-doc-snippet-typesreportsplugin-development.mdTS2305, 1 failed of 778. So the new block is compiled.plugin-dashboardwas built at the base sources (fc3c2cc, restore proven by blob hash) and at head.dist/index.d.tsis byte-identical.MetricWidget.d.tsandMetricCard.d.tsdiffer in doc comments only.schemaHostProps.d.tsgains the internalhostDomPropsdeclaration, which is not re-exported from the entry; the packageexportsmap serves.only.9e36c2dwith the exit code captured before any pipe:pnpm --filter '@object-ui/plugin-dashboard^...'closure build: exit 0pnpm --filter @object-ui/plugin-dashboard build: exit 0type-check(tsc --noEmit && tsc -p tsconfig.test.json): exit 0lint: exit 0, with 0 errorspnpm exec vitest run packages/plugin-dashboard/: exit 0,Test Files 171 passed (171),Tests 1691 passed, 6 skipped (1697)215 passed (215)check:doc-snippets, after building its--build-filterclosure: exit 0,778 of 778 block(s) judged, 0 failedcheck: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-presenceorigin/main(f9f4a62) was merged in once, as a merge commit. Everything above ran after that merge.Open: the authored
labelhalf (needs a decision)The triage line reads: "An authored
labelon 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 validateon{ type: 'dashboard', widgets: [{ type: 'metric-card', id: 'kpi', value: '42', label: 'Revenue' }] }exits 0 with "Schema is valid!". The control, the same card withoutvalue, is refused atwidgets → 0 → valuewith exit 1. The strict authoring face accepts the card too. The TypeScript twinDashboardWidgetSlotComponentSchemaaccepts it as well. On both Zod faces,labelis aBaseSchemamember the slot arm inherits, so the strict face's unknown-key closing never sees it. On the TypeScript face, it isBaseSchema.label.label="Revenue"on its element. After this PR it draws no heading and carries no attribute. The key is silently dropped.@objectstack/spec17.6.0 declares nometric-card(its twoMetricCardmentions are code comments). The registration declarestitleas the heading input. Every in-repo producer writestitle: all elevenmetric-cardentries inexamples/schema-catalogdo.Either way the triage line asks for, the fix leaves this card's fence:
labelrefusal tombstone on the slot's component arm in@object-ui/types(zod), namingtitleas the heading. This is the objectui#9256 pattern forbody/childrenon the same arm. It also needslabel?: neveron the exportedDashboardWidgetSlotComponentSchema. That makes it an exported-type change, Clause-② yes, and a narrowing of the tolerant face thatobjectui validateruns.labelas a second heading input on the registration,MetricCardPropsand the slot arm. It is a new prop and input, and two spellings for one heading.metricnode takeslabel, so a mixed-up author writes it on a card.labelon a card, so this would be a speculative second spelling.objectui validatethat namestitleRecommendation: A. A wider variant of A is a scoping question for the same decision. It would also refuse the other
BaseSchemamembersMetricCardnever reads, as objectui#9256 did forbody/children. After this PR those members are equally silent on the renderer door:name,placeholder,styleanddata.Acceptance notes
styleornameon either node, or atitleon ametricnode, 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 writesstyle,nameortitleonto these nodes. That was checked against the catalogmetric-cardentries; the widget→node mapping inDashboardRendererbuildsmetricnodes fromoptionspluslabel,descriptionandvalue.toDomPropsfor SDUI widgets is@object-ui/core's (lifted there by objectui#4431). The@object-ui/fieldsexport is the field-widget twin, which also passesnameanddisabled. This PR imports thecoreone, asDashboardRendererdoes.MetricWidget'svariant: 'bare'root spreads nothing on either door.id,aria-*anddata-obj-*never reach it, althoughMetricWidgetPropsdeclares the pass-through. Its reach is zero: no producer emitsvariant: 'bare'outsideMetricWidgetand itsObjectMetricWidgetpass-through. Owner: none.check:docs-route-closureand the repo-widepnpm lint/pnpm testfarm, which CI runs.Generated by Claude Code