Skip to content

fix(fields): a gated option list keeps the field's stored value on mount - #4253

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4247-gated-options-clear
Aug 11, 2026
Merged

fix(fields): a gated option list keeps the field's stored value on mount#4253
yinlianghui merged 1 commit into
mainfrom
claude/issue-4247-gated-options-clear

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#4247

A dependency-gated option list deleted the field's stored value on mount, before any interaction, while the control was rendering "Select Country first" beside it — telling the user it could not offer anything, and deleting what they had.

Ruling implemented (from the card)

Ruling: gated means UNKNOWN — leave the stored value alone. The cascade-clear exists (ADR-0058) so a USER-DRIVEN parent change prunes now-invalid children; a gated set on MOUNT (parent not yet chosen, options unresolvable) is missing information, not invalidity, and destroying stored data on missing information is the silent-data-loss family this month's cards keep closing. Convergence stays available where it belongs: when the parent IS chosen and the resolved set genuinely excludes the stored value, the existing prune applies.

Gating signal

The guard reads resolveCascadingOptions's own gated flag — already returned on CascadingOptions and already destructured by all four widgets. It is deliberately not re-derived from options.length === 0: that expression is the never-configured case guarded by #4244/#4220, a different state with a different guard, and collapsing the two would undo that fix's distinction.

Two clear sites, not one

The card notes the FORM path is affected too. Measured: it is affected by an independent second clear, not by the widgets' effect. packages/components/src/renderers/form/form.tsx runs its own cascade clear over every option field (form.setValue(name, undefined)), and the components test suite never loads @object-ui/fields — yet the gated value was still wiped there. So the guard lands in both places; the widget fix alone does not reach the form host. Both read the same resolver, so the two guards cannot drift.

The three states, pinned apart

StateOffered setBehaviourPinned
never-configured — no authored options at allemptykeep the value (#4220 / #4244)optionWidgets.unconfiguredOptions.test.tsx — control, unchanged, still green
gated — authored list withheld behind an unmet dependsOnemptykeep the value (NEW)optionWidgets.gatedOptions.test.tsx x4 widgets + form-gated-options-keep-value.test.tsx
resolved-and-excludes — parent chosen, resolved set does not offer the valuenon-emptyclear / prune (ADR-0058)optionWidgets.gatedOptions.test.tsx x4 widgets + form control

The transition between rows 2 and 3 is pinned as its own case: a gated field holding a value converges the moment the parent is chosen — the resolved set excluding it clears it then; a resolved set including it keeps it. Pinned on the widget path (MultiSelectField, SelectField) and on the form path (typing a country whose list excludes the stored value prunes it on that transition).

Evidence

Premise reproduced red-first on the branch tip, both hosts.

Widget path, before the fix — 8 red / 4 green:

FAIL optionWidgets.gatedOptions.test.tsx > a GATED list never deletes the stored value (#4247)
> MultiSelectField keeps its array and shows the parent-first hint
> CheckboxesField keeps its array and shows the parent-first hint
> RadioField keeps its value and shows the parent-first hint
> SelectField keeps its value and shows the parent-first hint
FAIL ... > transition ... (4 cases, all at the gated-first assertion)
AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
1st vi.fn() call: Array [ undefined, ]
Test Files 1 failed (1)
Tests 8 failed | 4 passed (12)

The 4 passes are the resolved-and-excludes controls — the ADR-0058 contract, green before and after.

Form path, before the fix — 1 red / 3 green (nothing but form.tsx's own effect can move the value in this suite):

FAIL form-gated-options-keep-value.test.tsx > a GATED option list keeps the stored value (#4247)
> submits the record it was given when the controlling field arrives empty
AssertionError: expected undefined to be 'zj'
Test Files 1 failed (1)
Tests 1 failed | 3 passed (4)

After the fix, all three files green — 24 passed (24), the #4220 control file included.

Reverse verification

Fix taken out with git checkout origin/main -- over the five source files (never git stash — shared stack). Predicted direction: RED, with the mount-clear signature returning per widget (onChange([]) for the array widgets, onChange(undefined) for the scalar ones), and the resolved-and-excludes controls staying green.

Confirmed exactly — 9 failed / 15 passed:

FAIL optionWidgets.gatedOptions.test.tsx > ... MultiSelectField keeps its array ...
Array [
Array [], the array widgets clear to []
FAIL optionWidgets.gatedOptions.test.tsx > ... RadioField keeps its value ...
Array [
undefined, the scalar widgets clear to undefined
FAIL form-gated-options-keep-value.test.tsx > ... the controlling field arrives empty
Test Files 2 failed | 1 passed (3)
Tests 9 failed | 15 passed (24)

The 15 green are the resolved-and-excludes controls (4 widget + 3 form) and the whole 8-case #4220 never-configured control file — i.e. taking the fix out moves the gated state only, leaving both neighbouring states exactly where they were. Restored, and re-confirmed green (24/24) before pushing.

Gates

  • pnpm exec vitest run packages/fields/ — 78 files, 1220 passed
  • pnpm exec vitest run packages/components/ — 116 files, 1047 passed
  • pnpm exec vitest run packages/core/ packages/plugin-form/ packages/react/ — 150 files, 2533 passed (the resolver's other consumers)
  • turbo run type-check --filter=@object-ui/fields --filter=@object-ui/components — 11 tasks green (build closure built first)
  • turbo run lint on both packages — 0 errors
  • node scripts/check-control-bytes.mjs — OK (3965 files); touched files additionally self-scanned with a ripgrep class covering the C0 range (NUL through unit-separator) plus DEL — clean
  • changeset gated-options-keep-stored-value-4247.md (@object-ui/fields + @object-ui/components, both patch); check-changeset-fixed / check-changeset-no-major green

No public API or type surface changed — the guard reads a flag the returned type already carried, so there is no consumer-facing signature to sweep.


Generated by Claude Code

The four fixed-option widgets and the form renderer each end their cascade
resolution with a "drop what is no longer offered" clear. Both read
`resolveCascadingOptions`, which returns an EMPTY offered set whenever the
list is gated (a declared `dependsOn` parent is still empty), so nothing the
field held could be "still offered" and both paths wrote the field empty on
MOUNT, with no interaction — while the control rendered its "select the parent
first" hint beside it.
Gated means UNKNOWN, not invalid. ADR-0058's cascade prunes on a USER-DRIVEN
parent change; a withheld list on mount is missing information (a later-cleared
parent, an import, a partially-migrated row), and that is not a reason to
destroy stored data. Both clears now skip while gated, reading the resolver's
own `gated` flag rather than re-deriving it from an empty offered set — which
would collide with the distinct never-configured case guarded by #4220.
Convergence is unchanged: once the parent IS chosen and the resolved set
genuinely excludes the stored value the prune applies, including at the moment
the gate lifts. The three states (never-configured / gated /
resolved-and-excludes) are pinned apart across all four widgets and the form
host.
Fixes#4247
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 8:59am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.3 KB350 KB
Entry fileindex-Bl803Hgl.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.62KB108.26KB
core (index.js)3.04KB1.15KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)144.34KB37.61KB
fields (index.js)228.45KB56.62KB
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.91KB42.67KB
plugin-detail (index.js)238.87KB59.70KB
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 (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.05KB1.52KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 11, 2026 09:14
@yinlianghui
yinlianghui added this pull request to the merge queueAug 11, 2026
Merged via the queue into main with commit bc64bfeAug 11, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4247-gated-options-clear branch August 11, 2026 09:15
Sign up for freeto 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.

A dependency-gated option list clears the field's stored value on mount, before the user touches anything

2 participants

@yinlianghui@claude