From 512832f75de2c6f42f7ef1d49aed5b0d5fb787a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:08:42 +0000 Subject: [PATCH 1/4] fix(components): a dependent lookup clears when its dependsOn parent changes or clears (objectui#11631) The form's cascade clear (#2284) tests a value against a static option set, so it never covered the reference family: a lookup scoped by `dependsOn` re-scoped its candidate query when the parent moved, but kept the selection already made, and the form saved an invoice whose contact belongs to a different account. A form.watch subscription now clears a dependent lookup (null, or [] for a multi-value lookup) when any parent that scopes it takes a different value. The parents are read from the same slot LookupField scopes by. A defaultValues reset (record landing, recordId swap, resetOnSubmit, cancel) re-bases the comparison instead of clearing, so a saved pair survives the record opening. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- ...form-dependent-lookup-clear-11631.test.tsx | 395 ++++++++++++++++++ .../components/src/renderers/form/form.tsx | 146 ++++++- 2 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 packages/components/src/renderers/form/__tests__/form-dependent-lookup-clear-11631.test.tsx diff --git a/packages/components/src/renderers/form/__tests__/form-dependent-lookup-clear-11631.test.tsx b/packages/components/src/renderers/form/__tests__/form-dependent-lookup-clear-11631.test.tsx new file mode 100644 index 0000000000..e0de2e264b --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/form-dependent-lookup-clear-11631.test.tsx @@ -0,0 +1,395 @@ +/** + * 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#11631 — a lookup scoped by `dependsOn` loses its selection when the + * parent changes or clears, and keeps it when the record merely opens. + * + * ## The measured defect (card body, driven live twice in the 17.7 run) + * + * `showcase_invoice.contact` declares `dependsOn: ['account']`. New invoice → + * Account Northwind → Contact "Prospect 18" → switch Account to Contoso → + * Create: the read-back held `account: ` beside `contact: `. The picker had re-scoped its candidate query, but the + * selection already made survived, and the server checks only that a + * reference exists. The form's cascade clear (#2284) never saw the field: it + * skips every type outside `CASCADE_OPTION_WIDGET_TYPES` and every field with + * no static `options`, and a lookup fails both tests. + * + * ## What is pinned + * + * - CLEAR rows — the parent switched or emptied by the user, on both shapes + * that reach this renderer (the object form's stashed field metadata, and a + * hand-written field carrying `dependsOn` itself), a multi-value lookup, and + * a two-level chain. The cleared value is an explicit `null` / `[]` that + * survives `JSON.stringify` (objectui#10291: an absent key means "leave it + * unchanged" on a PATCH). + * - KEEP rows — an existing record opened for edit, whether its values are + * present at mount or land after first paint; a `recordId` swap in a + * still-mounted form; a `resetOnSubmit` reset; a change of an unrelated + * field; and a `dependsOn` the picker does not scope by. + * + * These components tests never load `@object-ui/fields`, so `field:lookup` is + * served by a probe that renders a plain input and calls `onChange` with the + * id typed into it — the value channel the real `LookupField` uses when a + * record is picked (`onChange(option.value)`) or its chip removed + * (`onChange(null)`). The rule under test reads the FIELD CONFIG and the + * form's values; nothing else can move a value in this environment. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +// Module-scope import (not `beforeAll`) — objectui#3010/#3021. +import '../../../renderers'; + +function LookupProbe(props: any) { + const value = props.value; + return ( + props.onChange(e.target.value === '' ? null : e.target.value)} + /> + ); +} +ComponentRegistry.register('field:lookup', LookupProbe, { namespace: 'test' }); + +type Rec = Record; + +function formSchema(schema: Rec): Rec { + return { type: 'form', showSubmit: true, submitLabel: 'Save', ...schema }; +} + +function renderForm(schema: Rec) { + const Form = ComponentRegistry.get('form')!; + return render(
); +} + +/** What `fromObjectSchema` (`@object-ui/plugin-form`) builds for the fixture. */ +const OBJECT_FORM_FIELDS = [ + { + name: 'account', + label: 'Account', + type: 'field:lookup', + field: { name: 'account', type: 'lookup', reference: 'showcase_account' }, + }, + { + name: 'contact', + label: 'Contact', + type: 'field:lookup', + field: { + name: 'contact', + type: 'lookup', + reference: 'showcase_contact', + dependsOn: ['account'], + }, + }, + { name: 'status', label: 'Status', type: 'input' }, +]; + +const pick = (field: string, id: string) => + fireEvent.change(screen.getByTestId(`lookup-${field}`), { target: { value: id } }); +const save = () => fireEvent.click(screen.getByRole('button', { name: /save/i })); + +async function payloadOf(onSubmit: ReturnType, call = 1) { + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(call)); + return onSubmit.mock.calls[call - 1][0] as Rec; +} + +/** The key is on the wire, not merely `== null` in memory (objectui#10291). */ +function wireKeys(payload: Rec): string[] { + return Object.keys(JSON.parse(JSON.stringify(payload))); +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('objectui#11631 — a dependent lookup is cleared when its parent moves', () => { + it('CREATE: switching the parent drops the pick made under the old parent', async () => { + const onSubmit = vi.fn(); + renderForm({ fields: OBJECT_FORM_FIELDS, onSubmit }); + + pick('account', 'northwind'); + pick('contact', 'prospect-18'); + pick('account', 'contoso'); + + await waitFor(() => + expect((screen.getByTestId('lookup-contact') as HTMLInputElement).value).toBe(''), + ); + save(); + const payload = await payloadOf(onSubmit); + expect(payload.account).toBe('contoso'); + expect(payload.contact).toBeNull(); + expect(wireKeys(payload)).toContain('contact'); + }); + + it('EDIT: clearing the parent of a saved pair clears the lookup with an explicit null', async () => { + const onSubmit = vi.fn(); + const stored = { account: 'northwind', contact: 'prospect-18', status: 'draft' }; + renderForm({ + fields: OBJECT_FORM_FIELDS, + defaultValues: stored, + previousValues: stored, + onSubmit, + }); + + pick('account', ''); + save(); + const payload = await payloadOf(onSubmit); + expect(payload.account).toBeNull(); + expect(payload.contact).toBeNull(); + expect(wireKeys(payload)).toContain('contact'); + }); + + it('a hand-written field carrying `dependsOn` itself clears the same way', async () => { + const onSubmit = vi.fn(); + renderForm({ + fields: [ + { name: 'account', label: 'Account', type: 'lookup' }, + { name: 'contact', label: 'Contact', type: 'lookup', dependsOn: ['account'] }, + ], + defaultValues: { account: 'northwind', contact: 'prospect-18' }, + onSubmit, + }); + + pick('account', 'contoso'); + save(); + expect((await payloadOf(onSubmit)).contact).toBeNull(); + }); + + it('a multi-value lookup is cleared to an empty list', async () => { + const onSubmit = vi.fn(); + renderForm({ + fields: [ + OBJECT_FORM_FIELDS[0], + { + name: 'cc', + label: 'CC', + type: 'field:lookup', + multiple: true, + field: { + name: 'cc', + type: 'lookup', + reference: 'showcase_contact', + multiple: true, + dependsOn: ['account'], + }, + }, + ], + defaultValues: { account: 'northwind', cc: ['prospect-18', 'prospect-19'] }, + onSubmit, + }); + + pick('account', 'contoso'); + save(); + expect((await payloadOf(onSubmit)).cc).toEqual([]); + }); + + it('a two-level chain converges: clearing the middle lookup clears the one it scopes', async () => { + const onSubmit = vi.fn(); + renderForm({ + fields: [ + ...OBJECT_FORM_FIELDS, + { + name: 'site', + label: 'Site', + type: 'field:lookup', + field: { name: 'site', type: 'lookup', reference: 'showcase_site', dependsOn: ['contact'] }, + }, + ], + defaultValues: { account: 'northwind', contact: 'prospect-18', site: 'hq' }, + onSubmit, + }); + + pick('account', 'contoso'); + save(); + const payload = await payloadOf(onSubmit); + expect(payload.contact).toBeNull(); + expect(payload.site).toBeNull(); + }); + + it('two lookups naming each other never wipe the pick that started the pass', async () => { + const onSubmit = vi.fn(); + renderForm({ + fields: [ + { name: 'a', label: 'A', type: 'lookup', dependsOn: ['b'] }, + { name: 'b', label: 'B', type: 'lookup', dependsOn: ['a'] }, + ], + defaultValues: { a: 'a1', b: 'b1' }, + onSubmit, + }); + + pick('a', 'a2'); + save(); + const payload = await payloadOf(onSubmit); + expect(payload.a).toBe('a2'); + expect(payload.b).toBeNull(); + }); +}); + +describe('objectui#11631 — what does NOT clear a dependent lookup', () => { + it('EDIT: a saved pair present at mount survives a save that changed nothing', async () => { + const onSubmit = vi.fn(); + const stored = { account: 'northwind', contact: 'prospect-18', status: 'draft' }; + renderForm({ + fields: OBJECT_FORM_FIELDS, + defaultValues: stored, + previousValues: stored, + onSubmit, + }); + + save(); + const payload = await payloadOf(onSubmit); + expect(payload.account).toBe('northwind'); + expect(payload.contact).toBe('prospect-18'); + }); + + it('EDIT: a record that lands AFTER first paint keeps its pair, and the rule is live afterwards', async () => { + const onSubmit = vi.fn(); + const Form = ComponentRegistry.get('form')!; + const { rerender } = render(); + + const stored = { account: 'northwind', contact: 'prospect-18', status: 'draft' }; + rerender( + , + ); + await waitFor(() => + expect((screen.getByTestId('lookup-contact') as HTMLInputElement).value).toBe('prospect-18'), + ); + + save(); + expect((await payloadOf(onSubmit, 1)).contact).toBe('prospect-18'); + + // The same mounted form still clears on a real switch. + pick('account', 'contoso'); + save(); + expect((await payloadOf(onSubmit, 2)).contact).toBeNull(); + }); + + it('a `recordId` swap in a still-mounted form keeps the NEW record\'s pair', async () => { + const onSubmit = vi.fn(); + const Form = ComponentRegistry.get('form')!; + const recordA = { account: 'northwind', contact: 'prospect-18', status: 'draft' }; + const recordB = { account: 'contoso', contact: 'buyer-3', status: 'sent' }; + const { rerender } = render( + , + ); + rerender( + , + ); + await waitFor(() => + expect((screen.getByTestId('lookup-account') as HTMLInputElement).value).toBe('contoso'), + ); + + save(); + const payload = await payloadOf(onSubmit); + expect(payload.account).toBe('contoso'); + expect(payload.contact).toBe('buyer-3'); + }); + + it('a `resetOnSubmit` reset restores the defaults instead of reading as a parent switch', async () => { + const onSubmit = vi.fn(); + renderForm({ + fields: OBJECT_FORM_FIELDS, + defaultValues: { account: 'northwind', contact: 'prospect-18' }, + resetOnSubmit: true, + onSubmit, + }); + + pick('account', 'contoso'); + pick('contact', 'buyer-3'); + save(); + const first = await payloadOf(onSubmit, 1); + expect(first.account).toBe('contoso'); + expect(first.contact).toBe('buyer-3'); + + // The reset moved `account` back to `northwind`. It is the form being + // reset, not the user switching the account, so the restored contact stays. + await waitFor(() => + expect((screen.getByTestId('lookup-contact') as HTMLInputElement).value).toBe('prospect-18'), + ); + save(); + const second = await payloadOf(onSubmit, 2); + expect(second.account).toBe('northwind'); + expect(second.contact).toBe('prospect-18'); + }); + + it('a change of an unrelated field keeps the pair', async () => { + const onSubmit = vi.fn(); + const stored = { account: 'northwind', contact: 'prospect-18', status: 'draft' }; + renderForm({ + fields: OBJECT_FORM_FIELDS, + defaultValues: stored, + previousValues: stored, + onSubmit, + }); + + fireEvent.change(screen.getByLabelText(/status/i), { target: { value: 'sent' } }); + save(); + const payload = await payloadOf(onSubmit); + expect(payload.status).toBe('sent'); + expect(payload.contact).toBe('prospect-18'); + }); + + it('a lookup that holds nothing is not written to when its parent moves', async () => { + const onSubmit = vi.fn(); + renderForm({ fields: OBJECT_FORM_FIELDS, onSubmit }); + + pick('account', 'northwind'); + pick('account', 'contoso'); + save(); + const payload = await payloadOf(onSubmit); + expect(payload.account).toBe('contoso'); + expect(payload.contact ?? undefined).toBeUndefined(); + expect(wireKeys(payload)).not.toContain('contact'); + }); + + it('BOUNDARY — a bare-name `dependsOn` the picker does not scope by moves nothing', async () => { + // `LookupField` honours only the field-level ARRAY shape; a bare parent + // name leaves its query unscoped, so there is no scope for a parent change + // to move and nothing to clear. + const onSubmit = vi.fn(); + renderForm({ + fields: [ + { name: 'account', label: 'Account', type: 'lookup' }, + { name: 'contact', label: 'Contact', type: 'lookup', dependsOn: 'account' }, + ], + defaultValues: { account: 'northwind', contact: 'prospect-18' }, + onSubmit, + }); + + pick('account', 'contoso'); + save(); + expect((await payloadOf(onSubmit)).contact).toBe('prospect-18'); + }); +}); diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index 1ba4798477..a2a6de902b 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { ComponentRegistry, resolveFieldRuleState, evalFieldPredicate, resolveCascadingOptions, CASCADE_OPTION_WIDGET_TYPES, EXPANDABLE_FIELD_TYPES, isValueStillOffered, isMissingForRequired, isServerOwnedValue } from '@object-ui/core'; +import { ComponentRegistry, resolveFieldRuleState, evalFieldPredicate, resolveCascadingOptions, resolveDependsOnFields, CASCADE_OPTION_WIDGET_TYPES, EXPANDABLE_FIELD_TYPES, isValueStillOffered, isMissingForRequired, isServerOwnedValue } from '@object-ui/core'; import type { FormSchema, FormField as FormFieldConfig, FormFieldTab, FormFieldPane, FieldCondition, SelectOption } from '@object-ui/types'; import { useForm } from 'react-hook-form'; import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from '../../ui/form'; @@ -526,6 +526,44 @@ function needsDataSourceWiring(widgetType: string): boolean { // form, the action dialog and the bulk dialog feed one evaluator and must read // one allow-table (objectui#4770 — until then each held a private copy). +/** + * The sibling fields that SCOPE a dependent lookup's candidate query — the + * parents whose change must clear its selection (objectui#11631). Empty for + * every field that is not a dependent member of the reference family. + * + * Read from exactly the slot the picker scopes by, so this form's clear and the + * picker's query cannot disagree about which parent moves the scope: + * `LookupField` (and `UserField`, which delegates to it) takes `dependsOn` from + * its `field` prop — this renderer hands it `field.field || field` — after + * unwrapping one nested `field` that carries `reference` or `type`, and it + * honours only the ARRAY shape the spec declares at field level + * (`FieldDependsOn`). A bare parent name there scopes nothing, so it moves + * nothing here either. + * + * The family is `EXPANDABLE_FIELD_TYPES` — the set `needsDataSourceWiring` + * above already feeds `dependentValues` to. ⛔ Not `CASCADE_OPTION_WIDGET_TYPES`: + * that shared allow-list names the OPTION widgets fed the live record, and its + * other readers rely on the lookup family not being in it. + * + * A field never counts as its own parent: a self-reference would clear the + * user's pick the moment it was made. + */ +function dependentLookupParents(f: FormFieldConfig | undefined): string[] { + const name = f?.name; + if (!name) return []; + const widget = resolveWidgetType(f); + if (typeof widget !== 'string' || !EXPANDABLE_FIELD_TYPES.has(normalizeFieldType(widget))) { + return []; + } + const carrier = ((f as any).field || f) as Record; + const inner = carrier?.field; + const meta = + inner && typeof inner === 'object' && ('reference' in inner || 'type' in inner) ? inner : carrier; + const declared = meta?.dependsOn; + if (!Array.isArray(declared)) return []; + return resolveDependsOnFields(declared).filter((parent) => parent !== name); +} + function stripRendererOnlyProps>(props: T): T { const { dataSource: _dataSource, @@ -1985,6 +2023,10 @@ ComponentRegistry.register('form', // stale "china + california" pair. Mirrors the dependent-lookup gate but for // static/predicate-driven option sets. Fail-open filtering keeps unrelated // fields untouched (no visibleWhen / dependsOn → nothing recomputed). + // A dependent LOOKUP never reaches this effect — it has no static option + // set to test a value against — and is cleared by its own rule further + // down, beside the `defaultValues` reset (`dependentLookupsKey`, + // objectui#11631). React.useEffect(() => { for (const f of fields as FormFieldConfig[]) { const name = f?.name; @@ -2346,6 +2388,108 @@ ComponentRegistry.register('form', } }, [form, onChangeProp]); + // Dependent-lookup clear (objectui#11631) — the reference-family half of + // the cascade clear (#2284), which only ever covered option fields. + // + // ## The defect this closes + // + // A lookup whose `dependsOn` names a sibling (an invoice's `contact` + // scoped by its `account`) re-scopes its candidate QUERY when the parent + // moves, but nothing dropped the selection already made: switch Account + // from Northwind to Contoso and the Northwind contact stayed selected and + // was saved beside the Contoso account. The server checks only that a + // reference exists, so this form is the one place the contradictory pair + // can be stopped. The cascade clear above cannot take it: it tests a + // value against a STATIC option set, and a lookup has none to test — its + // offered set is a query result. + // + // ## The rule: a change of the parent is the clear + // + // When any parent that scopes a lookup (`dependentLookupParents`) takes a + // different value — another record, or empty — the lookup's selection is + // cleared: `null` for a single value, `[]` for a multi-value lookup, the + // sentinels the two clears above write (`null`, never `undefined`, + // objectui#10291). Whether the old selection happens to fall inside the + // new scope is not asked: answering it is a query against the server, and + // a pick the user made under a parent they have since replaced is not + // theirs to keep silently. A lookup that holds nothing is never written to, + // so a create form cannot turn an absent key into an explicit `null`. + // + // ## Only an EDIT moves a parent — the host's data landing does not + // + // The comparison is between successive values of the form, so the + // question is which value changes count. A `defaultValues` reset — an + // edit-mode record landing after first paint, a `recordId` swap in a + // still-mounted drawer, the carried input re-applied inside it — fills + // the parent AND the lookup in one operation; read as an edit, it would + // empty every saved pair the moment the record opened. So the comparison + // is re-based, never acted on, in two cases: inside the explicit reset + // window (`resetInFlightRef`, the same signal the two value channels above + // read), and on a notification that names no field — react-hook-form's + // whole-record replacement (`reset()`, including the bare ones the cancel + // and `resetOnSubmit` paths call). Everything else is a value moving in + // this form — a user's pick, or one of the clears above emptying a field — + // and is acted on. That is also what makes a multi-level chain converge: + // clearing `contact` is itself a named change, so a lookup scoped by + // `contact` is cleared in the same pass. + // + // The field whose change started a pass is never cleared by it, so two + // lookups that name each other cannot wipe the pick the user just made. + // + // A subscription, like the value channels above, and not a `ruleRecord` + // effect: the reset window is a synchronous span inside the reset itself, + // so only a subscriber notified INSIDE it can read it; an effect running + // after the commit sees two value sets and no record of which operation + // moved them. Established only when the form has a dependent lookup, and + // keyed on the primitive `dependentLookupsKey`, not on the memoised + // `fields` (AGENTS.md #10). + const dependentLookupsKey = React.useMemo(() => { + const pairs: Array<[string, string[]]> = []; + for (const f of fields as FormFieldConfig[]) { + const parents = dependentLookupParents(f); + if (parents.length > 0) pairs.push([f.name as string, parents]); + } + return pairs.length > 0 ? JSON.stringify(pairs) : ''; + }, [fields]); + React.useLayoutEffect(() => { + if (!dependentLookupsKey) return; + const lookups = JSON.parse(dependentLookupsKey) as Array<[string, string[]]>; + const parentNames = Array.from(new Set(lookups.flatMap(([, parents]) => parents))); + const readParents = (): Record => { + const values = form.getValues() as Record; + const out: Record = {}; + for (const parent of parentNames) out[parent] = values?.[parent]; + return out; + }; + let seen = readParents(); + // The field whose change started the current pass; clears nest inside + // it synchronously (`setValue` notifies this subscription re-entrantly). + let origin: string | undefined; + const subscription = form.watch((_values, info) => { + const before = seen; + const now = readParents(); + seen = now; + if (resetInFlightRef.current || !info?.name) return; + const outermost = origin === undefined; + if (outermost) origin = info.name; + try { + for (const [name, parents] of lookups) { + if (name === origin) continue; + if (parents.every((parent) => valuesEqualForDirty(before[parent], now[parent]))) continue; + const current = form.getValues(name); + if (isEmptyish(current)) continue; + form.setValue(name, Array.isArray(current) ? [] : null, { + shouldValidate: false, + shouldDirty: true, + }); + } + } finally { + if (outermost) origin = undefined; + } + }); + return () => subscription.unsubscribe(); + }, [form, dependentLookupsKey]); + /** * Scroll a field into view and focus a control inside it. The field wrapper * carries `data-field` (FormItem), so this reaches custom widgets that RHF's From d7417c8e73cb6f799a3d357701f95ef8c966426a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:09:59 +0000 Subject: [PATCH 2/4] test(components): pin that input carried across a defaults reset is not a parent switch (objectui#11631) Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- ...form-dependent-lookup-clear-11631.test.tsx | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/components/src/renderers/form/__tests__/form-dependent-lookup-clear-11631.test.tsx b/packages/components/src/renderers/form/__tests__/form-dependent-lookup-clear-11631.test.tsx index e0de2e264b..03d838dd9a 100644 --- a/packages/components/src/renderers/form/__tests__/form-dependent-lookup-clear-11631.test.tsx +++ b/packages/components/src/renderers/form/__tests__/form-dependent-lookup-clear-11631.test.tsx @@ -31,8 +31,10 @@ * unchanged" on a PATCH). * - KEEP rows — an existing record opened for edit, whether its values are * present at mount or land after first paint; a `recordId` swap in a - * still-mounted form; a `resetOnSubmit` reset; a change of an unrelated - * field; and a `dependsOn` the picker does not scope by. + * still-mounted form; input the reset carries across a defaults change; a + * `resetOnSubmit` reset; a change of an unrelated field; a lookup holding + * nothing (never written to); and a `dependsOn` the picker does not scope + * by. * * These components tests never load `@object-ui/fields`, so `field:lookup` is * served by a probe that renders a plain input and calls `onChange` with the @@ -317,6 +319,37 @@ describe('objectui#11631 — what does NOT clear a dependent lookup', () => { expect(payload.contact).toBe('buyer-3'); }); + it('input the reset CARRIES across a defaults change is part of the reset, not a parent switch', async () => { + // The wizard shape (#2982): the next step's defaults name the contact but + // not the account the user already picked, so the reset carries the + // account across. Re-applying it is the same operation as the reset. + const onSubmit = vi.fn(); + const Form = ComponentRegistry.get('form')!; + const { rerender } = render( + , + ); + pick('account', 'northwind'); + rerender( + , + ); + await waitFor(() => + expect((screen.getByTestId('lookup-contact') as HTMLInputElement).value).toBe('prospect-18'), + ); + + save(); + const payload = await payloadOf(onSubmit); + expect(payload.account).toBe('northwind'); + expect(payload.contact).toBe('prospect-18'); + }); + it('a `resetOnSubmit` reset restores the defaults instead of reading as a parent switch', async () => { const onSubmit = vi.fn(); renderForm({ From a3e25ffa5f0e4c8cda6fd16ed53f92b408cb06fe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:11:00 +0000 Subject: [PATCH 3/4] chore(changeset): @object-ui/components patch for the dependent-lookup clear (objectui#11631) Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .changeset/11631-lookup-cascade-clear.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/11631-lookup-cascade-clear.md diff --git a/.changeset/11631-lookup-cascade-clear.md b/.changeset/11631-lookup-cascade-clear.md new file mode 100644 index 0000000000..ddeab673df --- /dev/null +++ b/.changeset/11631-lookup-cascade-clear.md @@ -0,0 +1,13 @@ +--- +'@object-ui/components': patch +--- + +A lookup whose `dependsOn` names a parent field drops its selection when that parent changes or is cleared (objectui#11631). + +Before, the picker re-scoped its candidate list to the new parent but kept the record already chosen. An invoice whose Account was switched from Northwind to Contoso saved Contoso beside a Northwind contact. The server checks only that a reference exists, so it accepted the pair. Now the form clears the dependent lookup as soon as any parent that scopes it takes a different value. It writes `null`, or `[]` for a multi-value lookup, so an edit clears the stored value instead of leaving it unchanged. A cleared lookup that scopes another lookup clears that one too. + +What does not clear it: opening an existing record, whether its values are present when the form mounts or arrive after; switching a mounted drawer to another record; a `resetOnSubmit` or Cancel reset; a change to any other field. A lookup that holds nothing is not written to. Which parents count is read from the same field-level `dependsOn` array the picker scopes its query by, so a `dependsOn` the picker ignores clears nothing either. + +The rule covers every form the `form` renderer draws, including the object form and its drawer, modal, split, tabbed and wizard variants. The console's form-view page (`/forms/:name`, `/f/:slug`) has its own renderer and is not changed here. + +**Clause-②: no.** Nothing on the package entry changes. No export, prop, type member or i18n key is added, and `CASCADE_OPTION_WIDGET_TYPES` is unchanged. From 243dfa6a8970582a204fd7938d26458ab2643783 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:56:30 +0000 Subject: [PATCH 4/4] refactor(components): type the dependent-lookup parent read and its test probe without any (objectui#11631) No behaviour change: the metadata carrier is read as Record and the test probe declares its props. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .../form-dependent-lookup-clear-11631.test.tsx | 10 ++++++++-- packages/components/src/renderers/form/form.tsx | 12 +++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/components/src/renderers/form/__tests__/form-dependent-lookup-clear-11631.test.tsx b/packages/components/src/renderers/form/__tests__/form-dependent-lookup-clear-11631.test.tsx index 03d838dd9a..fe56f849ca 100644 --- a/packages/components/src/renderers/form/__tests__/form-dependent-lookup-clear-11631.test.tsx +++ b/packages/components/src/renderers/form/__tests__/form-dependent-lookup-clear-11631.test.tsx @@ -51,12 +51,18 @@ import { ComponentRegistry } from '@object-ui/core'; // Module-scope import (not `beforeAll`) — objectui#3010/#3021. import '../../../renderers'; -function LookupProbe(props: any) { +interface LookupProbeProps { + name: string; + value?: unknown; + onChange: (next: string | null) => void; +} + +function LookupProbe(props: LookupProbeProps) { const value = props.value; return ( props.onChange(e.target.value === '' ? null : e.target.value)} /> ); diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index a2a6de902b..9c47e4b8bb 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -550,16 +550,18 @@ function needsDataSourceWiring(widgetType: string): boolean { */ function dependentLookupParents(f: FormFieldConfig | undefined): string[] { const name = f?.name; - if (!name) return []; + if (!f || !name) return []; const widget = resolveWidgetType(f); if (typeof widget !== 'string' || !EXPANDABLE_FIELD_TYPES.has(normalizeFieldType(widget))) { return []; } - const carrier = ((f as any).field || f) as Record; - const inner = carrier?.field; + const carrier: Record = f.field || (f as Record); + const inner = carrier.field; const meta = - inner && typeof inner === 'object' && ('reference' in inner || 'type' in inner) ? inner : carrier; - const declared = meta?.dependsOn; + inner !== null && typeof inner === 'object' && ('reference' in inner || 'type' in inner) + ? (inner as Record) + : carrier; + const declared = meta.dependsOn; if (!Array.isArray(declared)) return []; return resolveDependsOnFields(declared).filter((parent) => parent !== name); }