Repository navigation
[console 17.0.0-rc.0 @4a4829d0] Edit modal: a visibleWhen that flips false→true when the record lands wipes every rendered select's value, and the form latches broken #2968
Description
Summary
In the record edit modal, if a field's visibleWhen flips false → true at the moment the record's defaultValues land, every currently-rendered select loses its value: RHF's _defaultValues still holds the correct value, but _formValues is ''. Because one of the wiped selects is usually the very field the predicate reads, the predicate flips back to false, the conditional fields hide again, and the form latches in the broken state — it never self-heals.
Net effect for the user: opening a record and pressing Update without touching anything fails validation ("field is required"), or silently submits an empty enum to the server.
Same family as #2959 (form state destroyed by a remount), but a different trigger — no tabs involved, and the record here is not multi-section-dependent.
Repro
Object with (a) a select field, and (b) any field whose visibleWhen references it:
category: {
type: 'select',
options: [ {label:'00 Production', value:'production'}, {label:'01 Quality', value:'quality'}, /* … */ ],
defaultValue: 'production',
},
status: { type: 'select', options: [ /* … */ ], defaultValue: 'pending' },
need_quality: {
type: 'boolean',
visibleWhen: P`record.category == "production" || record.need_quality == true`,
},- Open a record whose stored
categoryis"production"(so the predicate is TRUE once data lands). - Detail page → Edit.
- Look at
categoryandstatus.
Expected: category = "00 Production", status = the record's status, and need_quality visible.
Actual: both selects render the placeholder (empty), and need_quality stays hidden.
A record whose category is anything else (predicate stays FALSE throughout) renders correctly.
Observed runtime state
Read off the live React tree (walk the fiber from the select up to the node carrying control + getValues), 3/3 identical runs:
_defaultValues.category = "production" ← the form DID receive the right defaults
_formValues.category = "" ← runtime value was wiped
lostKeys = "category,status"
lostKeys = keys that are non-empty in _defaultValues but empty/absent in _formValues. It is exactly the rendered selects. In the same form, text / number / datetime / lookup values all survive. A third select in the schema (inspection_status) is not in the list — it happened to be hidden at that moment.
Server data is not implicated: the modal's own findOne returns 48 keys with category:"production", status:"pending".
This is a race — and here is the proof
Same page, no reload, open the edit modal three times in a row:
open #1: category="" status="" selects=2
open #2: category="production" status="pending" inspection_status="pending" selects=3
open #3: category="production" status="pending" inspection_status="pending" selects=3
From open #2 the modal already has the record cached, so defaultValues are present on the first render — no flip, no wipe, and the conditional field renders as it should (select count 2 → 3).
So the trigger is: modal mounts → findOne still in flight → defaultValues = {} → ruleRecord seeds every field to null → predicate FALSE → conditional fields render hidden → data lands → form.reset(defaultValues) → predicate TRUE → the rendered field set changes → the already-mounted selects get clobbered to '' → predicate FALSE again → latched.
Falsifiable experiment (it follows the predicate, not the value)
Flip the predicate from record.category == "production" to record.category == "quality" and change nothing else:
production record: category="production" status="pending" ← now correct
quality record: category="" status="" ← now broken
3/3 stable. Removing the visibleWhen clauses altogether also fixes it, and the conditional fields then render normally.
Scope observed
| Situation | Result |
|---|---|
| predicate flips hidden → visible when the record lands | wiped |
| predicate flips visible → hidden | fine |
| predicate constant (either way) | fine |
| field types affected | select only — text / number / datetime / lookup keep their values |
| section grouping | irrelevant — reproduced with the conditional field in a group that also holds unconditional fields, so no section divider appears/disappears |
Across four objects in the same app only the heaviest one reproduces deterministically (48 fields, ~12 populate joins on the detail fetch) — consistent with the race framing: the slower the record load relative to first paint, the more reliably it latches. On lighter objects the same predicate shape is fine, and even the heavy one occasionally converges correctly.
Ruled out
The per-option cascade clear is not the path here. These fields have no per-option visibleWhen and no dependsOn, so:
resolveCascadingOptions(packages/core/src/evaluator/optionRules.ts) returns the full set,gated:false;isValueStillOfferedis therefore true;SelectField's clear effect (packages/fields/src/widgets/SelectField.tsx, the[options, gated]effect) and the form renderer's equivalent both bail out at thehasOptionPredicate || dependsOnguard.
Both of those also write undefined, whereas the value we observe is the string '' — which points at a different writer.
Hypothesis (not verified — flagging, not asserting)
'' is what a native <select> reports when no option matches. The shadcn/Radix Select renders a hidden native <select name=…> mirror for form integration. If the field-set change causes RHF to (re-)attach the field ref to that mirror element while Radix's own value is momentarily unset, getValues() would read '' back out of the DOM and write it into _formValues. That would explain both why only select is affected and why the written value is '' rather than the undefined every explicit clear path uses. Worth checking before designing the fix.
Suggested direction
Any one of these closes it:
- don't render the form until
defaultValuesfor an edit-mode record have resolved (the modal already knows it is loading); - evaluate the field rules against
defaultValuesmerged over the watched values, rather than an all-nullseed, so the predicate does not start FALSE for a record that will satisfy it; - after a
reset, reconcile_formValuesagainst_defaultValuesfor fields that were remounted by a visibility change.
Downstream damage worth noting
- The wipe is recorded as a user edit — the modal's dirty guard fires, so Cancel prompts "discard changes?" on a form the user never touched.
- If the user just presses Update, the server rejects with a message about the other field (in our app:
派工单状态流转非法:pending → 。i.e. "illegal status transition: pending → ."), which points at the state machine and completely masks the real cause. This cost us a full debugging session before we found the form was submitting empty enums.
Environment
@objectstack/console17.0.0-rc.0,dist/.objectui-sha= 4a4829d@objectstack/*17.0.0-rc.0, single-tenant, SqlDriver (better-sqlite3)- Chromium (Playwright), admin session, desktop viewport
Activity
fix(fields): a select no longer wipes itself when its value outruns i…
fix(components): apply new form defaultValues in the commit that rend…
docs(components): document the remaining nine Shadcn divergences — ev…