Skip to content

fix(fields): a select no longer wipes itself when its value outruns its options (#2968) - #2969

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-2968-review-094653
Jul 30, 2026
Merged

fix(fields): a select no longer wipes itself when its value outruns its options (#2968)#2969
hotlong merged 2 commits into
mainfrom
claude/issue-2968-review-094653

Conversation

@baozhoutao

@baozhoutaobaozhoutao commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Closes#2968.

Root cause

Not visibleWhen, and not the cascade-clear path — both of those write
undefined, which does not match the '' the reporter measured. The writer is
Radix's hidden native <select>mirror.

Radix renders an aria-hidden<select name=…> alongside every Select so the
value takes part in native form submission. When the controlled value changes,
it assigns the mirror through HTMLSelectElement.prototype.value and dispatches
a synthetic change. If the mirror carries no <option> for that value the
assignment is a no-op — the element stays on '' — but the change fires
anyway, so '' comes straight back out through onValueChange and into
react-hook-form, on top of the value the caller just set.

The window is not theoretical: SelectContent returns null on its first
render (its DocumentFragment is created in a layout effect), so the native
options register a commit after the trigger mounts. A record landing after
first paint — an edit modal whose findOne is still in flight — resets the form
into exactly that gap.

That explains every detail of the report: the value is '' and not undefined,
only select fields are hit, _defaultValues stays correct while _formValues
is blanked, and it is timing-dependent. And because one wiped field is usually
the one the visibleWhen predicate reads, the predicate flips back to false, the
conditional fields hide again, and the form latches broken.

Demonstrated directly against the Select wrapper (pre-fix):

scenarioonValueChange received
undefined → value present in options[]
undefined → value not in options[""]
value lands before the options[""]
option list changes, value unchanged[]

Fix

SelectItem rejects value="" outright, so '' can never be a value a user
picked — it is always the mirror talking. Drop it at the single Select
chokepoint (packages/components/src/ui/select.tsx), which covers every surface
that renders one: the object form, the inline grid editor, ActionParamDialog.
Clearing a select still goes through undefined, so the dependsOn
cascade-clear is untouched.

Tests

Two regression files, red before the fix and green after (3 failed → 6 passed):

  • packages/components/src/ui/__tests__/select-empty-writeback.test.tsx — the
    wrapper's invariant, including a positive assertion that a real selection is
    still forwarded.
  • packages/components/src/renderers/form/__tests__/form-select-value-survives-rules.test.tsx
    — the form-level shape from the issue: a record landing late keeps both
    selects and reveals the visibleWhen field, and no '' is ever recorded as a
    user edit.

Full DOM suite: 4113 passed / 24 skipped, 0 failed. The 18 unit-project
failures on this branch are pre-existing (verified by re-running them with the
fix stashed): spec Theme schema parity, plugin-kanban, data-objectstack
onMutation, DATEFORMAT.

Browser verification

Framework examples/app-showcase on a private port, driven through objectui's
own console dev server (so the running UI is this branch's src, not the
vendored bundle). Fixtures: showcase_invoice — two selects (region,
status) plus paid_on carrying visibleWhen: record.status == 'paid', the
exact shape in the issue, and a master-detail line-item subform — and
showcase_cascade for the dependsOn / per-option visibleWhen paths.

  • Edit modal on a paid invoice: both selects correct, paid_on rendered, and a
    fiber probe of RHF reports lostKeys: [] with _formValues matching
    _defaultValues.
  • Live predicate flip Paid → Sent → Paid: paid_on hides and comes back with
    its value
    , readonlyWhen on tax_rate follows, nothing else is touched.
  • Master-detail save (the batchTransaction path): renaming the invoice and
    switching region APAC → EMEA both persist, with status / paid_on intact;
    the server's readonlyWhen notice for tax_rate fires as designed.
  • Cascading select: the dependsOn gate ("Select country first") still holds,
    changing the parent still clears the child, the option list re-filters, and
    cn/zjus/ca persists server-side.
  • Create form: gate → country → filtered provinces → created and persisted.
  • Escape on an untouched edit form closes with no "discard changes?" prompt.

A this.client.data.batchTransaction is not a function failure seen mid-run was
traced to a stale local node_modules (installed @objectstack/client had
drifted to 14.6.0 while pnpm-lock.yaml pins 17.0.0-rc.0). pnpm install
resolved it; no lockfile or source change was needed, and the master-detail save
above was then verified on the correct client.

🤖 Generated with Claude Code

@vercel

vercelBot commented Jul 29, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredJul 29, 2026 4:13pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)27.9 KB350 KB
Entry fileindex-Ct5NPuyF.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.20KB2.97KB
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.12KB3.41KB
auth (LoginForm.js)17.86KB5.29KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.43KB2.09KB
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.25KB1.01KB
auth (org-roles.js)6.72KB2.85KB
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)18.38KB4.49KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)3.65KB1.42KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.25KB0.53KB
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)450.37KB97.97KB
core (index.js)2.16KB0.78KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)129.13KB32.55KB
fields (index.js)221.06KB54.17KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.46KB0.96KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)5.37KB1.72KB
i18n (useObjectLabel.js)25.17KB5.80KB
i18n (useSafeTranslation.js)3.26KB1.44KB
layout (index.js)38.45KB10.67KB
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)4.42KB1.27KB
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)1.77KB0.77KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)6.84KB2.42KB
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)44.90KB12.35KB
plugin-charts (index.js)57.26KB16.24KB
plugin-chatbot (index.js)179.93KB42.67KB
plugin-dashboard (index.js)109.60KB28.33KB
plugin-designer (index.js)210.56KB42.56KB
plugin-detail (index.js)216.52KB53.02KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)103.32KB25.08KB
plugin-gantt (index.js)162.26KB39.53KB
plugin-grid (index.js)179.45KB47.03KB
plugin-kanban (index.js)47.82KB13.18KB
plugin-list (index.js)98.19KB23.17KB
plugin-map (index.js)16.80KB5.24KB
plugin-markdown (index.js)13.65KB4.67KB
plugin-report (index.js)37.77KB10.00KB
plugin-timeline (index.js)25.03KB7.11KB
plugin-tree (index.js)8.36KB2.81KB
plugin-view (index.js)85.47KB20.82KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.55KB0.67KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)3.19KB1.38KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)18.70KB6.09KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.00KB0.55KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)2.16KB0.94KB
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)0.77KB0.41KB
types (disclosure.js)0.20KB0.18KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (index.js)1.86KB0.91KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)0.20KB0.18KB
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.04KB1.93KB
types (system-fields.js)2.39KB1.17KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)0.75KB0.46KB
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

baozhoutaoand others added 2 commits July 29, 2026 11:34
…ts options (#2968)
Radix keeps a hidden native `<select>` mirror so a Select's value takes part in
native form submission. Assigning a value that mirror has no `<option>` for is
a no-op — the element stays on `''` — but Radix still dispatches the synthetic
`change`, so `''` comes straight back out through `onValueChange` and lands in
react-hook-form on top of the value the caller just set.
That window is not theoretical: `SelectContent` registers its native options a
commit AFTER the trigger mounts, so a record that lands after first paint (an
edit modal whose findOne is still in flight) resets the form into exactly that
gap. Every rendered select came back empty while `_defaultValues` still held
the right value. When one of the wiped fields is the one a `visibleWhen`
predicate reads, the predicate flips back to false, the conditional fields hide
again and the form latches broken — Update then fails validation, or submits an
empty enum, on a form the user never touched.
`SelectItem` rejects `value=""`, so `''` is never a value a user picked — it is
always the mirror talking. Drop it at the single `Select` chokepoint, which
covers every surface (object form, inline grid editor, action param dialog).
Clearing a select still goes through `undefined`, which is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@baozhoutao@hotlong