Skip to content

fix(fields): FieldEditWidget forwards the DOM pass-through block it declares - #7009

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-6909-fieldeditwidget-passthrough
Aug 31, 2026
Merged

fix(fields): FieldEditWidget forwards the DOM pass-through block it declares#7009
os-sam merged 1 commit into
mainfrom
claude/issue-6909-fieldeditwidget-passthrough

Conversation

@claude

@claude claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #6909

FieldEditWidget declared the whole DOM pass-through block and delivered one key of it. It now delivers the block.

Direction 1 from the card, and only direction 1. Direction 2 (narrowing this component's published prop type to the five keys it honoured) is deliberately not taken here: it would break any caller passing those keys today, and it is a contract decision for the maintainer.

The defect

The component's props are FieldWidgetComponentProps — the controlled-input keys intersected with FieldWidgetDomProps, AriaAttributes and the open data- family. A host could always pass id, name, autoFocus, tabIndex, onBlur, onFocus, onClick, any aria- and any data- attribute with no type error. The body then destructured field, value, onChange, readonly, autoFocus and rendered the widget with those five plus compact. autoFocus was the only survivor of the whole block.

That is this package's own first-class defect class, named in packages/fields/src/widgets/toDomProps.ts: a key that type-checks, reads as supported, and silently never reaches the element (#3290's aria-required, #3222's validation slot). toDomProps binds the WIDGET contract to its whitelist with compile-time assertions in both directions. Nothing bound this factory to either, so the factory was the one unbound link in the chain.

Not a widening, and not a contract change

The keys were already declared, and every widget still re-filters through its own toDomProps before anything reaches a DOM element. What any widget accepts or rejects is unchanged — this only makes the implementation deliver what the declaration already promised.

The fix, and why it is the reuse rather than a new list

The factory hands the widget toDomProps(props) — this package's own executor of the declaration — instead of a second key list written out in the factory. That reuse is the structural guard: toDomProps.ts's direction-2 assertion already makes keyof FieldWidgetDomProps extends DomPassThroughKey a compile error to violate, so a key added to the declared DOM block now reaches the widget through this factory automatically. One mechanism, one judge. A private list here would have been free to drift, which is exactly how the factory came to deliver one key out of seven.

The forwarded set is a deliberate superset of FieldWidgetDomProps: it also carries className and disabled, declared on the controlled-input block and forwarded by the same executor for the reason stated there (withholding them makes it a silent styling- and interactivity-dropper). Semantic props — field, value, onChange, readonly, and compact for the relational pickers — stay explicit and are applied after the spread, so a host cannot displace them.

The triage measurement the card asked for

Per host, read at the call site on this branch's base:

host file keys passed to FieldEditWidget passes a dropped key?
ObjectGrid.renderCellEditor packages/plugin-grid/src/ObjectGrid.tsx:3690 field, value, onChange no
InlineFieldInput packages/plugin-detail/src/InlineFieldInput.tsx:434 field, value, onChange, autoFocus no — autoFocus is the one key that already survived
RequiredFieldsDialog packages/plugin-kanban/src/RequiredFieldsDialog.tsx:125 field, value, onChange, readonly no

Those are the only three JSX call sites in the repo outside this package's own tests (grep -rn "FieldEditWidget" --include=*.tsx --include=*.ts over packages and apps).

So: plain repair, not a live regression — no host is silently losing a key today. But one host had already WORKED AROUND the drop. RequiredFieldsDialog wraps every control in a label and says why: "FieldEditWidget renders the widget itself and takes no id to associate with, and widening its contract belongs to @object-ui/fields, not to a caller." The premise was false at the type level all along — id is declared — and true only at the implementation level, which is this defect. It takes an id now.

Card measurement re-derived, not inherited: EDIT_WIDGETS has 31 keys resolving to 27 distinct components; 26 call toDomProps themselves and UserField delegates its whole props object to LookupField via {...(props as any)}; none strips onBlur — the five that own a blur handler compose the host's (#6780, #6802). That matches the card's 27 / 26 / 1 exactly.

Evidence the fix is load-bearing

Pin: packages/fields/src/__tests__/FieldEditWidget.domPassThrough-6909.test.tsx, written before the fix.

RED first (pin only, before touching the factory) — Test Files 1 failed (1) · Tests 3 failed (3). The boundary assertion printed the defect directly:

AssertionError: expected [ 'autoFocus', 'field', ...(3) ] to deeply equal [ 'aria-label', 'autoFocus', ...(13) ]
-   "aria-label"   -   "className"   -   "data-os6909"   -   "disabled"
-   "id"   -   "name"   -   "onBlur"   -   "onClick"   -   "onFocus"   -   "tabIndex"

GREEN after — same command, Test Files 1 passed (1) · Tests 3 passed (3).

Ablation — revert only the forwarding (toDomProps(props) spread replaced by autoFocus={props.autoFocus}, i.e. the exact pre-fix shape) and the pin goes red again with the identical 5-key set. No build or dist leg is involved and that is measured, not assumed: vitest.config.mts aliases @object-ui/fields to packages/fields/src, and the pin imports ../FieldEditWidget relatively, so the test executes the source file the script mutates (both asserted in the script's preflight).

Mutation proved on disk by anchored counts in BOTH directions plus hashes, never by an exit code:

PRE   forward-anchor 1 · ablation-anchor 0 · worktree 70ad0de26e489c5d99a4fa6d9f2b85a4e9a2b934 (== HEAD blob)
POST  forward-anchor 0 · ablation-anchor 1 · worktree fa983d0c4ab7b96c417a3c2a5ed02fa52f98b5b0
RUN   Test Files 1 failed (1) · Tests 3 failed (3) · vitest exit 1
REST  worktree 70ad0de26e489c5d99a4fa6d9f2b85a4e9a2b934 == HEAD blob · git diff HEAD empty · anchors back to 1 / 0

The restore is git checkout HEAD -- <absolute path> (never bare — a bare restore reads the index, which a mutation can pollute), under trap ... EXIT INT TERM, with the repo root taken from git rev-parse --show-toplevel.

Control — an undeclared authored key (zzcanary) must still be dropped, or "everything forwards now" would be indistinguishable from having reopened the bare spread toDomProps exists to prevent. It is asserted at two points, because the DOM alone cannot see a reopened spread (each widget re-filters and would quietly rescue the mistake):

  • at the factory boundary, as an EXACT prop-key set — zzcanary absent, and no other extra key;
  • at the DOM, with the data- probe present in the same render so the absence is a measurement and not an empty tree.

Verification

Union run after the final commit, at 0071a0825 (git status --porcelain empty):

  • pnpm exec vitest run packages/fields/Test Files 126 passed (126) · Tests 2105 passed (2105)
  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-detail/ packages/plugin-kanban/ (the three hosts) — Test Files 235 passed (235) · Tests 2105 passed (2105)
  • pnpm --filter @object-ui/fields --filter @object-ui/components type-check — both Done, exit 0. The new test file is a real program input, not merely present: before a typing fix, this same command failed with src/__tests__/FieldEditWidget.domPassThrough-6909.test.tsx(168,24): error TS2769, which is what proves tsconfig.test.json compiles it.
  • pnpm exec eslint . --format json (plain form) — 4035 files, 0 errors, exit 0. Repo-wide, so no narrowing to justify.
  • Gates: check-changeset-presence "3 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"; check-changeset-no-major, check-changeset-fixed, check-changeset-overwrite, check-control-bytes (5815 files), check-side-effects-array, check-entry-guard, check-type-check-coverage (45/46), check-lint-coverage (46/46), check-package-self-import, check-node-esm-load --specifiers-only, check-vi-mock-specifiers, check-vi-mock-inherit — all exit 0.
  • check-eager-closure-budget is NOT MEASURED locally, not green and not red: it needs apps/console/dist/eager-closure.json from a console build this branch never ran, and it says so itself ("This is a broken gauge, not a passing budget"). Nothing in this diff touches apps/console or the eager-closure surface. CI builds the console first.

Dependency closure built before any type-check (pnpm --filter '@object-ui/fields^...' build), so no unbuilt-tree false red.

One bounded in-place correction, declared

packages/components/src/renderers/complex/data-table.tsx — comment only, no code. Its injected-editor comment justified the document-level pointerdown listener partly with "FieldEditWidget forwards autoFocus and nothing else out of the DOM block, so a host handler could not reach the control through it even if one were passed." This PR makes that sentence false, so it is corrected in the same change rather than left to rot — that comment was itself landed to replace an earlier stale one (#6912, for #6859), and this repo treats a confidently wrong comment as a first-class defect.

The listener stays load-bearing, on the untouched half of the same reason: the renderCellEditor context object still has nowhere to put an onBlur. Widening it is a DataTableSchema contract change and is not proposed here. No test pinned the corrected sentence (grepped), and packages/components type-check and the repo-wide eslint are green above.

Not in this PR

FieldEditWidget also does not forward the DECLARED non-DOM host-plumbing keys — error, dataSource, dependentValues, dependsOn, dependsOnLabels, emptyHint, onUploadingChange, onSelectRecord, onCreateNew. Same declared-but-not-delivered shape, but each carries real design content (error drives aria-invalid; who produces dependsOnLabels for an inline host is a question, not a mechanical answer), so it is not a mechanical extension of this repair. That half is filed as #7008 and remains open — out of scope here.

Generated by Claude Code


Generated by Claude Code

…eclares

Its props are `FieldWidgetComponentProps` — the controlled-input keys
intersected with `FieldWidgetDomProps`, `AriaAttributes` and the open `data-`
family — so a host could always pass `id`, `name`, `autoFocus`, `tabIndex`,
`onBlur`, `onFocus`, `onClick`, any `aria-*` and any `data-*` with no type
error. The body destructured five keys and rendered the widget with those, so
`autoFocus` was the only survivor of the whole block.

The factory now hands the widget `toDomProps(props)` — this package's own
executor of that declaration — rather than a second key list. The reuse is the
guard: `toDomProps.ts`'s direction-2 assertion makes
`keyof FieldWidgetDomProps extends DomPassThroughKey` a compile error to
violate, so a key added to the declared block reaches the widget through this
factory automatically. Semantic props stay explicit and are applied after the
spread.

Not a widening: the keys were already declared, and every widget still
re-filters through its own `toDomProps` before anything reaches the DOM.

Also corrects the `data-table.tsx` comment this falsifies — the injected-editor
listener's other justification (the `renderCellEditor` context has nowhere to
put an `onBlur`) is untouched and still load-bearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 45 chunks) 3180.3 KB 3222.7 KB
Main entry chunk (gzip) 143.6 KB 350 KB
Entry file index-DXcoi8Tk.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) 12.46KB 4.71KB
app-shell (runtime-config.js) 20.68KB 7.36KB
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) 512.08KB 116.42KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 177.67KB 49.45KB
fields (index.js) 243.64KB 61.63KB
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) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
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) 4.83KB 2.27KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.92KB 12.93KB
plugin-charts (index.js) 64.68KB 18.35KB
plugin-chatbot (index.js) 190.53KB 45.18KB
plugin-dashboard (index.js) 133.48KB 34.51KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 247.26KB 63.17KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 133.11KB 32.61KB
plugin-gantt (index.js) 165.21KB 40.37KB
plugin-grid (index.js) 202.08KB 54.61KB
plugin-kanban (index.js) 53.14KB 14.64KB
plugin-list (index.js) 113.15KB 27.59KB
plugin-map (index.js) 20.20KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 29.05KB 8.37KB
plugin-tree (index.js) 8.98KB 3.08KB
plugin-view (index.js) 85.79KB 21.10KB
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) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 3.11KB 1.48KB
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) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
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.85KB 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-sam
os-sam marked this pull request as ready for review August 31, 2026 09:26
@os-sam
os-sam added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit b458300 Aug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6909-fieldeditwidget-passthrough branch August 31, 2026 12:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants