Skip to content

fix(components): stop previousValues leaking onto the <form> DOM node - #6413

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-6396-previousvalues-spread-hygiene
Aug 25, 2026
Merged

fix(components): stop previousValues leaking onto the <form> DOM node#6413
os-support-ai merged 1 commit into
mainfrom
claude/issue-6396-previousvalues-spread-hygiene

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes #6396

Verified tree: 643094dee — every reading below was taken on that exact tree (working tree clean at push time).

Step 1 — the fork: does previousValues have a real consumer on the master-detail header path?

Yes. This is step 2 (spread hygiene), not step 3 (enforce-or-remove). The measurement:

  • packages/components/src/renderers/form/form.tsx:979 destructures previousValues off schema, and :1140-1147 builds previousRecord from it. That memo is load-bearing twice: it binds previous for field-rule CEL predicates, and isCreateForm = previousRecord === undefined is the INSERT/UPDATE signal the read-only submit strip gates on (objectui#3484).
  • The producer on the header path is live: ObjectForm.tsx:1057 computes previousValues for mode === 'edit' && recordId, and puts it on the form node at both :1270 and :1423. MasterDetailForm.tsx:976 renders that ObjectForm as its header.
  • Three existing suites exercise that consumer end-to-end (form-readonly-when-previous, form-readonly-submit-strip, form-required-when-server-owned).

So the declared key is not dead, nothing here goes to the enforce-or-remove channel, and the authorable surface is untouched: packages/types/src/form.ts and packages/types/src/zod/form.zod.ts are not in this diff. Clause-② no.

The defect

The consumer above reads schema.previousValues. The leak is on the other channel.

SchemaRenderer spreads every non-metadata top-level schema key as a React prop in addition to handing the node over as schema (packages/react/src/SchemaRenderer.tsx:1035 / :1105). So an edit-mode host delivers a second, top-level copy of previousValues in the renderer's ...props.

The renderer already consume-and-drops that entire family before its DOM spread — objectName, onDirtyChange, defaultValues, fields, layout, columns, … at form.tsx:2531-2582, with a comment naming exactly this mechanism ("Some callers / the SDUI dispatch spread the whole form node at the top level … so these arrive in props and would leak onto the DOM"). previousValues was the one member of the family missing from that list, so it rode ...formProps onto <form>.

The one-line fix adds it to the existing list. The schema consumer never reads props, so it cannot be affected.

Premise re-derived on the merged tree

The card measured this on origin/main @ 631d81dbf; main has moved many times since. Re-derived on 062943f86 (the merge-base this branch was cut from) — still present, and worse than the card recorded. React 19 does not merely decline the prop, it also stamps it on the element:

React does not recognize the `%s` prop on a DOM element. … previousValues previousvalues
expected '[object Object]' to be null   ← form.getAttribute('previousvalues')

So the persisted record was reaching the DOM as previousvalues="[object Object]" on every edit-mode header render. (The attribute stringifies to [object Object], so no field values were exposed — but the attribute should not be there at all.)

Ghost-assertion guard — both readings

The instrument asserts on captured console.error, because the symptom is a React console warning, not a thrown error or a DOM difference. Each call's arguments are joined before matching, since React passes the prop name as a separate %s argument rather than inside the format string.

Against unmodified origin/main (source untouched, test file only) — FAILING:

 ❯ form-previous-values-dom-leak.test.tsx (4 tests | 2 failed)
   × does not leak `previousValues` onto <form> on an edit-mode header render
   × still renders the header, and the DOM node carries no previousvalues attribute

AssertionError: expected [ Array(1) ] to deeply equal []
+ [ "React does not recognize the `%s` prop on a DOM element. … previousValues previousvalues" ]

AssertionError: expected '[object Object]' to be null

 Test Files  1 failed (1)
      Tests  2 failed | 2 passed (4)

With the fix — PASSING:

 Test Files  1 passed (1)
      Tests  4 passed (4)

The two controls that keep the pin honest

The 2 tests that passed in both runs are deliberate, and they are the reason a green pin means something:

  • Positive controlPOSITIVE CONTROL: the capture catches a real unknown-prop warning renders a bare <div madeUpProp={…}> and asserts the capture does return React's notice. Without it, the pin would keep passing if the capture broke or React stopped warning — a green vacuous assertion, which is the exact failure class this card is about.
  • Degenerate controlDEGENERATE CONTROL: tolerates unrelated console output on a clean render renders the same node without previousValues, emits an unrelated console.error, and asserts the pin still passes. The matcher requires /does not recognize/ and the prop name, so it pins one named prop rather than "the render printed nothing"; the next unrelated warning cannot break it.

The new test renders through SchemaRenderer rather than pulling the component off the registry directly. That is the whole reason a declared key could leak to the DOM on every edit-mode header render without a single existing test noticing: <Form schema={…} /> never populates ...props.

Verification

All heavy runs serialized through the container's shared verify lock; exit codes captured before any pipe, and each verdict quoted from the tool's own output.

Check Result
New pin, unmodified main Tests 2 failed | 2 passed (4) — ghost-assertion guard
New pin, with fix Tests 4 passed (4)
vitest run packages/components/src/renderers/form/ Test Files 55 passed (55) / Tests 366 passed (366)
vitest run packages/plugin-form/ (master-detail host path) Test Files 71 passed (71) / Tests 726 passed (726)
pnpm --filter @object-ui/components run type-check TYPECHECK_EXIT=0
eslint .whole repo, one pass files linted: 3788, errorCount total: 0
check:control-bytes OK (scanned 5302 tracked text file(s))
check:phantom-deps / check:self-import / check:esm-specifiers / check:vi-mock-specifiers all EXIT=0
check-changeset-presence 1 source file(s) of 1 released package(s) changed … declares 1 changeset(s)
check-changeset-no-major No changeset declares a major bump

The lint reading is a full repo-wide run, not a narrowed one — no scope-reduction argument is being relied on.

Type-check coverage was measured rather than assumed: tsc -p packages/components/tsconfig.test.json --listFiles reports the new test file and renderers/form/form.tsx each present in the program (1 hit each), so "type-check clean" actually covers this diff.

Dependency closure (pnpm --filter '@object-ui/components^...' build) was built before type-checking, so the .d.ts files read are current rather than stale.

Scope

One line of production change plus its test and changeset (@object-ui/components, patch).

Deliberately not touched:


Generated by Claude Code

`FormSchema.previousValues` is a declared schema key with a real consumer —
the form renderer's `previousRecord` memo, which binds `previous` for
field-rule CEL predicates and is the INSERT/UPDATE signal the read-only
submit strip gates on (objectui#3484). That consumer reads it off `schema`
and is untouched here.

The leak was on the other channel. `SchemaRenderer` spreads every
non-metadata top-level schema key as a React prop *in addition* to handing
the node over as `schema`, so an edit-mode host (`ObjectForm`, which is what
the `object-master-detail-form` header composes) delivered a second,
top-level copy in `...props`. The renderer already consume-and-drops that
whole family before its DOM spread — `objectName`, `onDirtyChange`,
`defaultValues`, `fields`, `layout`, … — and `previousValues` was the one
member missing from the list, so it rode `...formProps` onto `<form>`.

Two things followed on every edit-mode header render: React declined the
prop ("does not recognize the `previousValues` prop on a DOM element"), and
— measured on React 19, not recorded on the card — the persisted record was
still stamped onto the element as `previousvalues="[object Object]"`.

The new test renders through `SchemaRenderer` (the host path) rather than
off the registry directly, which is precisely why every existing
`previousValues` test missed this: rendering `<Form schema={…} />` never
populates `...props`. It asserts on captured `console.error` output and
carries two controls — a positive control proving the capture still catches
a real unknown-prop warning, and a degenerate control proving the pin
matches this one named prop rather than "the render printed nothing".

Scope is the runtime leak only. The declared key is unchanged.

Fixes #6396
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3230.8 KB 3266.6 KB
Main entry chunk (gzip) 156.1 KB 350 KB
Entry file index-Ef_9g9jp.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 11.30KB 4.28KB
app-shell (runtime-config.js) 18.10KB 6.51KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 505.86KB 114.58KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.18KB 47.97KB
fields (index.js) 238.89KB 60.02KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.66KB 12.84KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 188.60KB 44.82KB
plugin-dashboard (index.js) 133.46KB 34.48KB
plugin-designer (index.js) 211.95KB 42.75KB
plugin-detail (index.js) 245.10KB 62.31KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 131.78KB 32.19KB
plugin-gantt (index.js) 164.14KB 39.87KB
plugin-grid (index.js) 201.79KB 54.60KB
plugin-kanban (index.js) 52.87KB 14.57KB
plugin-list (index.js) 112.63KB 27.45KB
plugin-map (index.js) 20.09KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.70KB 7.69KB
plugin-tree (index.js) 9.26KB 3.13KB
plugin-view (index.js) 84.55KB 20.74KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 54.84KB 18.43KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.35KB 0.70KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 12.13KB 3.65KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 7.54KB 2.63KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@os-support-ai
os-support-ai marked this pull request as ready for review August 25, 2026 22:15
@os-support-ai
os-support-ai added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit f24de8b Aug 25, 2026
29 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-6396-previousvalues-spread-hygiene branch August 25, 2026 22:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] the declared previousValues form key reaches the DOM as an unknown React prop (warning on every ObjectForm header render)

2 participants