Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/10291-cleared-scalar-saves-null.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@object-ui/fields': minor
'@object-ui/components': minor
---

fix(fields,components): a single select or radio emptied by a cascade clear is now saved — as `null`

When a controlling field changes and a single select or radio no longer offers
its current value, the cascade clear empties it. It emptied it to `undefined`,
and the JSON transport omits an own `undefined` key (`@objectstack/client`'s
`data.update` sends `body: JSON.stringify(data)`). So the user saw the field
emptied, pressed Save, and the stored value survived and came back on refresh —
on the record page's inline edit (reachable since objectui#7190) and in forms
alike. The multi-value clear beside it wrote a real `[]` and was saved; the two
disagreed.

Both clears that can empty a scalar now write `null`, the write contract's
"clear the stored value" (an absent key means "leave it unchanged"):

- `SelectField` and `RadioField` (`@object-ui/fields`) emit `onChange(null)`.
This covers every host that renders them: inline edit, `ObjectForm` and the
other form variants.
- The form renderer's own cascade clear (`@object-ui/components`) sets `null`.
It runs independently of the widgets and is the only clear for a field no
option widget renders: a field `visibleWhen` keeps unmounted, or the built-in
`select` branch. The clear-on-hide effect in the same file already wrote
`null` for the same reason.

`SelectField` also renders a `null` value as "nothing chosen": it shows the
placeholder, as an empty string does. Before this change a raw `null` painted a
blank trigger.

A multi-value clear still writes its array. A select nobody touched is still not
staged: no invented `null` reaches a save, so a server default is not
suppressed. Required validation, the master-detail dirty diff and the create
form's default omission already treat `null` and `undefined` as the same blank,
so none of them changes.
4 changes: 3 additions & 1 deletion content/docs/fields/select.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ Declare the sibling field(s) a select reacts to with `dependsOn`. While any is
empty the control is **gated** (a "Select country first" hint) instead of
showing an unfiltered list; once the parent changes, the list re-evaluates and
any now-invalid selection is **cleared automatically** (no stale "China +
California" pair).
California" pair). The clear is saved like any other edit: a single select (or
radio) is emptied to `null`, which a save writes as "clear the stored value", and
a multiple select keeps only the values still offered.

### Cascading (country → province)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ describe('field-inherited option predicates reach the dialog control (objectui#3
// the widget's cascade-clear drops the seeded value.
const { onChange } = renderInheritedSelect(ROLE_GATED, ['sales'], 'admin_only');
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(onChange).toHaveBeenCalledWith(undefined);
expect(onChange).toHaveBeenCalledWith(null);
});

it('keeps that value for a viewer the predicate admits', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,11 @@
* ## The prune path must SETTLE
*
* Handing the record to an option widget arms its cascade clear: a stored value
* the current parent no longer offers is dropped with `onChange(undefined)` as
* the current parent no longer offers is dropped with `onChange(null)` as
* soon as inline edit is entered. That stages exactly one prune — IF the host
* then hands the widget the pruned value. A host that reads a field's own value
* by a different rule than the record it passes (the saved value whenever the
* draft entry is `undefined`) prunes again on every render, forever; the strip
* draft entry is empty) prunes again on every render, forever; the strip
* did exactly that at `cfd1f8c5`. The last describe pins one prune per field at
* both call sites, through a probe whose circuit breaker makes the regression a
* bounded failure rather than a hung runner.
Expand Down Expand Up @@ -111,7 +111,7 @@ vi.mock('./MetadataInspector', () => ({

/**
* The prune probe. An option widget drops a stored value its current parent no
* longer admits by calling `onChange(undefined)` from an effect keyed on its
* longer admits by calling `onChange(null)` from an effect keyed on its
* offered `options`. If the host then hands the widget the SAVED value again,
* the next render prunes again — forever, synchronously inside `act`, which
* hangs the runner instead of failing it (measured on the highlights strip at
Expand Down Expand Up @@ -139,7 +139,10 @@ vi.mock('@object-ui/fields', async (importOriginal) => {
const name = String(props.field?.name ?? '');
probe.lastValue[name] = props.value;
const onChange = (v: unknown) => {
if (v === undefined) {
// `null` since objectui#10291 (it was `undefined`, which the wire
// dropped). Both are counted, so a regression back to `undefined` still
// trips the breaker instead of hanging the runner.
if (v === null || v === undefined) {
probe.prunes[name] = (probe.prunes[name] ?? 0) + 1;
if (probe.prunes[name] > PRUNE_CIRCUIT_BREAKER) return;
}
Expand Down Expand Up @@ -581,7 +584,7 @@ describe('objectui#7190 — a stored option the current parent no longer admits'
// The host now hands each widget the pruned value: the single-select branch
// spells an empty value `''`, the `FieldEditWidget` branch passes it as is.
expect(probe.lastValue.tier).toBe('');
expect(probe.lastValue.band).toBeUndefined();
expect(probe.lastValue.band).toBeNull();
});

it('HIGHLIGHTS STRIP control: an ADMISSIBLE stored value is never pruned', async () => {
Expand All @@ -607,6 +610,10 @@ describe('objectui#7190 — a stored option the current parent no longer admits'
expect(probe.prunes.tier).toBe(1);
expect(probe.prunes.band).toBe(1);
expect(probe.lastValue.tier).toBe('');
// The body reads a field's value as `data[name] ?? field.value`, so the
// staged `null` reaches the radio as `undefined` — empty either way, which
// is what stops the second prune. The WRITE still carries `null`: that is
// pinned on the wire in plugin-detail (objectui#10291).
expect(probe.lastValue.band).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ describe('form host cascade clear — prefixed widget ids are option fields too
});

const payload = await submitAndRead(onSubmit);
expect(payload.province).toBeUndefined();
expect(payload.homeProvince).toBeUndefined();
expect(payload.province).toBeNull();
expect(payload.homeProvince).toBeNull();
expect(payload.provinces).toEqual([]);
expect(payload.visited).toEqual([]);
});
Expand All @@ -144,8 +144,8 @@ describe('form host cascade clear — prefixed widget ids are option fields too
);

const payload = await submitAndRead(onSubmit);
expect(payload.province).toBeUndefined();
expect(payload.homeProvince).toBeUndefined();
expect(payload.province).toBeNull();
expect(payload.homeProvince).toBeNull();
expect(payload.provinces).toEqual([]);
expect(payload.visited).toEqual([]);
});
Expand Down Expand Up @@ -212,8 +212,8 @@ describe('control — the bare-name (hand-written SDUI) path is unchanged', () =
});

const payload = await submitAndRead(onSubmit);
expect(payload.province).toBeUndefined();
expect(payload.homeProvince).toBeUndefined();
expect(payload.province).toBeNull();
expect(payload.homeProvince).toBeNull();
expect(payload.provinces).toEqual([]);
expect(payload.visited).toEqual([]);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
*
* The form renderer runs its OWN cascade clear (#2284) alongside the widgets':
* for every option field it resolves the offered set and `form.setValue(name,
* undefined)` when the current value is no longer offered. `resolveCascadingOptions`
* null)` when the current value is no longer offered (a scalar; an array is
* emptied to `[]`). `resolveCascadingOptions`
* returns an EMPTY set whenever the list is gated — a declared `dependsOn`
* parent is still empty — so a record that simply ARRIVES with the parent empty
* (a later-cleared parent, an import, a partially-migrated row) had its
Expand Down Expand Up @@ -99,7 +100,7 @@ describe('control — a RESOLVED list still prunes what it does not offer (ADR-0
});

const payload = await submitAndRead(onSubmit);
expect(payload.province).toBeUndefined();
expect(payload.province).toBeNull();
expect(payload.provinces).toEqual([]);
});

Expand Down Expand Up @@ -136,7 +137,7 @@ describe('transition — the gate lifting is what converges the value (#4247)',
);

const payload = await submitAndRead(onSubmit);
expect(payload.province).toBeUndefined();
expect(payload.province).toBeNull();
expect(payload.provinces).toEqual([]);
});
});
9 changes: 8 additions & 1 deletion packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1948,8 +1948,15 @@ ComponentRegistry.register('form',
// guard — this effect is an independent second clear on the form host,
// and the widgets' fix does not reach it.
if (gated) continue;
// A cleared scalar is `null`, never `undefined` (objectui#10291) — the
// reasoning the clear-on-hide effect below spells out at length:
// `undefined` vanishes from `JSON.stringify`, and an absent key tells
// the write contract "leave the stored value unchanged". This clear
// runs on its own wherever no option widget prunes first — a field
// `visibleWhen` keeps unmounted, the built-in `select` branch — so
// `undefined` here saved the stale pair this effect exists to drop.
if (!isValueStillOffered(current, visible)) {
form.setValue(name, Array.isArray(current) ? [] : undefined, {
form.setValue(name, Array.isArray(current) ? [] : null, {
shouldValidate: false,
shouldDirty: true,
});
Expand Down
4 changes: 2 additions & 2 deletions packages/fields/src/widgets/RadioField.cascade.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ describe('RadioField — cascade clear (#2715)', () => {
/>,
);
// 'ca' is a US province — under country=cn it is not offered, so it is dropped.
expect(onChange).toHaveBeenCalledWith(undefined);
expect(onChange).toHaveBeenCalledWith(null);
});

it('keeps a value that is still offered', () => {
Expand Down Expand Up @@ -121,7 +121,7 @@ describe('RadioField — role / context gating (#2715)', () => {
/>
</PredicateScopeProvider>,
);
expect(onChange).toHaveBeenCalledWith(undefined);
expect(onChange).toHaveBeenCalledWith(null);
});

it('keeps an admin-only value for an admin', () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/fields/src/widgets/RadioField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ export function RadioField({
// reasoning, at length, in `MultiSelectField`.
if (gated) return;
if (value === undefined || value === null || (value as unknown) === '') return;
if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
// `null`, never `undefined`: the wire drops an own `undefined`, so the
// clear was never saved (objectui#10291) — see `SelectField`.
if (!isValueStillOffered(value, options)) onChange?.(null as unknown as string);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options, gated]);

Expand Down
4 changes: 2 additions & 2 deletions packages/fields/src/widgets/SelectField.cascade.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe('SelectField — cascade clear (#2284)', () => {
/>,
);
// 'ca' is a US province — under country=cn it is not offered, so it is dropped.
expect(onChange).toHaveBeenCalledWith(undefined);
expect(onChange).toHaveBeenCalledWith(null);
});

it('keeps a value that is still offered', () => {
Expand Down Expand Up @@ -118,7 +118,7 @@ describe('SelectField — role / context gating (#2284)', () => {
/>
</PredicateScopeProvider>,
);
expect(onChange).toHaveBeenCalledWith(undefined);
expect(onChange).toHaveBeenCalledWith(null);
});

it('keeps an admin-only value for an admin', () => {
Expand Down
90 changes: 90 additions & 0 deletions packages/fields/src/widgets/SelectField.clearedNull-10291.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#10291 — the single-value option widgets empty a pruned value to
* `null`, and render `null` as "nothing chosen".
*
* The cascade clear used to emit `undefined`, which the JSON transport drops, so
* the clear never reached the server. It now emits `null` — the write
* contract's "clear the stored value" — and the widget-level pins of that live
* beside each widget (`SelectField.cascade`, `RadioField.cascade`,
* `optionWidgets.*`). This file pins the other half the change owes: the
* widget's OWN display of the value it now emits. A form hands the widget
* whatever it stored, so after a prune the select receives `null`; Radix shows
* its placeholder only for `''` / `undefined`, and a raw `null` painted a blank
* trigger with no placeholder.
*/
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import { SelectField } from './SelectField';
import { RadioField } from './RadioField';

const OPTIONS = [
{ label: 'Gold', value: 'gold' },
{ label: 'Silver', value: 'silver' },
];

function trigger(container: HTMLElement): HTMLElement {
return container.querySelector('[data-testid="select-trigger-tier"]') as HTMLElement;
}

describe('objectui#10291 — a null value renders as "nothing chosen"', () => {
it('SelectField: null shows the placeholder exactly as an empty string does, and never the text "null"', () => {
const empty = render(
<SelectField
value={''}
onChange={vi.fn()}
field={{ name: 'tier', type: 'select', options: OPTIONS } as any}
/>,
);
const emptyTrigger = trigger(empty.container);
expect(emptyTrigger).toHaveAttribute('data-placeholder');
const emptyText = emptyTrigger.textContent;
empty.unmount();

const cleared = render(
<SelectField
value={null as unknown as string}
onChange={vi.fn()}
field={{ name: 'tier', type: 'select', options: OPTIONS } as any}
/>,
);
const clearedTrigger = trigger(cleared.container);
expect(clearedTrigger).toHaveAttribute('data-placeholder');
expect(clearedTrigger.textContent).toBe(emptyText);
expect(cleared.container.textContent).not.toContain('null');
});

it('RadioField: null checks no radio and never renders the text "null"', () => {
const { container } = render(
<RadioField
value={null as unknown as string}
onChange={vi.fn()}
field={{ name: 'band', type: 'radio', options: OPTIONS } as any}
/>,
);
expect(container.querySelectorAll('[role="radio"][data-state="checked"]')).toHaveLength(0);
expect(container.querySelectorAll('[role="radio"]')).toHaveLength(2);
expect(container.textContent).not.toContain('null');
});

it('SelectField: a null value is empty, so the cascade clear never fires on it', () => {
const onChange = vi.fn();
render(
<SelectField
value={null as unknown as string}
onChange={onChange}
field={{ name: 'tier', type: 'select', options: OPTIONS } as any}
/>,
);
expect(onChange).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe('SelectField — `dependsOn` off the DECLARED metadata type (objectui#6
<SelectField value="ca" onChange={dropped} field={provinceField} {...host('province', { country: 'cn' })} />,
);
// 'ca' is offered only under country=us — under cn it is not, so it is cleared.
expect(dropped).toHaveBeenCalledWith(undefined);
expect(dropped).toHaveBeenCalledWith(null);

const kept = vi.fn();
render(
Expand Down
18 changes: 16 additions & 2 deletions packages/fields/src/widgets/SelectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,15 @@ function SingleSelectField({
// reasoning, at length, in `MultiSelectField`.
if (gated) return;
if (value === undefined || value === null || (value as unknown) === '') return;
if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
// A cleared scalar is `null`, never `undefined` (objectui#10291). The
// write contract reads an explicit `null` as "clear the stored value" and
// an absent key as "leave it unchanged" — and `undefined` becomes the
// absent key on the wire, because `JSON.stringify` omits it. Emitting
// `undefined` here showed the user an emptied field that no save ever
// wrote, on every host (inline edit and forms alike), while the
// multi-value prune beside it wrote a real array. `null` is the sentinel
// the other clearing widgets in this package already emit.
if (!isValueStillOffered(value, options)) onChange?.(null as unknown as string);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options, gated]);

Expand Down Expand Up @@ -178,7 +186,13 @@ function SingleSelectField({
return (
<Select
name={domName}
value={value}
// `null` is how an empty value arrives — the cascade clear above emits it
// (objectui#10291), and so does a record whose column is empty. Radix
// shows the placeholder only for `''` / `undefined`, so a raw `null` paints
// a blank trigger; `''` is the controlled spelling of "nothing chosen".
// `undefined` passes through untouched: mapping it too would move every
// host that never sets a value from uncontrolled to controlled mode.
value={(value as string | null) === null ? '' : value}
onValueChange={onChange}
disabled={readonly || props.disabled}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ describe('controls — a RESOLVED list still prunes what it does not offer (ADR-
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
/>,
);
expect(onChange).toHaveBeenCalledWith(undefined);
expect(onChange).toHaveBeenCalledWith(null);
});

it('SelectField clears a value the chosen parent excludes', () => {
Expand All @@ -169,7 +169,7 @@ describe('controls — a RESOLVED list still prunes what it does not offer (ADR-
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
/>,
);
expect(onChange).toHaveBeenCalledWith(undefined);
expect(onChange).toHaveBeenCalledWith(null);
});
});

Expand Down Expand Up @@ -241,7 +241,7 @@ describe('transition — a gated field converges the moment its parent is chosen
{...({ name: 'province', dependentValues: { country: 'us' } } as any)}
/>,
);
expect(onChange).toHaveBeenCalledWith(undefined);
expect(onChange).toHaveBeenCalledWith(null);
});

it('SelectField: a resolved set that INCLUDES the stored value keeps it', () => {
Expand Down
Loading
Loading