…aved as null (objectui#10291)
A single select or radio emptied by a cascade clear staged `undefined`,
which `JSON.stringify` omits from the PATCH/POST body, so the value the
user saw cleared was never written and came back on refresh -- on the
record page's inline edit and in forms. The multi-value clear beside it
wrote a real array and was saved.
- SelectField / RadioField emit `null` from their cascade clear.
- The form renderer's own cascade clear sets `null`: it is the only clear
for a field no option widget renders (a `visibleWhen`-hidden field, the
built-in select branch).
- SelectField renders a `null` value with its placeholder, as it does `''`.
Pins observe the wire body: the inline-edit save (plugin-detail) and the
form edit/create saves through the real data-objectstack adapter
(plugin-form). Existing prune pins move from `undefined` to `null`.
Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
Fixes #10291
Clause-②: no
What was wrong
A cascade clear empties an option field when its parent changes and the current value is no longer offered. For a single select or a radio, the clear emptied the field to
undefined. The JSON transport leaves out an own key whose value isundefined:@objectstack/client'sdata.updatesendsbody: JSON.stringify(data). So the field the user saw emptied was never written, and the stored value came back on refresh. The multi-value clear wrote a real[]and was saved.Measured first, on
origin/main94436f42a, with the new pins (before any edit)Both pins read the wire body, not the
updateargument:@object-ui/data-objectstackadapter and@objectstack/client, with onlyfetchstubbed.mainDetailSection+InlineEditSaveBar), parent moved{ region: 'apac', tags: [] }.tierandbandare missing.ObjectFormedit, parent movedregion,tags: [],tier_any.tierandbandare missing.ObjectFormcreate, parent moved{ region: 'apac', tags: [] }.tierandbandare missing.⇒ The form path loses the value too: premise and reach both confirmed.
The fix, and why this route
The fix is on the widget side, as the seat suggested. The measurement then required one more edit in the same file family.
SelectField/RadioFieldcascade clear:onChange(undefined)becomesonChange(null). One change covers inline edit,ObjectFormand every other host.packages/components/src/renderers/form/form.tsx):setValue(name, undefined)becomessetValue(name, null). This clear runs independently of the widgets. With only step 1 applied, one case was still measured red: anObjectFormedit whose dependent select is hidden byvisibleWhen, so no widget is mounted. The form host clears that field alone, and the PATCH body still lackedtier. The built-inselectbranch is the same case, because it has no widget clear. The clear-on-hide effect in the same file already writesnull, for the reason it documents (objectui#6958 / objectui#6848): an explicitnullclears the stored value and an absent key leaves it unchanged.SelectFieldrenders anullvalue as "nothing chosen" (next section).I did not use the save-path mapping (map an own
undefinedtonullon the inline and form save paths). The widgets are where the wrong value is made. Fixing them is one change for every host, where the mapping needs two changes and misses hosts it does not know.What
nulldoes downstream (the four checks)isMissingForRequired(@object-ui/core) countsundefinedandnullas absent. Nothing changes. (Code read.)isSameStoredValue) countsnullandundefinedas one blank, so a pruned field (goldbecomesnull) is dirty. The form host clear still passesshouldDirty: true.null. Measured in the form: a rawnullgave the select trigger empty text and nodata-placeholder. It never showed the text "null", but the placeholder disappeared. The widget now absorbs this:nullis passed to Radix as'', the controlled spelling of "nothing chosen".undefinedis left alone so that no host changes between controlled and uncontrolled.RadioFieldalready renderedvalue ?? ''. Pinned inSelectField.clearedNull-10291.nullis accepted on write for a select. This relies on the write contract documented beside the clear-on-hide effect (objectui#6848:driver-memorymerges,driver-sqlissuesSETfor present keys). It was ⛔ NOT MEASURED against a live server in this PR.Pins (all read the wire body)
InlineEditSaveBar.cascadePruneWire-10291. After the parent moves, the body is exactly{ region: 'apac', tier: null, band: null, tags: [] }.plugin-formcascadePruneWire-10291:tier: nullandband: null;tier: null;tier: nullandband: null.tags: []on both paths.'gold', because an edit form sends its whole value set today. "Only dirty" is objectui#10156, which this PR does not address.undefinedtonull:@object-ui/fieldswidget suites and the objectui#6153 one;form-cascade-clear-prefixed-type,form-gated-options-keep-value);nullandundefinedprunes, so a regression still trips its circuit breaker instead of hanging the run.Ablation (tests resolve
@object-ui/fields/@object-ui/componentstosrcthrough the root vitest aliases, so no build sits between the mutation and the run)Each leg ran through
ablation-replace.mjs(wrap mode, restore trap) at11a8bbcd6:undefined(both files, anchor x1 → x0 each). Result:Tests 7 failed | 9 passed. Red: pin (a), form EDIT, form CREATE, and 4 widget cascade pins. The form host-only case stayed green, as expected for a clear the widgets do not own.undefined. Result:Tests 6 failed | 9 passed. Red: the host-only form pin and 5 components host pins. The inline pin and the mounted-form pins stayed green. The first launch of this leg was refused by the tool before any write ("ANCHOR AMBIGUOUS", x2, because the clear-on-hide line is identical). It was a no-op, and I re-ran it with a unique two-line anchor.git diff HEADis empty, per the tool's own verdict lines.Verification (at
11a8bbcd6)pnpm exec vitest run packages/fields/ packages/plugin-detail/ packages/plugin-form/ packages/components/src/renderers/form/from the repo root, plus these files:apps/consoleFormPage.*.Test Files 598 passed | 2 skipped (600),Tests 7052 passed | 26 skipped (7078).type-check, afterturbo run buildof the dependency closure:@object-ui/fields,components,plugin-detail,plugin-form,app-shellallDone. The touched test files are in each package'stsconfig.test.jsonprogram (--listFilesOnly).check:control-bytesOK.check:new-line-citationsreported0 new citation(s).check-changeset-presence✅.check:changeset-claimsexit 0.check:changeset-claimsflagged 3 pending changesets that nameform.tsx. I read each one, and each is about an unrelated part of that file: the FormPage chain,section-dividerclassName, anddescriptionwiring. None is falsified.check:vi-mock-*×3,check:test-path-roots,check:phantom-deps,check:doc-fences,check:doc-example-ids,check:pending-changeset-literals, and the governed-surface test (NOT GOVERNED).check:doc-snippetswas ⛔ NOT MEASURED: its prerequisite is a whole-tree build, which is left to CI..ts/.tsxfiles (--format json), compared with base through--stdin: 0 errors.no-explicit-anywarnings went from 121 to 133. All +12 are in the two new test files, and they are the sameas anyhost-prop casts as the neighbouring suites.react-refresh7 → 7,no-unused-vars1 → 1.Scope note: the claim's file surface was exceeded (flagged, not settled here)
The claim named the two widgets, the tests beside them,
InlineEditSaveBarif needed, and one changeset. This PR also touches these files:form.tsxand its two host suites (the measured form-path gap above);undefined;content/docs/fields/select.mdx.When the edits were made:
form.tsx;The seat decides whether to accept this surface or split it off.
Acceptance notes
FieldWidgetComponentProps.onChangeis typed(val: T) => void, so every clearing widget casts itsnull(null as unknown as string,onChange(null as any)). Not changed here, because that would change an exported signature.SelectFieldavaluethat turns intoundefinedstill switch Radix from controlled to uncontrolled. The cascade clear no longer causes that switch.Changeset
.changeset/10291-cleared-scalar-saves-null.md:minoron@object-ui/fieldsand@object-ui/components, because a cleared value is now saved.Implemented by
os-devin sessionhttps://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC.Generated by Claude Code