Skip to content

test(app-shell): generalize the #3291 DOM-leak canary sweep beyond packages/fields (#4425 phase 1) - #4441

Merged
yinlianghui merged 3 commits into
mainfrom
claude/issue-4425-dom-leak-sweep-gate
Aug 12, 2026
Merged

test(app-shell): generalize the #3291 DOM-leak canary sweep beyond packages/fields (#4425 phase 1)#4441
yinlianghui merged 3 commits into
mainfrom
claude/issue-4425-dom-leak-sweep-gate

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Part of #4425

Phase 1 as ruled in the claim comment: measure before converging. The #3291 canary sweep, generalized beyond packages/fields to the registry-reachable SDUI widgets of the four packages the card names. Zero widget contract changes, zero widget source changes — leaks are RECORDED, not fixed.

New suite: packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx, 39 cases over 23 measured targets. Plus one changeset with empty frontmatter (test-only, declared as releasing nothing).

The enumeration, measured from the registry

Not taken from the card's candidate list. Each plugin barrel was imported in isolation and the registry diffed before/after, then attributed by canonical namespace — because plugin-calendar's barrel transitively registers plugin-detail and all 46 field widgets, which a naive diff over-attributes by 100+ entries.

packagetargetsleakingleaked attributes
plugin-charts900
plugin-calendar300
plugin-chatbot3214 each
plugin-dashboard837 / 9 / 13

5 of 23 targets leak. The two clean packages are clean for opposite reasons: plugin-charts never spreads the node onto its container, while plugin-calendar is clean only because its one spreading target is swept with a canary withheld — authoring events crashes it outright (#4433).

Enumeration parity is asserted per package against the registry itself, so a widget added to any of these plugins cannot slip past unswept — the guarantee FORM_FIELD_TYPES gives the fields gate.

The reading phase 2 was waiting for

The dashboard rows are the interesting ones. Read them against what is not in them:

plugin-dashboard:metric name reference_to zzcanary zzcanaryobj zzcanarynum
zzcanarycamel zzcanaryprop
plugin-dashboard:metric-card ...the same, plus colorvariant and label

Every one of the seven keys schemaHostProps.ts enumerates (#4357 / PR #4428) is absent on both components — schema, bind, events, props, ariaLabel, ariaDescribedBy and the injected dataSource adapter are all correctly stripped. The deny-list does exactly what it claims. Raw post-fix DOM from the sweep:

class="rounded-lg border bg-card text-card-foreground shadow-sm h-full"
id="canary-node" name="canary_node" label="Revenue" zzcanary="CANARY-STR"
zzcanaryobj="[object Object]" zzcanarynum="42" zzcanarycamel="CANARY-CAMEL"
reference_to="contacts" colorvariant="success" zzcanaryprop="CANARY-PROP"

What remains is the open tail, which is toDomProps' argument measured rather than predicted. colorvariant is #4425's own example; label is a new one — MetricCardProps spells its heading title, so authoring the key its sibling MetricWidget takes puts the heading on the DOM as an attribute instead of rendering it. A deny-list bounded by enumeration cannot be finished; a whitelist bounded by declaration can.

Option 3 ("record the divergence") happens in the gate's docblock either way, as the ruling said it would. Option 1 stays with the maintainer.

Four traps that made the first measurement a lie

Each was hit, and each is now an assertion rather than a comment:

  1. A lazy boundary answers with a skeleton. The first run scanned class="animate-pulse …"ChartRenderer's Suspense fallback — and reported nine clean chart targets while no chart had mounted. Every target now has to reach a readiness selector proving its real markup exists.
  2. Portals are not in the render container.chatbot-floating mounts through createPortal into #floating-chatbot-portal; a container-scoped scan saw zero elements and called it clean. The sweep scans document.body, and afterEach removes the portal node RTL does not own.
  3. An error boundary looks like a render.SchemaErrorBoundary renders tidy, attribute-clean markup. Three calendar targets "passed" that way. Every target asserts the boundary is absent before scanning.
  4. A host-less render is a different component.object-calendar throws useSchemaContext must be used within a SchemaRendererProvider on a bare renderer, and an unconfigured one renders a placeholder. Renders go through the provider with the minimum schema each widget needs.

Non-vacuity, and the reverse verification

Direction predicted before each run. Four mutations, each restored with git checkout (never git stash — the stack is shared across worktrees):

mutationpredictedobserved
leaky fixture made to filter1 red: the mechanism case, all 8 canary families missingexactly that, 1 failed / 38 passed
metric-card ledger row removed1 red: that sweep case, 9 attributes2 red — see below
phantom attribute added to view:dashboard's row1 red: set mismatchexactly that, 1 failed / 38 passed
pie-chart dropped from TARGETS1 red: the plugin-charts parity case, 38 cases not 39exactly that

The metric-card prediction was wrong, and the extra red is correct. I implemented "remove the row" as a rename to a non-swept key, so the ledger shape assertion also fired with zz-removed-for-rv:metric-card: ledgered but not a swept target. Two assertions caught one mutation; the prediction was off because of how the mutation was written, not because the gate misbehaved. Recording it rather than quietly reporting 1/1.

The mechanism case is the one the ruling asked for explicitly — a fixture component with a deliberate leak, rendered through the same harness, going red when it stops leaking:

AssertionError: expected [ …(8) ] to deeply equal []
+ "schema (the node injected on every render)",
+ "bind (authored SDUI metadata)",
+ "events (authored SDUI action metadata)",
+ "props (the props container)",
+ "arialabel (authored camelCase ARIA)",
+ "zzcanary (the authored open tail)",
+ "zzcanaryprop (the authored `props` contents)",
+ "datasource (the injected data-source adapter)",

Its twin — a fixture that filters its spread and must stay clean — is what keeps the judge from being merely noisy.

The ledger, and why it cannot rot

Assertion is exact set equality, so a row is a two-way ratchet: a new leaked attribute fails the gate, and fixing a leak also fails it until the row is deleted in the same change. That is the expiry — no date to forget, no allow-list to rot; the tree itself expires the row. A separate case rejects a row with no reason, no owning issue, or no matching swept target.

Ledger contents: plugin-chatbot:chatbot and :chatbot-enhanced (14 each, #4431), plugin-dashboard:metric (7) and :metric-card (9) (both #4425, phase 2 decides the shape), view:dashboard (13, #4432).

The withheld canary is a recorded defect, not an exemption

calendar-view is swept without events because authoring it — ordinary SDUI action metadata, legal on any node — crashes the component: the renderer computes a CalendarEvent[], passes it as events, then spreads {...props} after it, so the SDUI object overwrites the array and CalendarView throws events is not iterable. Verified in isolation: events alone crashes it, the same node without it renders the real calendar. A crashing render produces attribute-clean error-boundary DOM and would have read as a clean pass. The omission carries its own pin, so fixing the crash turns that case red and both halves come out together.

Filed from the measurement

metric / metric-card are deliberately not filed separately — they are #4425's own subject, so a new issue would be a twin.

Home justification (#4409 dependency-direction method)

None of the four target packages can host this: plugin-dashboard declares only plugin-charts, and nothing declares plugin-calendar or plugin-chatbot, so hosting it in any one of them inverts the direction for the other three — the inversion #4409 refused when it kept the map gate out of @object-ui/i18n. Four workspace entries declare all four; three are apps/examples. packages/app-shell declares all four as devDependencies (the correct field for a test-only import, and what check-phantom-dependencies honours for a __tests__/ file) and already hosts spec-symbol-parity.test.ts and #4409's own map gate. One suite covered all four — the "one suite per package" fallback the card allowed for was not needed.

Two deviations worth flagging

  1. The chart lazy modules are not preloaded at module scope. AGENTS.md's flake rule says to hoist the module behind a React.lazy boundary into module scope. @object-ui/plugin-charts exports only ., so @object-ui/plugin-charts/ChartImpl resolves for Vite's alias but not for tsc (TS2882 on both passes), and widening that package's exports is a public-surface change this PR must not make. Each chart target's readiness selector covers it instead, with a deliberately generous 10s budget. Three consecutive runs: 39/39 each.
  2. The judge is duplicated rather than shared with the fields gate, because that judge is not exported and packages/fields is out of scope here. Recorded as finding: the DOM-leak "is this attribute HTML-defined" judge now exists in two copies, in two test files that cannot import each other #4434; the calibration fixtures keep this copy honest meanwhile.

Verification

pnpm exec vitest run (the gate) 39 passed (39)
... three consecutive runs, post-merge 39/39 each
pnpm exec vitest run packages/app-shell/src/__tests__/ \
packages/plugin-{charts,calendar,chatbot,dashboard}/ 92 files, 937 tests passed
pnpm --filter '@object-ui/app-shell^...' build Done (closure first, fresh worktree)
pnpm --filter @object-ui/app-shell type-check Done — BOTH passes
(tsc --noEmit && tsc -p tsconfig.test.json)
eslint (the new file) exit 0
check-control-bytes / check-phantom-dependencies PASS
check-changeset-presence / -no-major / -fixed PASS

Control-byte self-scan beyond the gate over both touched files: clean.

origin/main moved 4 commits mid-flight and was merged in; it carried #4426's domPassthroughPins.ts and MetricWidget.domPassthrough.test.tsx. That change declares the passthrough types without moving the runtime filter, and the gate stayed 39/39 with the ledger matching exactly — which, under exact set equality, is positive evidence that the leak surface did not move rather than an absence of evidence. Build, both type-check passes and every gate were re-run after the merge.


Generated by Claude Code

…ckages/fields (#4425)
Phase 1 of #4425: a MEASUREMENT gate over the registry-reachable SDUI widgets
of plugin-charts / plugin-calendar / plugin-chatbot / plugin-dashboard. No
widget contract and no widget source changes; the leaks it finds are recorded
in an in-gate ledger with exact attribute sets and owning issues.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
`@object-ui/plugin-charts` exports only `.`, so the deep subpath resolved for
Vite's alias but not for tsc (TS2882). Widening that package's exports would be
a public-surface change this measurement-only PR must not make; each chart
target's readiness selector already covers the lazy boundary.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@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 7:28am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)24.7 KB350 KB
Entry fileindex-B7R9OEpn.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.99KB56.82KB
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), Part of #4425 (phase 1 complete; the card stays open for phase 2).

Flipping ready + arming auto-merge.


Generated by Claude Code

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.

2 participants

@yinlianghui@claude