Skip to content

test(app-shell): widen the DOM-leak sweep to packages/components renderers, with the first run's ledger - #5629

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-5574-widget-dom-leak-sweep-components
Aug 21, 2026
Merged

test(app-shell): widen the DOM-leak sweep to packages/components renderers, with the first run's ledger#5629
os-sales merged 2 commits into
mainfrom
claude/issue-5574-widget-dom-leak-sweep-components

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Part of #5574 — the widening lands here; the per-renderer burn-down it enables does not, and that card stays open to own it. See "Fold or serial" below.

What changed

packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx — its discovery was four namespace prefixes wide, so the 158 registry-reachable types registered from packages/components/src/renderers/** were outside the gate entirely. That is why ui:grid's leak had to be found by hand during #4011 / PR #4785 rather than by this file.

Plus .changeset/dom-leak-sweep-components-5574.md — empty frontmatter, the repo's explicit "publishes nothing" declaration.

No renderer source is touched. No test is skipped, disabled or quarantined.

Evidence — the discovery probe and its control, at one scope

The gate's discovery mechanism is the per-package completeness case: registeredTypesUnder(prefix) read off ComponentRegistry.getAllConfigs(), compared with the enumerated targets. Both probes are the same perturbation (register one extra widget), against the same assertion, differing only in which namespace the widget lands in.

Run against the pre-widening gate (git show origin/main: of the sweep, one registration injected):

probescopeextra widgetresult
PROBEpackages/components/src/renderers/**ui:zzprobe5574green — 37/37 passed, the completeness case never saw it
CONTROLplugin-dashboard, a prefix the gate does coverplugin-dashboard:zzprobe5574redplugin-dashboard: every widget it registers is swept failed, naming plugin-dashboard:zzprobe5574
× plugin-dashboard: every widget it registers is swept — a new one cannot slip past
AssertionError: expected [ …(7) ] to deeply equal [ …(8) ]
- "plugin-dashboard:zzprobe5574",
Test Files 1 failed | 1 passed (2)
Tests 1 failed | 73 passed (74)

The control is at the same scope as the probe and it can fail — it did. A control one level up (does the sweep run at all? does the file import @object-ui/components?) could not have failed and would have proved nothing.

Third leg, run against this branch's gate with the sameui: perturbation, then reverted (git checkout HEAD -- <path>, injection confirmed on disk beforehand with a grep and git diff --stat):

× components: every widget it registers is swept — a new one cannot slip past
AssertionError: expected [ 'action:bar', …(157) ] to deeply equal [ 'action:bar', …(158) ]

So the perturbation the gate used to miss is now caught, by name.

Evidence — discovered counts, before and after, identical scope

Scope: types registered from packages/components/src/renderers/**, and the source files under that path that register them.

metric, at that scopebeforeafter
registry types discovered by the sweep0 of 158158 of 158
renderer source files reached0 of 8686 of 86
namespaces covered0 of 55 of 5 (ui:, element:, page:, action:, protocol-placeholder:)
sweep targets, whole file23181
cases in the file74200

The five prefixes are exclusively this package's: a workspace-wide grep finds namespace: 'ui' outside packages/components only in a packages/core docstring and a standalone packages/sdui-parser/verify.ts script, neither of which registers into this registry at import time. So nothing foreign is swept into these counts.

The ledger — 119 of 158 targets leak

Recorded, not fixed. Grouped by the mechanism that produces each shape, with every renderer named:

shapetargetswhat leaks
BARE_SPREAD95the full 14, name included — the renderer forwards its whole prop bag to the host element
BARE_SPREAD_MINUS_NAME1813 — same spread onto a host that definesname, so HTML makes that one legitimate
BARE_SPREAD_ON_SVG214, camelCase preserved — SVG attribute names are case-sensitive, so ariaLabel survives as authored
action:group1the full set plus actions, the authored action list itself
action:menu1the name-defining variant, plus actions
ui:form112 — FormRenderer consumes dataSource, and form defines name
ui:sidebar-trigger114 including schema itself, the node SchemaRenderer injects on every render
clean39— including ui:grid, i.e. #4787 / PR #5573's fix now pinned by a gate

The four renderers #5574 named as candidatesui:flex, ui:stack, ui:container, ui:text — all leak. A dedicated case pins that, and pins ui:grid's absence from the ledger from the other side.

Ledger, not allowlist, and the difference is mechanical rather than rhetorical: the per-target assertion is exact set equality in both directions. A listed renderer that starts leaking a fifteenth attribute fails the gate; a renderer that stops leaking also fails it, until its row is deleted in the same change. An allowlist has neither property. Two new cases keep that honest — no renderer may appear in two groups, and every readiness override must carry a recorded reason (and every reason an override).

The finding behind the finding: 16 phantom cleans

A first pass reported 46 clean targets. Sixteen were not clean — they had rendered nothing, and an empty scan reports no leaks:

  • 12 rendered no element at all. The overlays are closed until defaultOpen; action:bar / action:group / action:menu return null with no actions; ui:icon returns null because the canary node's name is canary_node, which is not a lucide icon.
  • 4 threwuseSidebar must be used within a SidebarProvider and were caught by SchemaErrorBoundary, whose markup is attribute-clean.

All 16 now render real markup and are scanned; 7 of them turned out to leak. This is traps 1/3/4 from the file's own docblock, at scale, and it is why this family's readiness is an authored className rather than 158 transcribed selectors — one string, uniform, and it caught all sixteen. The six targets it cannot cover carry their measured reason in READY_OVERRIDE_REASONS, pinned two-way.

Three of those six (element:repeater, element:definition-list, element:metadata_viewer) are swept in a placeholder branch because the sweep's adapter answers with no records by design. Recorded rather than implied, and filed as a follow-up.

Fold or serial — serial, deliberately

Renderer fixes are not folded in, and the answer is owed on #5574 rather than assumed:

  1. Folding would destroy the measurement. The ledger is the record of the tree the gate arrived on. A PR that both widens and fixes leaves no reading anyone can check the fixes against, and the finding(plugin-dashboard): the #3291 toDomProps whitelist stops at packages/fields — SDUI widgets elsewhere still close their DOM leak by hand, if at all #4425 phase-2 pattern this file already grades is explicitly "measure, then burn down per package, deleting your own rows".
  2. 119 fixes is not "genuinely one-line". The PM's fold criterion was a one-line fix; the smallest honest fix here is routing each renderer through toDomProps, times 119, in the most-shared package in the repo — the worst possible shape for a merge queue several agents land into.
  3. The ledger already forces the serialisation to happen. Because a fix fails the gate until its row goes, the follow-up cards cannot quietly not-happen.

Verification

All at ed1cbb162, the head of this branch, run after the final commit:

gateresult
pnpm exec vitest run packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsxTest Files 1 passed (1) / Tests 200 passed (200)
pnpm --filter @object-ui/app-shell type-checkexit 0 (tsc --noEmit && tsc -p tsconfig.test.json)
pnpm --filter @object-ui/app-shell lintexit 0
node scripts/check-changeset-presence.mjs✅ … declares 1 changeset(s)… Every one of them has an EMPTY frontmatter
node scripts/check-changeset-no-major.mjs✅ No changeset declares a major bump.
node scripts/check-control-bytes.mjs✅ … scanned 4672 tracked text file(s)

Vitest was run from the repo root throughout (#3378's guard; never package-cwd). The packages/app-shell suite is ~487 files and exceeds this container's foreground cap, so the sweep was run targeted; lint was run over the whole @object-ui/app-shell package — a provable superset of the diff at package granularity — reporting 918 files linted via --format json, with the changed file at 0 errors / 0 warnings. eslint.config.js configures no project/projectService, so linting here is not type-aware and this diff cannot move the verdict on any file it does not touch.

Changeset: present, and it declares nothing

Measured, not asserted. packages/app-shell/dist was built twice — once at the origin/main version of the changed file, once at this branch's — each time from a cleared dist/and a cleared tsconfig.tsbuildinfo, then sha256-compared:

IDENTICAL — 862 dist files, 431 of them .d.ts, all hashes equal

packages/app-shell/tsconfig.json excludes **/*.test.tsx from the build program, which is the mechanism behind that reading. One trap worth recording: the first attempt produced an empty dist/ and would have read as a false "identical" — tsc is composite, so with the build info left in place it skips the emit entirely. Clearing it is part of the measurement.


Generated by Claude Code

…erers
The sweep's discovery was four namespace prefixes wide, so the 158 types
registered from `packages/components/src/renderers/**` (`ui:`, `element:`,
`page:`, `action:`, `protocol-placeholder:`) sat outside the gate entirely.
That is why `ui:grid`'s leak had to be found by hand rather than by this file.
Widens the target set to all 158, and records what the first run finds as a
ledger: 119 of 158 targets leak, in eight measured shapes, every renderer
named. No target is skipped, quarantined or allow-listed — the per-target
assertion stays exact set equality in both directions, so a fix cannot go
green until its row goes.
Also records four classes of phantom clean the widening surfaced: 12 targets
rendered no element at all (closed overlays, `action:*` with no actions,
`ui:icon` with a non-icon `name`) and 4 threw `useSidebar must be used within
a SidebarProvider` into an attribute-clean error boundary. All 16 read clean
before they were made to render.
Renderer fixes are deliberately NOT folded in: the ledger is the record of
the tree the gate arrived on, and a change that both widens and fixes leaves
no reading to check the fixes against.
Part of #5574
…othing
Empty frontmatter, the repo's explicit "releases nothing" declaration.
Measured rather than asserted: `packages/app-shell/dist` built twice from a
cleared `dist/` and a cleared `tsconfig.tsbuildinfo` — once at the origin/main
version of the changed test file, once at this branch's — and sha256-compared.
862 emitted files, 431 of them `.d.ts`, every hash equal.
Part of #5574
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3785.8 KB3867.2 KB
Main entry chunk (gzip)151.6 KB350 KB
Entry fileindex-B7i2NkUs.js
StatusPASS

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

PackageSizeGzipped
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)8.91KB2.99KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)506.99KB113.73KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)238.85KB60.13KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.22KB3.08KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.72KB18.35KB
plugin-chatbot (index.js)181.41KB43.22KB
plugin-dashboard (index.js)128.36KB32.95KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.15KB60.89KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.70KB27.17KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.50KB20.68KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
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.08KB1.53KB
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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — domain:ui seat review, round 8

Gate read by name on ed1cbb162: 19 success + the 3 always-skipped no-ops = 22. Shards in at 19:34:15–19:37:03Z — the slowest of the round, which is expected when a PR takes one app-shell suite from 74 cases to 200.

The fold-or-serial ruling is on the card (#5574) rather than repeated here: SERIAL, and the argument that decided it was yours, not my dispatch's default.

The probe design is the best this seat has reviewed this session

Three legs, and the part that makes it work is structural rather than clever: both probes ran in one vitest invocation, so discovery mechanism, judge and harness were provably identical across probe and control. Nothing was left to "I ran them the same way."

legperturbationresult
probeone extra ui: registration into origin/main's sweep, verbatim via git showgreen — the pre-widening completeness case never saw it
controlthe same perturbation into the same sweep, only the namespace differs (plugin-dashboard:)red, naming plugin-dashboard:zzprobe5574
verificationthe same ui: injection against this branch's gatered, naming the missing target

The control is at the probe's scope and it can fail — it did. That is the standing rule satisfied properly, and the third leg closes the loop my dispatch only asked half of: it proves the widened gate would now catch what the old one missed, rather than merely that the old one missed it.

Coverage before/after at identical scope, which is what I asked for and got: 0 of 86 → 86 of 86 renderer files, 0 of 158 → 158 of 158 registry types, 0 of 5 → 5 of 5 namespaces.

The changeset measurement carries a trap the lane did not know about

Empty-frontmatter changeset — this repo's explicit "publishes nothing" declaration, and the right form here. check-changeset-presence exited 1 first and 0 with the file, so the requirement was demonstrated rather than assumed. No skip-changeset label was invented or applied; there is none in this repo.

The dist measurement was taken rather than asserted: packages/app-shell/dist built twice, once per leg, sha256-compared — identical, 862 emitted files, 431 of them .d.ts. Mechanism named: tsconfig.json excludes **/*.test.tsx from the build program, so a test-only change cannot move emitted output.

And the trap, which is new to this lane's notes: the first attempt produced an empty dist/ and would have read as a false "identical". tsc is composite — with tsconfig.tsbuildinfo left in place it skips the emit entirely. Clearing dist/ alone is not enough; the build info has to go too, or you are comparing two empty directories and calling them equal.

That joins the two measurement traps this lane already carries — gzip storing the source filename in its header, and a zero byte-size delta over non-identical content (index.umd.cjs on #5626, differing at char 2083 with equal length). All three have the same shape: a measurement that returns the expected answer for the wrong reason.

The ledger, and the finding inside it

Covered in full on #5574. The two things worth restating on the PR itself:

It is a ledger, not an allowlist, by construction. Exact set equality in both directions — a listed renderer that leaks one more attribute fails, and one that stops leaking also fails until its row is deleted. Nothing is skipped, it.skip-ed or quarantined; all 158 targets render and are scanned every run. The three meta-cases (no renderer in two groups; every readiness override carries a reason and every reason an override; the four card-named renderers ledgered while ui:grid must not be) are what stop it decaying.

The 16 phantom cleans are the real discovery. First pass read 46 clean; 16 of those rendered nothing or threw into an attribute-clean error boundary, and once made to render real markup, 7 leaked. A DOM-attribute gate cannot tell "safe" from "absent" without a rendering-something precondition. Finding that before publishing the ledger is the difference between a census and a comfortable number.

Two details I would have missed and am recording: BARE_SPREAD_ON_SVG preserves camelCase because SVG attribute names are case-sensitive and a lowercased ledger would have dropped those two; and ui:sidebar-trigger leaks schema itself — the node SchemaRenderer injects on every render.

ui:grid clean means #4787 / PR #5573, which this lane landed in round 4, is now pinned by a gate from both sides instead of by hand.

Findings

#5630 and #5631 labelled finding + domain:ui, unassigned. #5631 got a note distinguishing it from #5586 and #5622 — three defects on one resolver, wrong schema key vs wrong name vs wrong resolver table, none a duplicate of the others.

Burn-down: I am not filing eight cards unilaterally. Reasoning and what I will file instead is on #5574.

Not counted as evidence

The bundle bot's PASS; the check_suite.completed bursts.

Landing now.


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

@os-sales@claude