Skip to content

fix(plugin-dashboard): keep schema-shaped props off the KPI card DOM (#4357) - #4428

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4357-metric-dom-spread
Aug 12, 2026
Merged

fix(plugin-dashboard): keep schema-shaped props off the KPI card DOM (#4357)#4428
yinlianghui merged 2 commits into
mainfrom
claude/issue-4357-metric-dom-spread

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#4357

MetricWidget and MetricCard ended their prop lists with ...props and spread the whole thing onto the Shadcn Card. Reached through SchemaRenderer — which every dashboard KPI tile is — the renderer hands a component its widget schema plus the schema's own keys, so SDUI metadata landed on the DOM: React passes unknown lowercase attributes straight through and stringifies object values.

Measured, not assumed

One render carrying every SDUI key at once, through the real SchemaRenderer path (packages/react/src/SchemaRenderer.tsx, the React.createElement(Component, …) block). The props a widget receives that are not HTML attribute names, and the attribute each emitted before this change:

propemitted aswhat it is
schemaschema="[object Object]"the node itself, injected on every render
eventsevents="[object Object]"SDUI action metadata
propsprops="[object Object]"the props container — the renderer already spreads its contents separately
bindbind="data.revenue"SDUI data-binding path
ariaLabelarialabel="…"camelCase authored form; the renderer already emits the resolved aria-label
ariaDescribedByariadescribedby="…"ditto for aria-describedby
dataSourcedatasource="[object Object]"the injected data-source adapter

Raw pre-fix DOM, type: 'metric' through SchemaRenderer:

class="rounded-lg border bg-card text-card-foreground shadow-sm h-full overflow-hidden"
role="group"
schema="[object Object]"
id="revenue"
name="revenue_kpi"
bind="data.revenue"
events="[object Object]"
arialabel="Revenue KPI"
ariadescribedby="desc-1"
props="[object Object]"
aria-label="Revenue KPI"
aria-describedby="desc-1"
data-obj-id="revenue"
data-obj-type="metric"

dataSource is the one a schema-only measurement misses, and the only one that leaked on a production dashboard rather than an authored edge case. It is not a schema key — SchemaRenderer strips the schema's own dataSource binding by name (objectstack#5576) — it is the adapter DashboardRenderer hands its SchemaRenderer call (DashboardRenderer.tsx:735/:764), arriving through the renderer's trailing props. Every fixture in this package renders a dashboard without an adapter, so it read undefined and wrote nothing in every test, while every deployment that actually loads data put datasource="[object Object]" on the card. My first pass enumerated six keys and shipped that gap; the second measurement, with an adapter attached, found it. Case (g) of the pin now renders the dashboard with an adapter, so the shape only production had is a test.

All seven are destructured out in both components. The spread survives: everything that is a DOM attribute still reaches the element unchanged — id, name, role, disabled, aria-*, data-*, className. Removing the spread would have deleted the components' only accessibility passthrough. The list and its measurement live in one place, packages/plugin-dashboard/src/schemaHostProps.ts, rather than being copied into two components. The line drawn is "is the key an HTML attribute name".

The pin, and the workaround it replaces

Pre-fix RED, both components at origin/main (MetricWidget.domProps.test.tsx):

× (a) MetricWidget: the metric container carries no stringified schema
× (b) MetricCard: same, through its own registry type
× (c) neither component emits any of the seven measured non-DOM props
× (d) the dashboard KPI path — the container assertion #4032 could not write
× (g) the dashboard KPI path WITH a data source — the production shape
✓ (e) genuine DOM / aria passthrough survives — the spread is not removed
✓ (f) rendered output is otherwise byte-identical — label and formatted value
Tests 5 failed | 2 passed (7)

(e) and (f) are the acceptance boundary and were predicted to pass on both sides: nothing about the render moves except the bogus attributes disappearing. Post-fix the whole package is green — 43 files, 363 tests.

DashboardRenderer.metricI18n.test.tsx (#4032) asserted on the card heading because the container assertion was unwritable: the card carried schema="[object Object]" before and after any i18n fix, so the natural pin was red for a reason unrelated to labels and the tempting repair was to loosen it. That workaround is removed — cases (b) and (b2) now assert not.toContain('[object Object]') on the container, the comment points at this fix, and the now-unused cardHeading() helper goes with it.

Reverse verification

Committed first, then took one component's destructuring out with git checkout origin/main -- FILE (never git stash), predicting the split beforehand: (a)/(c)/(d)/(g) are MetricWidget's, (b) is MetricCard's.

runredgreen
MetricWidget reverted(a) (c) (d) (g) + i18n (b) (b2)(b), i18n (a) (b3) (f) (f2), (e) (f)
MetricCard reverted(b) (c)(a) (d) (g) (e) (f), all six i18n
restored13 / 13

The i18n cases going red only in the first run is the direct evidence that replacing #4032's workaround is load-bearing rather than cosmetic — that assertion now fails if this fix regresses.

Type surface, against the rebuilt dist/index.d.ts from a consumer package: a probe using both components normally, passing the injected metadata, and assigning MetricCard to a narrower React.FC compiles clean; a negative probe stays red on a bad label, a colorVariant outside the vocabulary, a bad trend and an undeclared prop — so tsc read the new declaration, not a cached one.

One thing the reviewer should weigh: this repo already has a decided answer for this class

objectui#3291 / PR #3313 closed the same defect in packages/fields with a whitelist (toDomProps), and its docstring argues explicitly against the deny-list shape — "a blacklist enumerating today's renderer-only keys … would not stop the next authored key either" — while naming this very path as the harder one, "SchemaRenderer … has no strip layer at all, so on the SDUI path a widget's own spread is the ONLY line of defence".

This PR is a deny-list because that is what the ruling on #4357 specified (destructuring enumeration; allow-list only if enumeration is impossible). It is correct and verified for the seven measured props, and the dataSource miss above is a live miniature of the weakness that doc predicted. What remains uncovered is the open tail: an authored key a component does not declare still reaches the DOM (measured — props: { colorVariant } on metric-card lands as colorvariant="success", since MetricCard has no such prop). Filed as #4425 with both options and the sweep-gate question, rather than changed unilaterally here.

Also filed: #4426 — the mirror image, pre-existing and untouched. Neither props interface declares the DOM passthrough the spread accepts, so id / role / aria-label are a type error for a TS consumer while working at runtime.

Changeset

patch. The exported MetricWidgetProps / MetricCardProps interfaces are byte-identical — the components' accepted props widen only by the optional, ignored SchemaHostProps keys, which is additive and narrows nothing, so the #4403 precedent (minor for a props-type change) does not apply. Never major, per the fixed-group rule.

Verification

  • pnpm exec vitest run packages/plugin-dashboard/ from the repo root — 43 files, 363 tests, green
  • tsc --noEmit in packages/plugin-dashboard (no tsconfig.test.json exists here — confirmed, matching PR fix(dashboard,report): localize a LOCAL select dimension on table/pivot and the dataset report block (#4330) #4388's report)
  • build closure first (--filter '@object-ui/plugin-dashboard^...' build), then a package rebuild before judging any type
  • eslint on the five touched files: 0 errors
  • check-control-bytes, check-phantom-dependencies, check-changeset-presence, check-changeset-fixed, check-changeset-no-major: green

Consumer sweep, honest about its limit: --filter '...@object-ui/plugin-dashboard' (prefix = the five downstream consumers, not the suffix form that walks upstream) type-checks red — but all 204 errors are Cannot find module '@object-ui/auth' | '@object-ui/layout' | '@object-ui/plugin-*', i.e. packages whose dist a dependency-scoped build never produces, and not one names a metric symbol. It is a missing-artifact red, so it neither confirms nor denies anything about this change; the type evidence that does count is the consumer-side probe against the rebuilt .d.ts above.


Generated by Claude Code

…4357)
MetricWidget and MetricCard ended their prop lists with `...props` and
spread it onto the Card. Reached through SchemaRenderer — every dashboard
KPI tile is — that spread also received the node's own metadata, and React
writes unknown lowercase attributes straight to the DOM, stringifying
objects. Every KPI card carried `schema="[object Object]"`.
Measured at the SchemaRenderer call site: six props arrive that are not
HTML attribute names — schema, events, props, bind, ariaLabel,
ariaDescribedBy. They are destructured out in both components; the spread
survives for everything that IS one (id, name, role, disabled, aria-*,
data-*, className), which is the components' only accessibility
passthrough. The list and its measurement live once, in schemaHostProps.ts.
The cost was never visible — it was that the defect poisoned the assertion
this area attracts. #4032 had to assert on the card heading because the
container pin was red for a reason unrelated to labels. That workaround is
replaced by the container assertion it was standing in for.
The first pass measured only what a SCHEMA can carry, and every fixture in
this package renders a dashboard without a data source — so `dataSource`
read `undefined` and wrote nothing, in every test. Measured again with an
adapter attached, the KPI card carried `datasource="[object Object]"`: the
adapter is not a schema key at all (SchemaRenderer strips the schema's own
`dataSource` binding by name, objectstack#5576) but the object
DashboardRenderer hands its SchemaRenderer call, arriving through the
renderer's trailing props.
That is the only one of the seven that leaked on a production dashboard
rather than an authored edge case, and the pin could not see it. Case (g)
now renders the dashboard WITH an adapter, so the shape only deployments
had is a test.
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 12, 2026 5:31am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)24.7 KB350 KB
Entry fileindex-BEj-5wo6.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)489.20KB108.43KB
core (index.js)2.99KB1.14KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)153.42KB41.19KB
fields (index.js)228.69KB56.74KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.33KB1.20KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.98KB10.85KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)45.23KB12.45KB
plugin-charts (index.js)62.01KB17.63KB
plugin-chatbot (index.js)181.17KB43.03KB
plugin-dashboard (index.js)120.75KB31.38KB
plugin-designer (index.js)211.16KB42.76KB
plugin-detail (index.js)239.03KB59.77KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.58KB27.68KB
plugin-gantt (index.js)164.14KB39.98KB
plugin-grid (index.js)187.99KB49.92KB
plugin-kanban (index.js)48.60KB13.41KB
plugin-list (index.js)110.21KB26.79KB
plugin-map (index.js)18.05KB5.80KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.99KB10.74KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)23.71KB7.96KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — PM 复核 (session session_017Qqyix2QcnpUC9XeYVDzx3), closes #4357.

Flipping ready + arming auto-merge.


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 12, 2026 05:42
@yinlianghui
yinlianghui added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 306c101Aug 12, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4357-metric-dom-spread branch August 12, 2026 05:42
This was referenced Aug 12, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MetricWidget / MetricCard spread ...props onto the DOM, emitting a schema="[object Object]" attribute on every KPI card

2 participants

@yinlianghui@claude