Skip to content

fix(detail): inline edit no longer destroys array values or flattens types on the record page - #4244

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4220-inline-edit-delegate
Aug 11, 2026
Merged

fix(detail): inline edit no longer destroys array values or flattens types on the record page#4244
yinlianghui merged 1 commit into
mainfrom
claude/issue-4220-inline-edit-delegate

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#4220

InlineFieldInput's type switch ends in a raw text input, and every type it has no branch for landed there: displayed through coerceToSafeValue and written back as whatever the user typed — a bare string. #4216/#4222 closed the structured composites, #4228 closed the containers and credentials at the host gate. This is the rest of the sweep.

Premise, re-measured on this tip (all confirmed)

A throwaway probe asserted the CURRENT behaviour before a line was changed; every assertion passed on origin/main (17/17):

classstorededit box showedemitted on save
A tags / checkboxes / multiselect / select+multiple, no options['alpha','beta']"alpha, beta" (coerceToSafeValue joins arrays)"alpha, beta, gamma" — a string over the array
C toggletrue"true""whatever-i-type" — a string over the boolean
C slider / progress / rating40 / 3"40" / "3"a string over the number
C radio'alpha'"alpha"any free-typed value the option list never offered
no branch at alljson, code, color, time, qrcode each reached input[type=text]

Class B is already closed and was verified, not redone: object / composite / record / grid / repeater / vector are all gated by #4228's shared-exclusion consultation.

The four-way classification, as landed

One fallback branch, not per-type routing. Every field type in FORM_FIELD_TYPES UNION the spec FieldType enum (57 types) now sits in exactly one bucket:

bucketdecided bytypes
excluded (18)the hosts' gates — the fields package's shared set + the computed setauto_number, autonumber, composite, filter-condition, formula, grid, html, markdown, object, object-ref, password, recipient-picker, record, repeater, richtext, secret, summary, vector
routed (22)InlineFieldInput's own switch — unchanged, no UX churnaddress, audio, avatar, boolean, currency, date, datetime, file, geolocation, image, location, lookup, master_detail, multiselect, number, owner, percent, select, signature, tree, user, video
delegated (12)FieldEditWidget — the same control the form renderscheckboxes, code, color, json, progress, qrcode, radio, rating, slider, tags, time, toggle
benign (5)the terminal text input, losslesslytext, textarea, email, phone, url

Two conditional members are pinned by name rather than by type: an options-less singleselect stays on the plain input (its value is a bare string, and the widget's own empty state would leave the user unable to enter anything), while the multiple spelling of that same degenerate field delegates and keeps its array.

select is deliberately absent from the benign set; markdown / html / richtext are excluded rather than benign because the hosts stopped opening an editor for them in #4228.

The drift guard is the deliverable that outlives the fix

packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx — 67 assertions. Every type must be in exactly one bucket: none is red (a new type would otherwise inherit the value-destroying default), two is red (overlapping claims). The benign list is enumerated explicitly, never "everything else" — the open tail is the drift itself. This family has produced six cards from one cause (#2942, #3355, #4216, #4219, #4221/#4228, #4220).

The declarative half alone would be a tautology over four hand-written sets, so each declared bucket is also checked against what the component really RENDERS, via a data-testid on the terminal input. Sniffing input[type=text] cannot do this: TextField, ColorField and QRCodeField render one too, so the heuristic reads a benign type's correct fallback and a delegated type's correct widget as the same thing.

FieldEditWidget under the inline hosts — measured

  • value/onChange contract: identical to the routed widgets' — { field, value, onChange }. The hosts' draft ({ ...data, ...editedValues } → one atomic dataSource.update) is value-shape agnostic, so an array / boolean / number reaches the write unchanged. Pinned per class.
  • autoFocus: FieldEditWidget now forwards it, and each widget's own toDomProps whitelist lands it on the real focusable control (pinned on tags' chip input and code's editor). No dataSource is needed — every record-querying type is routed above.
  • sizing: no delegated type needs compact (that is set only for the relational pickers, all routed). The tallest surfaces are checkboxes / radio option lists, which grow the row the same way the routed multi-select chip row already does.
  • One measured hazard, fixed at the producer instead of worked around. All four fixed-option widgets end their cascade resolution with a "drop what is no longer offered" effect. It ran against the RESOLVED set without asking why that set was empty, and the two causes are opposites: a list that cascaded to zero (a real decision — clear, ADR-0058), and a field that declares no options at all (no decision — the widget renders its "nothing to pick" state). In the second case it deleted the stored value on mount, and the detail hosts render every editable field in a section at once, so an options-less checkboxes would have staged an empty array into the record draft the moment the user started editing any other field in that section, and the save bar would have written it back. The prune now requires an authored list to prune against; the cascade itself is untouched, and four controls pin that a configured list still clears what it no longer offers. This also closes the same path in the grid's inline cell editor, which has always delegated here.

No type turned out to be genuinely unusable inline, so no new host-gate exclusion was needed.

Reverse verification

Predicted before running, both times, and taken out with git checkout (never git stash — shared stack):

  1. Delegation branch deleted → 32 red, exactly the predicted set: every class A pin (joined-string display, string write), every class C pin (getByRole('switch') / ('slider') unable to find, stars absent, radio back on a text box), the five never-branched types, and the 12 delegated entries of the coverage guard. Every class D pin, every routed control (select+options, boolean, number, date, address, lookup, image, the permission-facet-link hint) and the whole partition half stayed green — 81 passed.
    One honest deviation from the naive prediction: tags' autoFocus pin stays GREEN without the fix, because the terminal input honours autoFocus too. That assertion only discriminates for a widget whose control is not a bare input (code's textarea), which is where it went red.
  2. Cascade guards reverted → exactly 7 red: one per widget in the new fields pin (onChange called once, with [] / undefined) plus the three detail-page pins for the options-less picklists. All four cascade controls stayed green, which is the assertion that the ADR-0058 behaviour was not weakened.
  3. Type level — pasting a key the rebuilt .d.ts rejects (autoFocusTypo) at the delegation call site fails with TS2322 … Did you mean 'autoFocus'?, so the typecheck read the freshly built declarations rather than a cached copy.

#4219 ride-along verdict — still worth landing

Measured (and now pinned): a field typed autonumber renders no inline affordance and no editor on either host, in the details body and inside an active session on the highlights strip. #4228 changed the reachability underneath that report — autonumber resolves through the alias table onto the excluded auto_number, so the shared exclusion catches it, not the computed set the card names.

The spelling fix is still worth landing, and not only as defence in depth, on two measured paths:

  1. TEXTUAL_REF_FALLBACK_TYPES is read directly by InlineFieldInput's reference fallback — !!field.reference_to && !TEXTUAL_REF_FALLBACK_TYPES.has(type) — with no host gate in front of it. InlineFieldInput is exported public API, so an autonumber carrying a reference_to still resolves into the record picker there today. That is the input-side half of A field typed autonumber (the spec spelling) is inline-editable on the detail page, so a user can overwrite a machine-generated value #4219's own report, and no exclusion-set consultation touches it.
  2. The exclusion set is documented as movable ("To add a type to inline editing, move it from here into EDIT_WIDGETS"). The two gates are a union precisely so that one of them going away is survivable; today auto_number would still be caught by the computed gate and autonumber would not, so the protection of a machine-generated identity depends on which spelling the metadata happens to use.

Nothing about #4219 is changed here — it is a separate card, and this PR deliberately does not pin today's asymmetry in either direction, since a pin asserting it would go red on the very fix that card proposes.

Verification

  • pnpm exec vitest run packages/plugin-detail/ packages/fields/148 files, 1929 tests, all passed (repo-root cwd, path-filtered per AGENTS.md §测试纪律).
  • New: 35 delegation pins, 67 coverage-guard assertions, 8 option-widget pins.
  • pnpm --filter '@object-ui/plugin-detail^...' --filter '@object-ui/plugin-grid^...' build → Done; type-check on plugin-detail / fields / plugin-grid (the other FieldEditWidget consumer) → Done.
  • pnpm exec eslint on all nine changed files → 0 errors (66 pre-existing no-explicit-any warnings; --max-warnings is deliberately unset in this repo).
  • pnpm run check:control-bytes → OK, 3946 files. node scripts/check-changeset-presence.mjs → OK, 1 changeset for 2 released packages.
  • No i18n change: no user-visible copy is authored here. The delegated widgets' strings are the fields package's existing translated keys (fields.options.empty, fields.tags.placeholder, …).

Generated by Claude Code

…idget
InlineFieldInput's type switch ends in a raw text input, and everything it has
no branch for landed there: displayed through coerceToSafeValue and written
back as whatever was typed, a bare string.
Two damage classes survived #4216 (structured objects) and #4228 (containers +
credentials, closed at the host gate). Array-valued fields (tags, checkboxes,
an options-less multi picklist) were offered as "a, b" — coerceToSafeValue
joins arrays — and saved as that string. Type-lossy scalars (toggle, slider,
progress, rating, radio) round-tripped through String(), so a boolean column
received "true", a numeric one "42", and radio accepted any free-typed value
its option list never offered.
Types the switch already routes keep their editors, so no working path churns.
Everything else the fields package can edit inline now falls back to
FieldEditWidget — the form's own control, json to the code editor included —
and only genuinely string-valued types keep the plain input. The design calls
inside the array class resolve inside that delegation, which is the point of
delegating rather than routing type by type.
The lasting deliverable is the drift guard: every member of the form widget map
UNION the spec FieldType enum must be exactly one of routed / excluded /
delegated / benign, and the declared bucket is checked against what the
component really renders. A new type in none of the four is red, instead of
inheriting the value-destroying default in silence — this family has produced
six cards from that one cause.
@object-ui/fields: the four fixed-option widgets no longer clear the stored
value when no options are authored. An empty offered set had two opposite
causes — cascaded to zero (clear, ADR-0058) and never configured (nothing to
decide) — and the second deleted the value on MOUNT, which the delegation would
have staged straight into the record draft. FieldEditWidget also forwards
autoFocus so an inline host's caret lands on the widget's own control.
Fixes#4220
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercelBot commented Aug 11, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 11, 2026 7:06am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.3 KB350 KB
Entry fileindex-RBl3JSiR.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.88KB3.25KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
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)488.60KB108.25KB
core (index.js)3.04KB1.15KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)144.34KB37.61KB
fields (index.js)228.43KB56.61KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
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)61.52KB17.49KB
plugin-chatbot (index.js)180.33KB42.79KB
plugin-dashboard (index.js)118.58KB30.71KB
plugin-designer (index.js)210.85KB42.64KB
plugin-detail (index.js)239.08KB59.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.97KB49.90KB
plugin-kanban (index.js)48.60KB13.41KB
plugin-list (index.js)110.31KB26.76KB
plugin-map (index.js)17.00KB5.32KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
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 (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)2.71KB1.34KB
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

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.

Inline edit still destroys array-valued and container field values on the detail page (the rest of the #4216 fall-through sweep)

2 participants

@yinlianghui@claude