diff --git a/.changeset/10778-dashboard-bus-readers.md b/.changeset/10778-dashboard-bus-readers.md index be7ab42602..6da9d731a0 100644 --- a/.changeset/10778-dashboard-bus-readers.md +++ b/.changeset/10778-dashboard-bus-readers.md @@ -16,3 +16,12 @@ over `{ provider: 'object' }` and what a drill-down drawer lists the underlying records with, so those re-read too. A drill that opens a report (`drillDown.report`) and the single-record drawer a table row opens do not re-read. Bound rows and authored `data` rows do not subscribe. + +⚠️ **Dated note, 2026-09-27 — the report a drill opens does re-read — objectui#10853.** +The sentence above that a drill opening a report (`drillDown.report`) does not +re-read stopped holding with objectui#10814 (PR objectui#10836), pending in the +same release: the drawer's `drillDown.report` arm renders `spec-report` through +`SchemaRenderer`, and the dataset report renderer's own reader re-reads it on the +bus. `drillDownReport.invalidationRefetch-10814.test.tsx` in `apps/console` pins +that through the real drawer. The single-record drawer a table row opens still +does not re-read. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/10853-master-detail-picker-bus-readers.md b/.changeset/10853-master-detail-picker-bus-readers.md new file mode 100644 index 0000000000..731965aa32 --- /dev/null +++ b/.changeset/10853-master-detail-picker-bus-readers.md @@ -0,0 +1,31 @@ +--- +'@object-ui/plugin-form': patch +'@object-ui/components': patch +--- + +fix(plugin-form,components): an edit-mode `object-master-detail-form`'s detail lines and `element:record_picker`'s options re-read on the data-invalidation bus + +An `object-master-detail-form` in edit mode now reads the bus for each detail +collection's child object: a write declared there (`notifyDataChanged`, as a +page action over raw HTTP does), or an unscoped `'*'`, re-reads that +collection's lines in place, and the rows and the baseline the next save diffs +against move together. A collection only re-reads for its own child object. The +header already re-read through its own form. While a collection holds lines the +user has changed since they were last read or saved (compared the way the save +compares rows, with the link to the parent set aside), or while the row editor +("Open row") is open on it, its re-read is held. It runs once, when the row +editor is closed and either the lines have been changed back or this form's +save has landed. A line typed while a re-read is in flight, in the grid or in +the row editor, is kept, and the re-read is held behind it; an open row editor +is never reset by a re-read. A re-read that fails keeps the lines on screen. +`object-form` with `subforms`, which renders the same form, re-reads the same +way. + +`element:record_picker` now re-reads its options when the bus reports a write to +the object it queries. The re-read keeps the control enabled over the options on +screen (no "Loading…") and never touches the bound page variable. If the bound +record is no longer among the options, the variable keeps its value and the +control shows no label until a later read offers that record again. + +Before, both refreshed after such a write only when their host remounted them, +and `PageView` is about to stop doing that (objectui#10519). diff --git a/packages/components/src/renderers/basic/__tests__/record-picker.busReread-10853.test.tsx b/packages/components/src/renderers/basic/__tests__/record-picker.busReread-10853.test.tsx new file mode 100644 index 0000000000..4efdf9671c --- /dev/null +++ b/packages/components/src/renderers/basic/__tests__/record-picker.busReread-10853.test.tsx @@ -0,0 +1,181 @@ +/** + * 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#10853 — `element:record_picker` re-reads its options when the + * data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a + * change to the object it queries, in place, and never touches the bound + * page-variable value. + * + * Before this card the option read ran only when the adapter or the query + * (object, filter, sort, limit) changed, so after a page action over raw HTTP + * the picker kept offering the pre-action records until its host remounted it, + * and `PageView`'s remount is what objectui#10519 removes. + * + * Rendered through the real `SchemaRenderer` and this package's own + * registration, inside a real `PageVariablesProvider` whose variable is bound + * to the picker. Reads answer at once until a case holds them, so the case can + * look at the picker while a re-read is in flight. The bare + * `useDataInvalidation` reader beside the picker is the positive control. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as React from 'react'; +import { render, act, cleanup, waitFor, screen } from '@testing-library/react'; +import { + AdapterCtx, + PageVariablesProvider, + SchemaRenderer, + notifyDataChanged, + useDataInvalidation, + usePageVariables, +} from '@object-ui/react'; +// Registers `element:record_picker` at module scope, not in a hook +// (object-ui/no-dynamic-import-in-test-hook, objectui#3010). +import '../../../renderers'; + +afterEach(cleanup); + +type Row = Record; + +interface HeldRead { + objectName: string; + resolve: (value: unknown) => void; +} + +function makeAdapter(stored: Row[]) { + const held: HeldRead[] = []; + const state = { hold: false, rows: stored }; + const adapter = { + find: vi.fn((objectName: string) => { + if (state.hold) { + return new Promise((resolve) => { + held.push({ objectName, resolve }); + }); + } + return Promise.resolve({ data: state.rows.map((r) => ({ ...r })) }); + }), + }; + return { adapter, held, state }; +} + +/** The positive control: a bare reader of the picker's object. */ +function BusControl() { + const nonce = useDataInvalidation('account'); + return {nonce}; +} + +/** What the page holds in the variable the picker writes. */ +function BoundValue() { + const { variables } = usePageVariables(); + return {String(variables.sel)}; +} + +const PICKER = { type: 'element:record_picker', id: 'picker', properties: { object: 'account' } }; + +function mountPicker(stored: Row[], node: Record = PICKER) { + const made = makeAdapter(stored); + render( + + + + + + + , + ); + return made; +} + +async function settle(fn: () => void = () => {}) { + await act(async () => { + fn(); + await Promise.resolve(); + }); +} +const rest = () => act(() => new Promise((resolve) => setTimeout(resolve, 30))); +const emit = (change: { objectName: string; recordId?: string }) => settle(() => notifyDataChanged(change)); + +const trigger = () => screen.getByTestId('record-picker-trigger') as HTMLButtonElement; +const boundValue = () => screen.getByTestId('bound-value').textContent; + +async function mountAtRest(stored: Row[] = [{ id: 'a1', name: 'Acme' }, { id: 'a2', name: 'Globex' }]) { + const made = mountPicker(stored); + await waitFor(() => expect(made.adapter.find).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(trigger().textContent).toBe('Acme')); + await rest(); + return made; +} + +describe('element:record_picker re-reads its options on the data-invalidation bus (objectui#10853)', () => { + it('an unscoped change ("*") re-reads once, in place, keeping the bound value', async () => { + const { adapter, held, state } = await mountAtRest(); + const triggerNode = trigger(); + state.hold = true; + + await emit({ objectName: '*' }); + + expect(screen.getByTestId('bus-control').textContent, 'control: the event never reached a subscriber').toBe('1'); + await waitFor(() => expect(adapter.find, 'the options never re-read after the bus reported a change').toHaveBeenCalledTimes(2)); + expect(adapter.find.mock.calls[1][0]).toBe('account'); + // In flight: the same control, enabled, still showing the bound record. + expect(trigger(), 'the re-read remounted the picker').toBe(triggerNode); + expect(trigger().disabled, 'the re-read disabled the picker').toBe(false); + expect(trigger().textContent, 'the re-read blanked the picker to "Loading…"').toBe('Acme'); + expect(boundValue()).toBe('a1'); + + await settle(() => held[0].resolve({ data: [{ id: 'a1', name: 'Acme (renamed)' }, { id: 'a2', name: 'Globex' }] })); + await waitFor(() => expect(trigger().textContent).toBe('Acme (renamed)')); + expect(trigger()).toBe(triggerNode); + expect(boundValue(), 'the re-read changed the bound page-variable value').toBe('a1'); + }); + + it('a change to its object re-reads once; an unrelated object does not', async () => { + const { adapter } = await mountAtRest(); + + await emit({ objectName: 'unrelated_object' }); + await rest(); + expect(adapter.find, 'a change to another object re-read the options').toHaveBeenCalledTimes(1); + + await emit({ objectName: 'account', recordId: 'a2' }); + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(2)); + await rest(); + expect(adapter.find, 'one change re-read the options more than once').toHaveBeenCalledTimes(2); + }); + + it('the bound record gone from the re-read options: the value is kept, the control shows no label, and the label returns with the record', async () => { + const { adapter, state } = await mountAtRest(); + + state.rows = [{ id: 'a2', name: 'Globex' }]; + await emit({ objectName: 'account' }); + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(2)); + await rest(); + + expect(boundValue(), 'the re-read cleared the bound page-variable value').toBe('a1'); + // The value names no offered record: neither the old label nor the + // placeholder is drawn (a value is set, so the placeholder does not apply). + expect(trigger().textContent).toBe(''); + expect(trigger().disabled).toBe(false); + + state.rows = [{ id: 'a1', name: 'Acme' }, { id: 'a2', name: 'Globex' }]; + await emit({ objectName: 'account' }); + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(3)); + await waitFor(() => expect(trigger().textContent).toBe('Acme')); + expect(boundValue()).toBe('a1'); + }); + + it('control: a picker with no object reads nothing, on mount or on an invalidation', async () => { + const { adapter } = mountPicker([{ id: 'a1', name: 'Acme' }], { type: 'element:record_picker', id: 'picker', properties: {} }); + await rest(); + + await emit({ objectName: '*' }); + await rest(); + + expect(screen.getByTestId('bus-control').textContent).toBe('1'); + expect(adapter.find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/components/src/renderers/basic/record-picker.tsx b/packages/components/src/renderers/basic/record-picker.tsx index b5217ead2d..d11d0e1fcc 100644 --- a/packages/components/src/renderers/basic/record-picker.tsx +++ b/packages/components/src/renderers/basic/record-picker.tsx @@ -37,6 +37,7 @@ import { ElementDataSourceErrorPanel, ElementDataSourceLoadingPanel, useAdapter, + useDataInvalidation, useElementDataSource, usePageVariableBinding, useFilterScope, @@ -135,14 +136,39 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) { // with the same entries is not a change (AGENTS.md #10). const sortKey = React.useMemo(() => (sort ? JSON.stringify(sort) : ''), [sort]); + // objectui#10853 — the data-invalidation bus (`notifyDataChanged` from + // `@object-ui/react`), read the objectui#10623 / objectui#10778 way: the + // nonce moves when the bus reports a change to the object this picker + // QUERIES (or `'*'`), and the fetch effect below names it, so the options are + // re-read. Before, a page action over raw HTTP left them stale unless the + // host remounted the picker, and `PageView` is to stop doing that + // (objectui#10519). Subscribed only when the effect can query (an adapter + // that reads, and an object resolved). + const invalidationNonce = useDataInvalidation( + adapter && typeof adapter.find === 'function' ? object : undefined, + ); + // The adapter and query the options on screen answer, for telling a re-read + // of them from a read of other options. Written when a read commits. + const committedReadRef = React.useRef<{ adapter: unknown; signature: string } | null>(null); + React.useEffect(() => { let cancelled = false; if (!adapter || !object || typeof adapter.find !== 'function') { setLoading(false); return; } - setLoading(true); - setError(null); + // objectui#10853 — a read of exactly the query whose options are on + // screen (a bus re-read) is IN PLACE: the control stays enabled over those + // options and the bound page-variable value is not touched, and the answer + // swaps them when it lands. Any other read starts from "Loading…", as it + // always has. + const signature = JSON.stringify([object, filterKey, sortKey, limit]); + const committed = committedReadRef.current; + const inPlace = committed !== null && committed.adapter === adapter && committed.signature === signature; + if (!inPlace) { + setLoading(true); + setError(null); + } (async () => { try { const query: any = {}; @@ -159,7 +185,13 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) { // it here. Pinned by // `record-picker.contractEnvelope-6726.test.tsx`. const data: any[] = res?.data ?? (Array.isArray(res) ? res : []); - if (!cancelled) setRows(data); + if (!cancelled) { + setRows(data); + // An in-place re-read that lands clears an earlier failure: the + // options on screen answer the query now. + setError(null); + committedReadRef.current = { adapter, signature }; + } } catch (e: any) { if (!cancelled) setError(e?.message ?? 'Failed to load'); } finally { @@ -170,7 +202,7 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [adapter, object, filterKey, sortKey, limit]); + }, [adapter, object, filterKey, sortKey, limit, invalidationNonce]); // Reflect the bound variable's value back into the control. When a variable // targets this picker we stay controlled for its whole lifetime (empty string @@ -402,7 +434,7 @@ ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecord // legal write this input's own description teaches (objectui#5637). type: ['string', 'object'], description: - 'Prompt shown in the closed control while no record is selected (renderer default "Select a record…"). Display-only — it never reaches the query. Accepts either a plain string or an inline per-locale map (`{ en: "Owner", "zh-CN": "负责人" }`), the `I18nLabel` union rc.6 widened this key to; the renderer resolves the map against the active language at the read site, falling back through base language, a region-qualified sibling, `default`, then `en`. It is REPLACED while the picker is busy: "Loading…" during the fetch and "Failed to load" after an error both win over this key. An authored empty string stays empty; the default applies only when the key is absent.', + 'Prompt shown in the closed control while no record is selected (renderer default "Select a record…"). Display-only — it never reaches the query. Accepts either a plain string or an inline per-locale map (`{ en: "Owner", "zh-CN": "负责人" }`), the `I18nLabel` union rc.6 widened this key to; the renderer resolves the map against the active language at the read site, falling back through base language, a region-qualified sibling, `default`, then `en`. It is REPLACED while the picker is busy: "Loading…" while it reads a new set of options and "Failed to load" after an error both win over this key (a re-read of the same options after a data change keeps them on screen and shows no "Loading…"). An authored empty string stays empty; the default applies only when the key is absent.', }, { name: 'label', diff --git a/packages/plugin-form/src/MasterDetailForm.busReread-10853.test.tsx b/packages/plugin-form/src/MasterDetailForm.busReread-10853.test.tsx new file mode 100644 index 0000000000..39b76e47f8 --- /dev/null +++ b/packages/plugin-form/src/MasterDetailForm.busReread-10853.test.tsx @@ -0,0 +1,510 @@ +/** + * 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#10853 — an `object-master-detail-form` in edit mode re-reads its + * detail lines when the data-invalidation bus (`notifyDataChanged` from + * `@object-ui/react`) reports a change to a collection's CHILD object, in place. + * + * A stored page can hold this block with `mode: 'edit'` and an authored + * `recordId`. Before this card the lines were read only when the record, the + * adapter or the resolved details changed, so after a page action over raw + * HTTP the header re-read (its own ``, objectui#10572) while the + * lines kept the pre-action rows until the host remounted the form, and + * `PageView`'s remount is what objectui#10519 removes. + * + * Unsaved lines follow the objectui#10712 R3 / objectui#10572 rule per + * collection: the re-read is HELD while the collection holds lines the user + * has not saved, and replayed once after a revert or after the save lands. + * + * Rendered through the real `SchemaRenderer` and this package's own + * registration, with the real line-item grid. Reads answer at once until a + * case holds them, so the case can look at the form while a re-read is in + * flight. The bare `useDataInvalidation` reader beside the form is the + * positive control: it proves the event reached subscribers. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup, waitFor, fireEvent, screen } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, notifyDataChanged, useDataInvalidation } from '@object-ui/react'; +import { registerAllFields } from '@object-ui/fields'; +// Registers `object-master-detail-form` and `object-form` through this package's own entry. +import './index'; + +registerAllFields(); + +type Row = Record; + +const SCHEMAS: Record = { + po: { name: 'po', fields: { ref: { type: 'text', label: 'Ref' } } }, + po_line: { + name: 'po_line', + fields: { + label: { type: 'text', label: 'Line' }, + memo: { type: 'text', label: 'Memo' }, + po: { type: 'master_detail', label: 'PO', reference: 'po' }, + }, + }, + po_note: { + name: 'po_note', + fields: { text: { type: 'text', label: 'Note' }, po: { type: 'master_detail', label: 'PO', reference: 'po' } }, + }, +}; + +interface HeldRead { + objectName: string; + query: any; + resolve: (value: unknown) => void; +} + +function makeDataSource(stored: Record) { + const held: HeldRead[] = []; + const state = { hold: false }; + let minted = 0; + const dataSource = { + getObjectSchema: vi.fn(async (objectName: string) => SCHEMAS[objectName] ?? null), + findOne: vi.fn(async () => ({ id: 'po1', ref: 'PO-1' })), + find: vi.fn((objectName: string, query: any) => { + if (state.hold) { + return new Promise((resolve) => { + held.push({ objectName, query, resolve }); + }); + } + return Promise.resolve({ data: (stored[objectName] ?? []).map((r) => ({ ...r })) }); + }), + // Answers per the `batchTransaction` contract and writes through to the + // store, so a later read returns what the save wrote. + batchTransaction: vi.fn(async (ops: Array<{ object: string; action?: string; id?: string; data?: Row }>) => ({ + results: ops.map((op) => { + const rows = (stored[op.object] ??= []); + if (op.action === 'create') { + const created = { id: `new${++minted}`, ...op.data }; + rows.push(created); + return created; + } + if (op.action === 'delete') { + stored[op.object] = rows.filter((r) => r.id !== op.id); + return true; + } + const at = rows.findIndex((r) => r.id === op.id); + if (at >= 0) rows[at] = { ...rows[at], ...op.data }; + return { id: op.id, ...op.data }; + }), + })), + }; + return { dataSource, held, state }; +} + +const PO_LINE_DETAIL = { + childObject: 'po_line', + relationshipField: 'po', + title: 'Lines', + columns: [{ name: 'label', label: 'Line', type: 'text' }], +}; +const PO_NOTE_DETAIL = { + childObject: 'po_note', + relationshipField: 'po', + title: 'Notes', + columns: [{ name: 'text', label: 'Note', type: 'text' }], +}; + +/** The block as a stored page holds it: edit mode, an authored parent, no record context. */ +const formNode = (details: unknown[]) => ({ + type: 'object-master-detail-form', + objectName: 'po', + mode: 'edit', + recordId: 'po1', + fields: ['ref'], + details, +}); + +/** The positive control: a bare reader of the child object, beside the form. */ +function BusControl() { + const nonce = useDataInvalidation('po_line'); + return {nonce}; +} + +const renderNode = (node: unknown, ds: unknown) => + render( + + + + , + ); + +async function settle(fn: () => void = () => {}) { + await act(async () => { + fn(); + await Promise.resolve(); + }); +} +const emit = (change: { objectName: string; recordId?: string }) => settle(() => notifyDataChanged(change)); +const answer = (read: HeldRead, rows: Row[]) => settle(() => read.resolve({ data: rows })); + +const reads = (ds: { find: { mock: { calls: any[][] } } }, objectName: string) => + ds.find.mock.calls.filter((c) => c[0] === objectName).length; + +/** + * A collection's line inputs; the grid always trails one blank entry row. The + * row editor's own fields carry the same labels, so they are left out. + */ +const inputsOf = (label: string) => + (screen.queryAllByLabelText(label) as HTMLInputElement[]).filter((el) => !el.closest('[data-testid="md-row-form"]')); +const shown = (label: string) => inputsOf(label).map((i) => i.value).filter((v) => v !== ''); +const saveButton = () => screen.getByTestId('md-form-submit') as HTMLButtonElement; +const change = (el: HTMLElement, value: string) => settle(() => fireEvent.change(el, { target: { value } })); + +async function mount(details: unknown[] = [PO_LINE_DETAIL], seed?: Record) { + const stored = seed ?? { + po_line: [{ id: 'l1', label: 'first', po: 'po1' }], + po_note: [{ id: 'n1', text: 'note one', po: 'po1' }], + }; + const ds = makeDataSource(stored); + const view = renderNode(formNode(details), ds.dataSource); + await waitFor(() => { + const ref = view.container.querySelector('input[name="ref"]') as HTMLInputElement | null; + expect(ref?.value).toBe('PO-1'); + }); + await waitFor(() => expect(shown('Line')).toEqual(stored.po_line.map((r) => r.label))); + await waitFor(() => expect(saveButton().disabled).toBe(false)); + // Let every read the mount issued settle, so a count taken now is at rest. + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + return { ...ds, view, stored }; +} + +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + vi.restoreAllMocks(); + cleanup(); +}); + +describe('object-master-detail-form (edit) re-reads its lines on the data-invalidation bus (objectui#10853)', () => { + it('an unscoped change ("*") re-reads the lines once, in place, and moves the save baseline with them', async () => { + const { dataSource, held, state } = await mount(); + const atRest = reads(dataSource, 'po_line'); + const firstInput = inputsOf('Line')[0]; + state.hold = true; + + await emit({ objectName: '*' }); + + expect(screen.getByTestId('bus-control').textContent, 'control: the event never reached a subscriber').toBe('1'); + await waitFor(() => + expect(reads(dataSource, 'po_line'), 'the lines never re-read after the bus reported a change').toBe(atRest + 1), + ); + const lineRead = held.find((r) => r.objectName === 'po_line')!; + expect(lineRead.query.$filter).toEqual({ po: 'po1' }); + // In flight: the lines on screen stay drawn, in the same grid. + expect(shown('Line'), 'the re-read blanked the lines').toEqual(['first']); + expect(inputsOf('Line')[0], 'the re-read unmounted the grid').toBe(firstInput); + expect(screen.queryByText('Loading columns…')).toBeNull(); + + await answer(lineRead, [ + { id: 'l1', label: 'first (renamed)', po: 'po1' }, + { id: 'l2', label: 'second', po: 'po1' }, + ]); + await waitFor(() => expect(shown('Line')).toEqual(['first (renamed)', 'second'])); + expect(inputsOf('Line')[0], 'the grid was remounted by the re-read').toBe(firstInput); + + // The baseline moved with the rows: a save with no edit writes no line. + state.hold = false; + await settle(() => fireEvent.click(saveButton())); + await waitFor(() => expect(dataSource.batchTransaction).toHaveBeenCalledTimes(1)); + const ops = dataSource.batchTransaction.mock.calls[0][0] as Array<{ object: string }>; + expect(ops.filter((op) => op.object === 'po_line'), 'the re-read left its rows unsaved against the old baseline').toEqual([]); + }); + + it('a change to the child object re-reads the lines once; an unrelated object does not', async () => { + const { dataSource } = await mount(); + const atRest = reads(dataSource, 'po_line'); + + await emit({ objectName: 'unrelated_object' }); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + expect(reads(dataSource, 'po_line'), 'a change to another object re-read the lines').toBe(atRest); + + await emit({ objectName: 'po_line', recordId: 'l1' }); + await waitFor(() => expect(reads(dataSource, 'po_line')).toBe(atRest + 1)); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + expect(reads(dataSource, 'po_line'), 'one change re-read the lines more than once').toBe(atRest + 1); + }); + + it('each collection answers to its own child object; "*" re-reads every collection', async () => { + const { dataSource } = await mount([PO_LINE_DETAIL, PO_NOTE_DETAIL]); + await waitFor(() => expect(shown('Note')).toEqual(['note one'])); + const lines = reads(dataSource, 'po_line'); + const notes = reads(dataSource, 'po_note'); + + await emit({ objectName: 'po_note' }); + await waitFor(() => expect(reads(dataSource, 'po_note')).toBe(notes + 1)); + expect(reads(dataSource, 'po_line'), 'a change to the notes re-read the lines').toBe(lines); + + await emit({ objectName: '*' }); + await waitFor(() => expect(reads(dataSource, 'po_line')).toBe(lines + 1)); + await waitFor(() => expect(reads(dataSource, 'po_note')).toBe(notes + 2)); + }); + + it('unsaved lines HOLD the re-read; a revert replays it once', async () => { + const { dataSource, held, state } = await mount(); + const atRest = reads(dataSource, 'po_line'); + await change(inputsOf('Line')[0], 'first edited'); + + await emit({ objectName: 'po_line' }); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + expect(reads(dataSource, 'po_line'), 'a bus re-read ran over unsaved lines').toBe(atRest); + expect(shown('Line'), 'the unsaved line was discarded').toEqual(['first edited']); + + state.hold = true; + await change(inputsOf('Line')[0], 'first'); + await waitFor(() => expect(reads(dataSource, 'po_line'), 'the revert did not replay the held re-read').toBe(atRest + 1)); + await answer(held[0], [{ id: 'l1', label: 'first (server)', po: 'po1' }]); + await waitFor(() => expect(shown('Line')).toEqual(['first (server)'])); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + expect(reads(dataSource, 'po_line'), 'the held re-read was replayed more than once').toBe(atRest + 1); + }); + + it('unsaved lines HOLD the re-read; the save landing replays it once', async () => { + const { dataSource } = await mount(); + const atRest = reads(dataSource, 'po_line'); + await change(inputsOf('Line')[0], 'first edited'); + + await emit({ objectName: '*' }); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + expect(reads(dataSource, 'po_line'), 'a bus re-read ran over unsaved lines').toBe(atRest); + + await settle(() => fireEvent.click(saveButton())); + await waitFor(() => expect(dataSource.batchTransaction).toHaveBeenCalledTimes(1)); + const ops = dataSource.batchTransaction.mock.calls[0][0] as Array<{ object: string; data?: Row }>; + expect(ops.find((op) => op.object === 'po_line')?.data).toEqual({ label: 'first edited' }); + await waitFor(() => expect(reads(dataSource, 'po_line'), 'the save did not replay the held re-read').toBe(atRest + 1)); + await waitFor(() => expect(saveButton().disabled).toBe(false)); + await waitFor(() => expect(shown('Line')).toEqual(['first edited'])); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + expect(reads(dataSource, 'po_line'), 'the held re-read was replayed more than once').toBe(atRest + 1); + }); + + it('a line edited while a re-read is in flight is kept; the re-read is held behind it', async () => { + const { dataSource, held, state } = await mount(); + const atRest = reads(dataSource, 'po_line'); + state.hold = true; + + await emit({ objectName: '*' }); + await waitFor(() => expect(reads(dataSource, 'po_line')).toBe(atRest + 1)); + await change(inputsOf('Line')[0], 'typed in flight'); + await answer(held.find((r) => r.objectName === 'po_line')!, [{ id: 'l1', label: 'from server', po: 'po1' }]); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + + expect(shown('Line'), 'the re-read overwrote a line typed while it was in flight').toEqual(['typed in flight']); + expect(reads(dataSource, 'po_line'), 're-read again over the unsaved line').toBe(atRest + 1); + }); + + it('`object-form` with `subforms` (routed to the same form) re-reads its lines too', async () => { + const ds = makeDataSource({ po_line: [{ id: 'l1', label: 'first', po: 'po1' }] }); + const view = renderNode( + { type: 'object-form', objectName: 'po', mode: 'edit', recordId: 'po1', fields: ['ref'], subforms: [PO_LINE_DETAIL] }, + ds.dataSource, + ); + await waitFor(() => expect((view.container.querySelector('input[name="ref"]') as HTMLInputElement | null)?.value).toBe('PO-1')); + await waitFor(() => expect(shown('Line')).toEqual(['first'])); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + const atRest = reads(ds.dataSource, 'po_line'); + + await emit({ objectName: '*' }); + await waitFor(() => expect(reads(ds.dataSource, 'po_line')).toBe(atRest + 1)); + }); + + it('control: create mode reads no lines, on mount or on an invalidation', async () => { + const ds = makeDataSource({ po_line: [{ id: 'l1', label: 'first', po: 'po1' }] }); + renderNode({ ...formNode([PO_LINE_DETAIL]), mode: 'create', recordId: undefined }, ds.dataSource); + await waitFor(() => expect(saveButton().disabled).toBe(false)); + + await emit({ objectName: '*' }); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + + expect(screen.getByTestId('bus-control').textContent).toBe('1'); + expect(reads(ds.dataSource, 'po_line')).toBe(0); + }); +}); + +/** + * objectui#10853, patch round (contract review of ac526b1) — the row editor + * ("Open row", offered in grid mode when `formFields` outnumber `columns`) keeps + * the user's draft in its own form, not in the collection's rows, so the rows + * alone cannot say the collection is being edited. While the editor is open on + * a collection, that collection's bus re-read is HELD, exactly as for unsaved + * grid lines, and it is replayed through the same path once the editor closes + * and the lines read as saved. The editor is neither reset, re-keyed nor + * disabled by a bus event. + */ +const EDITOR_DETAIL = { + childObject: 'po_line', + relationshipField: 'po', + title: 'Lines', + columns: [{ name: 'label', label: 'Line', type: 'text' }], + formFields: ['label', 'memo'], +}; +const TWO_LINES = () => ({ + po_line: [ + { id: 'l1', label: 'first', memo: 'memo one', po: 'po1' }, + { id: 'l2', label: 'second', memo: 'memo two', po: 'po1' }, + ], +}); + +const editor = () => screen.queryByTestId('md-row-form'); +const editorInput = (name: string) => editor()?.querySelector(`input[name="${name}"]`) as HTMLInputElement | null; +const editorButton = (text: string) => + Array.from(editor()?.querySelectorAll('button') ?? []).find((b) => b.textContent?.trim() === text) as + | HTMLButtonElement + | undefined; + +async function openRow(index: number, label: string) { + await settle(() => fireEvent.click(screen.getAllByLabelText('Open row')[index])); + await waitFor(() => expect(editorInput('label')?.value).toBe(label)); +} +async function draft(values: Record) { + for (const [name, value] of Object.entries(values)) { + await change(editorInput(name)!, value); + await waitFor(() => expect(editorInput(name)?.value).toBe(value)); + } +} +const pause = () => + act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + +describe('object-master-detail-form (edit): an open row editor holds its collection’s bus re-read (objectui#10853)', () => { + it('a bus event that rewrites the line under the editor: the draft is kept, Apply writes that line, the held re-read replays once after the save', async () => { + const { dataSource, stored } = await mount([EDITOR_DETAIL], TWO_LINES()); + await waitFor(() => expect(shown('Line')).toEqual(['first', 'second'])); + const atRest = reads(dataSource, 'po_line'); + await openRow(0, 'first'); + await draft({ label: 'first (draft)', memo: 'memo draft' }); + + stored.po_line[0] = { ...stored.po_line[0], label: 'first (server)', memo: 'memo server' }; + await emit({ objectName: 'po_line', recordId: 'l1' }); + await pause(); + + expect(editorInput('label')?.value, 'the re-read reset the editor’s draft').toBe('first (draft)'); + expect(editorInput('memo')?.value, 'the re-read reset the editor’s draft').toBe('memo draft'); + expect(reads(dataSource, 'po_line'), 'a bus re-read ran under an open row editor').toBe(atRest); + + await settle(() => fireEvent.click(editorButton('Apply')!)); + await waitFor(() => expect(editor()).toBeNull()); + expect(shown('Line'), 'Apply did not write the line it was opened on').toEqual(['first (draft)', 'second']); + await pause(); + // The applied line is unsaved, so the re-read stays held behind it. + expect(reads(dataSource, 'po_line'), 'the re-read ran over the applied, unsaved line').toBe(atRest); + + await settle(() => fireEvent.click(saveButton())); + await waitFor(() => expect(dataSource.batchTransaction).toHaveBeenCalledTimes(1)); + const ops = dataSource.batchTransaction.mock.calls[0][0] as Array<{ object: string; action?: string; id?: string; data?: Row }>; + expect(ops.filter((op) => op.object === 'po_line')).toEqual([ + { object: 'po_line', action: 'update', id: 'l1', data: { label: 'first (draft)', memo: 'memo draft' } }, + ]); + await waitFor(() => expect(reads(dataSource, 'po_line'), 'the save did not replay the held re-read').toBe(atRest + 1)); + await waitFor(() => expect(saveButton().disabled).toBe(false)); + await pause(); + expect(reads(dataSource, 'po_line'), 'the held re-read was replayed more than once').toBe(atRest + 1); + }); + + it('the server drops the line under the editor: the draft is kept and Apply writes the line it was opened on, not its neighbour', async () => { + const { dataSource, stored } = await mount([EDITOR_DETAIL], TWO_LINES()); + await waitFor(() => expect(shown('Line')).toEqual(['first', 'second'])); + const atRest = reads(dataSource, 'po_line'); + await openRow(0, 'first'); + await draft({ label: 'first (draft)' }); + + stored.po_line = stored.po_line.filter((r) => r.id !== 'l1'); + await emit({ objectName: '*' }); + await pause(); + + expect(editorInput('label')?.value, 'the re-read reset the editor to another line').toBe('first (draft)'); + expect(reads(dataSource, 'po_line'), 'a bus re-read ran under an open row editor').toBe(atRest); + + await settle(() => fireEvent.click(editorButton('Apply')!)); + await waitFor(() => expect(editor()).toBeNull()); + expect(shown('Line'), 'Apply wrote onto another line').toEqual(['first (draft)', 'second']); + + await settle(() => fireEvent.click(saveButton())); + await waitFor(() => expect(dataSource.batchTransaction).toHaveBeenCalledTimes(1)); + const ops = dataSource.batchTransaction.mock.calls[0][0] as Array<{ object: string; id?: string }>; + expect(ops.filter((op) => op.object === 'po_line').map((op) => op.id), 'the edit was sent to another line').toEqual(['l1']); + // After the save the held re-read replays once and shows what the server holds. + await waitFor(() => expect(reads(dataSource, 'po_line')).toBe(atRest + 1)); + await waitFor(() => expect(shown('Line')).toEqual(['second'])); + }); + + it('closing the editor without Apply replays the held re-read once', async () => { + const { dataSource, stored } = await mount([EDITOR_DETAIL], TWO_LINES()); + await waitFor(() => expect(shown('Line')).toEqual(['first', 'second'])); + const atRest = reads(dataSource, 'po_line'); + await openRow(0, 'first'); + await draft({ label: 'first (draft)' }); + + stored.po_line[0] = { ...stored.po_line[0], label: 'first (server)' }; + await emit({ objectName: 'po_line' }); + await pause(); + expect(editorInput('label')?.value, 'the re-read reset the editor’s draft').toBe('first (draft)'); + expect(reads(dataSource, 'po_line'), 'a bus re-read ran under an open row editor').toBe(atRest); + + await settle(() => fireEvent.click(editorButton('Close')!)); + await waitFor(() => expect(editor()).toBeNull()); + await waitFor(() => expect(reads(dataSource, 'po_line'), 'closing the editor did not replay the held re-read').toBe(atRest + 1)); + await waitFor(() => expect(shown('Line')).toEqual(['first (server)', 'second'])); + await pause(); + expect(reads(dataSource, 'po_line'), 'the held re-read was replayed more than once').toBe(atRest + 1); + }); + + it('a re-read already in flight when the editor opens is not committed under it; it is held and replays once on close', async () => { + const { dataSource, held, state, stored } = await mount([EDITOR_DETAIL], TWO_LINES()); + await waitFor(() => expect(shown('Line')).toEqual(['first', 'second'])); + const atRest = reads(dataSource, 'po_line'); + state.hold = true; + + await emit({ objectName: '*' }); + await waitFor(() => expect(reads(dataSource, 'po_line')).toBe(atRest + 1)); + await openRow(0, 'first'); + await draft({ label: 'first (draft)' }); + await answer(held.find((r) => r.objectName === 'po_line')!, [ + { id: 'l1', label: 'first (server)', memo: 'memo one', po: 'po1' }, + { id: 'l2', label: 'second', memo: 'memo two', po: 'po1' }, + ]); + await pause(); + + expect(editorInput('label')?.value, 'the in-flight re-read reset the editor’s draft').toBe('first (draft)'); + expect(shown('Line'), 'the in-flight re-read committed under the open editor').toEqual(['first', 'second']); + expect(reads(dataSource, 'po_line'), 're-read again under the open editor').toBe(atRest + 1); + + state.hold = false; + stored.po_line[0] = { ...stored.po_line[0], label: 'first (server)' }; + await settle(() => fireEvent.click(editorButton('Close')!)); + await waitFor(() => expect(editor()).toBeNull()); + await waitFor(() => expect(reads(dataSource, 'po_line')).toBe(atRest + 2)); + await waitFor(() => expect(shown('Line')).toEqual(['first (server)', 'second'])); + }); +}); diff --git a/packages/plugin-form/src/MasterDetailForm.tsx b/packages/plugin-form/src/MasterDetailForm.tsx index 157fa2762a..1a74a580d7 100644 --- a/packages/plugin-form/src/MasterDetailForm.tsx +++ b/packages/plugin-form/src/MasterDetailForm.tsx @@ -37,6 +37,7 @@ import { LineItemsField, type GridColumn } from '@object-ui/fields'; import { Button, Card, CardContent, CardHeader, CardTitle, cn, toast } from '@object-ui/components'; import { useDisplayLocale } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; +import { dataChangeMatches, subscribeDataChanges } from '@object-ui/react'; import { ObjectForm } from './ObjectForm'; import { applyColumnPermissions } from './fieldWriteGate'; import { useUploadGate, UploadGateProvider, UploadInFlightNotice } from './uploadGate'; @@ -47,6 +48,7 @@ import { isBlankRow, sumRows, type BatchEditDetailInput, + type ChildSchema, } from './masterDetailTx'; import { isSameStoredValue } from './sanitize'; import { deriveDetail, hydrateColumns, type InlineMode } from './deriveMasterDetail'; @@ -377,6 +379,78 @@ function synthesizeDetailIds(raw: MasterDetailDetailConfig[]): string[] { }); } +/** + * objectui#10853 — `useDataInvalidation` (`@object-ui/react`) for a SET of + * objects: each name's nonce moves once per change the data-invalidation bus + * reports for it. + * + * The edit-mode lines read one child object per detail collection, and the + * collections are authored, so there is no fixed number of hook calls to make. + * This is the same bus and the same test, not a second channel: + * `subscribeDataChanges` is the listener set `useDataInvalidation` subscribes + * through, and `dataChangeMatches` its matcher. It is applied object-level (no + * `recordId`), because a collection is many records of its object. + * + * Keyed on the set's CONTENT, so a fresh array of the same names neither + * resubscribes nor resets a nonce (AGENTS.md #10). + */ +function useObjectsInvalidation(objectNames: readonly string[]): Readonly> { + const namesKey = JSON.stringify(Array.from(new Set(objectNames)).sort()); + const [nonces, setNonces] = useState>({}); + useEffect(() => { + const names = JSON.parse(namesKey) as string[]; + if (names.length === 0) return; + const unsubscribe = subscribeDataChanges((change) => { + const hit = names.filter((name) => dataChangeMatches(change, name)); + if (hit.length === 0) return; + setNonces((prev) => { + const next = { ...prev }; + for (const name of hit) next[name] = (next[name] ?? 0) + 1; + return next; + }); + }); + return () => { + unsubscribe(); + }; + }, [namesKey]); + return nonces; +} + +/** + * objectui#10853 — whether one collection holds lines the user has not saved: + * the edit save's own row diff (`buildMasterDetailEditBatch`) finds something + * to write to it OTHER than the parent link. A user's edit therefore reads as + * unsaved exactly when the save would send it, and a line changed back to its + * stored value (a revert) reads as saved again. + * + * The parent link is set aside on purpose, and it is the one place the two + * disagree. The save restates the link on every row it writes, as the parent + * id in string form, and a row read back can carry it in another form (a + * numeric id, an expanded lookup); against such a backend the save restates it + * on every row while this reads the lines as saved. That restatement is not the + * user's input, so it does not hold a re-read. + */ +function linesUnsaved( + state: RowState | undefined, + detail: MasterDetailDetailConfig, + parentId: string, + childSchema: ChildSchema, +): boolean { + if (!state || !detail.relationshipField) return false; + const relationshipField = detail.relationshipField; + const ops = buildMasterDetailEditBatch('', parentId, {}, [ + { + childObject: detail.childObject, + relationshipField, + rows: state.rows, + original: state.original.map((row) => ({ ...row, [relationshipField]: parentId })), + childSchema, + }, + ]); + // Op 0 is the (empty) parent update the builder always leads with. + return ops.length > 1; +} + /** * Read the live header record from the rendered parent-form host by scraping its * named controls. The header is owned by react-hook-form (inside ), @@ -965,37 +1039,197 @@ export const MasterDetailForm: React.FC = ({ clearSaveGuardTimer(); }, [clearSaveGuardTimer]); + // Per-row "expand to full form": opens the child's complete form (all business + // fields, incl. rich types the grid omits) in a drawer, pre-filled with the + // row. Saving writes back into the in-memory row — the atomic batch still + // persists everything on the parent Save (no separate backend write here). + // `isNew` marks a row created by "Add" in list/form mode — cancelling the + // editor without applying discards that empty row. + const [expanded, setExpanded] = useState<{ entryId: string; rowIdx: number; isNew?: boolean } | null>(null); + // objectui#10853 — the collection the row editor is open on. The editor holds + // its draft in its own form, not in `rowState`, so the rows alone cannot say + // that collection is being edited; the bus re-read reads this instead. + // Mirrored in render, as `rowStateRef` is, so a read that lands later sees + // the editor as it stands. + const rowEditorEntryRef = useRef(null); + rowEditorEntryRef.current = expanded?.entryId ?? null; + + /** + * objectui#10853 — the edit-mode lines read the data-invalidation bus + * (`notifyDataChanged` from `@object-ui/react`), the objectui#10623 / + * objectui#10778 way: a change the bus reports for a collection's CHILD + * object (or `'*'`) re-reads that collection's lines in place. Before, they + * were read only when the record, the adapter or the resolved details + * changed, so a page action over raw HTTP left them stale unless the host + * remounted this form, and `PageView` is to stop doing that + * (objectui#10519). The header re-reads through its own `` + * (objectui#10572). + * + * Subscribed only for what the reads below can query: edit mode, an adapter, + * and a collection whose relationship field has resolved. + * + * Unsaved lines are the objectui#10712 R3 / objectui#10572 rule applied per + * collection: a collection holding lines the user has not saved + * ({@link linesUnsaved}), or with the row editor open on it, HOLDS its + * re-read. One re-read is replayed once the editor is closed and the lines + * read as saved again (a revert), or once this form's save lands. An open + * editor is never reset, re-keyed or disabled by a re-read. + */ + const linesReadObjects = + isEdit && dataSource + ? entries.flatMap((e) => (e.config.childObject && e.config.relationshipField ? [e.config.childObject] : [])) + : []; + const linesBusNonces = useObjectsInvalidation(linesReadObjects); + /** Bumped when the load effect below is torn down: every read in flight is then superseded. */ + const linesReadGenRef = useRef(0); + /** The number of each collection's latest read: only that read commits. */ + const linesReadSeqRef = useRef>({}); + /** Collections whose bus re-read is held behind unsaved lines. */ + const heldLinesRereadRef = useRef>(new Set()); + /** Collections a save has written since the lines were last replayed. */ + const linesSavedRef = useRef>(new Set()); + /** Per collection, the bus nonce of its child object already answered. */ + const linesBusSeenRef = useRef>({}); + + const parentIdKey = String(schema.recordId); + const unsavedLines = useCallback( + (entry: DetailEntry) => + rowEditorEntryRef.current === entry.id || + linesUnsaved( + rowStateRef.current[entry.id], + entry.config, + parentIdKey, + childSchemasRef.current[entry.config.childObject], + ), + [parentIdKey], + ); + + /** + * Read one collection's lines. `origin`: + * - `'load'`: the record, the adapter or the resolved details changed. The + * answer replaces the collection's rows and baseline, and a failed read + * leaves it empty (the behaviour before objectui#10853). + * - `'bus'`: the bus reported a change to the child object. HELD while the + * collection holds unsaved lines. A failed re-read keeps the lines on + * screen. + * - `'replay'`: a held re-read, replayed after a revert or a save. + * A `'bus'` or `'replay'` read keeps the grid drawn over the lines on screen + * and commits only over those same lines: if they were edited while it was + * in flight, its answer is not committed and the re-read is asked for again, + * so it is held behind the edit. + */ + const readLines = useCallback( + async function readLinesOf(entry: DetailEntry, origin: 'load' | 'bus' | 'replay'): Promise { + if (origin === 'bus') { + if (unsavedLines(entry)) { + heldLinesRereadRef.current.add(entry.id); + return; + } + heldLinesRereadRef.current.delete(entry.id); + } + const gen = linesReadGenRef.current; + const seq = (linesReadSeqRef.current[entry.id] ?? 0) + 1; + linesReadSeqRef.current[entry.id] = seq; + const isCurrent = () => linesReadGenRef.current === gen && linesReadSeqRef.current[entry.id] === seq; + const d = entry.config; + if (!dataSource || !d.relationshipField) { + // Not resolved yet: nothing to read, and no lines to show. + setRowState((prev) => ({ ...prev, [entry.id]: { rows: [], original: [] } })); + return; + } + const inPlace = origin !== 'load'; + const rowsAtIssue = rowStateRef.current[entry.id]?.rows; + let rows: RowState['rows']; + try { + const res = await dataSource.find(d.childObject, { + $filter: { [d.relationshipField]: schema.recordId }, + $top: 500, + }); + rows = (res?.data ?? []) as Record[]; + } catch (err) { + if (!isCurrent()) return; + if (inPlace) { + console.warn( + `[MasterDetailForm] could not re-read the lines of "${d.childObject}" after a data change; ${rowsAtIssue === undefined ? 'no lines have been read for it yet' : 'the lines on screen are kept'}.`, + err, + ); + return; + } + rows = []; + } + if (!isCurrent()) return; + if (inPlace && rowEditorEntryRef.current === entry.id) { + // The row editor was opened while this read was in flight: its commit + // would reset the editor's draft. Held, and replayed when it closes. + heldLinesRereadRef.current.add(entry.id); + return; + } + if (inPlace && rowStateRef.current[entry.id]?.rows !== rowsAtIssue) { + // Edited while this read was in flight: its answer would overwrite the + // edit. Ask again, which holds it behind the edit while it is unsaved. + void readLinesOf(entry, 'bus'); + return; + } + // Keyed by entry id, so a collection's rows land in ITS slot regardless + // of where it currently sits in the authored array. + setRowState((prev) => { + if (inPlace && (prev[entry.id]?.rows !== rowsAtIssue || rowEditorEntryRef.current === entry.id)) { + heldLinesRereadRef.current.add(entry.id); + return prev; + } + return { ...prev, [entry.id]: { rows: rows.map((r) => ({ ...r })), original: rows.map((r) => ({ ...r })) } }; + }); + }, + [dataSource, schema.recordId, unsavedLines], + ); + // Edit mode: load existing children for each detail collection. useEffect(() => { - let cancelled = false; - if (!isEdit || !dataSource) return; - (async () => { - const loaded = await Promise.all( - entries.map(async (e): Promise<[string, RowState]> => { - const d = e.config; - if (!d.relationshipField) return [e.id, { rows: [], original: [] }]; // not resolved yet - try { - const res = await dataSource.find(d.childObject, { - $filter: { [d.relationshipField]: schema.recordId }, - $top: 500, - }); - const rows = (res?.data ?? []) as Record[]; - return [e.id, { rows: rows.map((r) => ({ ...r })), original: rows.map((r) => ({ ...r })) }]; - } catch { - return [e.id, { rows: [], original: [] }]; - } - }), - ); - // Keyed by entry id, so a collection's loaded rows land in ITS slot - // regardless of where it currently sits in the authored array. - if (!cancelled) setRowState(Object.fromEntries(loaded)); - })(); + heldLinesRereadRef.current.clear(); + linesSavedRef.current.clear(); + if (isEdit && dataSource) { + for (const e of entries) void readLines(e, 'load'); + } return () => { - cancelled = true; + linesReadGenRef.current += 1; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isEdit, dataSource, schema.recordId, resolvedEntries]); + // objectui#10853 — the bus reported a change to a collection's child object. + // A nonce the collection has not seen before is taken as seen: the load + // above reads it. Values, not identities, decide here, so a run for any + // other reason re-reads nothing. + useEffect(() => { + for (const e of entries) { + const obj = e.config.childObject; + if (!obj || !e.config.relationshipField) continue; + const nonce = linesBusNonces[obj] ?? 0; + const seen = linesBusSeenRef.current[e.id]; + linesBusSeenRef.current[e.id] = nonce; + if (seen === undefined || seen === nonce) continue; + void readLines(e, 'bus'); + } + }, [linesBusNonces, entries, readLines]); + + // objectui#10853 — a held re-read is replayed once its collection's lines + // read as saved again (a revert), or once this form's save has written them. + // Not while a save is in flight: the lines take no input then, and its + // outcome decides. Never while the row editor is open on the collection: it + // runs here again when the editor closes (Apply or cancel). + useEffect(() => { + if (saving) return; + const held = heldLinesRereadRef.current; + for (const e of entries) { + if (!held.has(e.id)) continue; + if (rowEditorEntryRef.current === e.id) continue; + if (!linesSavedRef.current.has(e.id) && unsavedLines(e)) continue; + held.delete(e.id); + void readLines(e, 'replay'); + } + linesSavedRef.current.clear(); + }, [rowState, saving, expanded, entries, readLines, unsavedLines]); + const setRows = useCallback((entryId: string, rows: Record[]) => { setRowState((prev) => ({ ...prev, @@ -1007,13 +1241,6 @@ export const MasterDetailForm: React.FC = ({ // (which scrapes the header record) and drives the Subtotal / Tax / Total stack. const taxRateField = schema.taxRateField || 'tax_rate'; - // Per-row "expand to full form": opens the child's complete form (all business - // fields, incl. rich types the grid omits) in a drawer, pre-filled with the - // row. Saving writes back into the in-memory row — the atomic batch still - // persists everything on the parent Save (no separate backend write here). - // `isNew` marks a row created by "Add" in list/form mode — cancelling the - // editor without applying discards that empty row. - const [expanded, setExpanded] = useState<{ entryId: string; rowIdx: number; isNew?: boolean } | null>(null); const expandedRow = expanded ? rowState[expanded.entryId]?.rows?.[expanded.rowIdx] : undefined; const expandedDetail = expanded ? entries.find((e) => e.id === expanded.entryId)?.config : undefined; @@ -1193,6 +1420,10 @@ export const MasterDetailForm: React.FC = ({ // `handleSaved` empties the rows for the next entry. if (editDetails) { const saved = childRowsAfterSave(ops, res?.results, editDetails); + // objectui#10853 — these lines are now what the server holds, so a bus + // re-read held behind them (this save's own echo included) is replayed + // once the save has settled. + for (const s of saved) linesSavedRef.current.add(s.entryId); setRowState((prev) => { let next = prev; for (const s of saved) {