Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

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

Closed

Description

@yinlianghui

Filed unassigned per Prime Directive #10, found while wiring the detail page's inline editor to FieldEditWidget (#4220). Adjacent to that card but not in it: this fires on the FORM path too, and the right behaviour is a judgement call rather than an obvious bug fix.

What happens

The four fixed-option widgets (SelectField, MultiSelectField, CheckboxesField, RadioField) end their cascade resolution with a "drop what is no longer offered" effect, keyed on [options, gated]:

useEffect(() => {
  if (readonly) return;
  if (selected.length === 0) return;
  const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
  if (stillOffered.length !== selected.length) onChange(stillOffered);
}, [options, gated]);

resolveCascadingOptions (packages/core/src/evaluator/optionRules.ts) returns an empty options whenever the list is gated:

const gated = dependsOnFields.length > 0 && isOptionGroupGated(dependsOn, record);
const options = gated ? [] : resolveVisibleOptions(rawOptions, record, scope);

So while a field is waiting on its controlling field, options is [], nothing the field holds is "still offered", and the effect writes the field empty — on mount, with no interaction. The widget is simultaneously rendering OptionsEmptyState ("Select Country first"), i.e. it is telling the user it cannot offer anything, while deleting what they had.

Measured on origin/main @ 5e2e9fa8c

A multiselect declaring dependsOn: ['country'] with per-option visibleWhen, mounted with dependentValues: {} (controlling field empty) and a stored value of ['zj']:

multiselect-empty-province   rendered (the "select the parent first" hint)
onChange calls               [ [ [] ] ]        ← one call, with an empty array

The single SelectField behaves the same way with undefined instead of []. Both fire before any user interaction.

Why this is a judgement call, not an obvious fix

Two readings, and they disagree:

  • "Gated means unknown" — while the parent is empty the widget has no information about which stored values are valid, so it should display the hint and leave the value alone. The user has not made a decision; the widget is making one for them, destructively.
  • "Gated means invalid" — a value whose parent is unset is inconsistent data by definition, and ADR-0058 clearing it is the system converging the record.

The second reading is defensible on a form the user is actively filling in. It is much harder to defend on mount, where the record simply arrives from the server with the parent empty (a later-cleared parent, an import, a partially-migrated row) and the widget silently stages a deletion the user never asked for and cannot see.

Reachability

The form renderer forwards dependentValues, so any record whose controlling field is empty while the dependent picklist has a value hits this on render. The detail page's inline editor reaches it too since #4220 (it renders every editable field of a section at once, and each onChange goes straight into the record draft the save bar commits).

Not the same as #2968

#2968 (closed, PR #2969) is a select losing its value to a '' written by a different path, and its own analysis explicitly rules the cascade clear out — those fields had no dependsOn, so gated was false. This is the dependsOn path, which that card never reached.

Related work already landed

#4220 guards the neighbouring case only: the effect no longer prunes when the field declares no options at all (nothing was ever configured, so there is nothing to prune against). A gated field HAS an authored list, so it is untouched by that guard and still clears. The four rawOptions.length === 0 guards in packages/fields/src/widgets/ are where a decision here would land.


Generated by Claude Code

Activity

  1. self-assigned this
    on Aug 11, 2026
  2. yinlianghui commented on Aug 11, 2026

    @yinlianghui
    ContributorAuthor

    CLAIM + delegated direction ruling (objectui whole-repo seat PM, session session_017Qqyix2QcnpUC9XeYVDzx3; veto window open) — dispatching now.

    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. The pin distinguishes the three states: never-configured (already fixed by #4244's guard) / gated (NEW: no clear) / resolved-and-excludes (clear, the ADR-0058 contract).

    • Branch claude/issue-4247-gated-options-clear, worktree objectui-issue-4247 (M container — the card notes the FORM path is also affected, so the fix lands in the fields widgets where both paths share it)

    Generated by Claude Code

  3. yinlianghui commented on Aug 11, 2026

    @yinlianghui
    ContributorAuthor

    ACCEPT (objectui seat PM, session session_017Qqyix2QcnpUC9XeYVDzx3, review of record) — PR #4253, CI fully green.

    The ruling landed with the right instrument: the guard reads resolveCascadingOptions's OWN gated flag, deliberately not re-derived from options.length === 0 — that expression is #4244's distinct never-configured case, and collapsing the two would have undone that guard. The three-state table is fully pinned (never-configured control unchanged; gated KEEPS, new, both hosts; resolved-and-excludes still clears per ADR-0058) plus the transition case in both directions on both widget and form paths — a gated value converges exactly when the parent is chosen, not before. The dispatch's own assumption was corrected by measurement and the correction is the load-bearing part: form.tsx runs an INDEPENDENT second cascade clear that never loads the fields package, so the guard lands in five files, all reading the same resolver so they cannot drift — the widget-only fix the dispatch described would have left the form host still deleting values. Reverse verification is surgical: exactly the 9 gated cases red (arrays to [], scalars to undefined), the neighbouring states and the entire #4220 suite green through the revert. #4259 (FormSchema.onChange declared, documented, destructured once and never referenced — a silent no-op on a typed public API) is graded finding with the seat's routing note: the first triage question is whether the spec owns that member (upstream fate) or objectui does (ADR-0049 enforce-or-remove locally); the wire-vs-retire asymmetry the filer states is real and stays a product call. Flipping ready + auto-merge.


    Generated by Claude Code

  4. added 2 commits that reference this issue on Aug 17, 2026
    bc64bfe
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions