Skip to content

Commit 1900c02

Browse files
fix(console): a child written through an authored related list refreshes the record page's header (#27106)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8QyKhkPtEoqpCPduABeGG56)_ Fixes #26890 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:记录页在子记录写入后重读父记录时,除了按关系推导出的相关列表,现在也盯住作者自己在页面上列出的相关列表块和侧栏引用卡片里的子对象。 - 为什么改:作者自建的记录页可以列出一个外键写了 `relatedList: false` 的子对象,这时它不在推导集合里,经该列表新建、编辑或删除子记录后,表头的汇总值(如报价总额)要刷新页面才会变。 - 风险与回滚:只扩大订阅的子对象集合,最坏多一次父记录读取;推导路径与 12244 的行为不变;回滚即 revert 本 PR。 - 先看:`RecordDetailView.tsx` 中 `relatedChildObjectsKey` 那一段;新文件 `recordPageChildObjects.ts`;`RecordDetailView.authoredChildWriteRefreshesParent-26890.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/RecordDetailView.authoredChildWriteRefreshesParent-26890.test.tsx`,预期 4 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes - New `recordPageChildObjects(page)` beside the view: the child objects a record page lists itself, read from every `record:related_list` block (`properties.objectName`, or the node's `dataSource.object` binding that the block's element data-source gate maps onto `objectName`) and every `record:reference_rail` entry's `objectName`, at any depth of the page. Sorted and deduplicated. - `RecordDetailView`'s child-write subscription (objectui#12244) now watches the union of the derived related lists' children and those, still keyed by content (AGENTS.md #10). Nothing else in the view moves. ## Why objectui#12244 re-reads the parent on a child write, but it watched only the children `deriveRelatedLists` derives from the relationship graph. An authored record page declares its own lists, and a child it lists there need not be derived: its FK can opt out of the derived lists with `relatedList: false`, the authored page then being the only place the child is shown. A create, edit or delete through such a list reached the invalidation bus under the child's name and matched nothing, so the header kept the pre-write rollup until a reload, as the card reports. Reproduced first on `origin/main` `cbb3a1a1` with the new test file: the create, update/delete and rail cases were red (`expected 1 to be 2` parent reads). A one-off control copy of the same file without `relatedList: false` (child derivable from the graph) was green on those three cases on the same tree, so the gap is specific to a child the authored page alone declares. HotCRM's own quote-line metadata was not read here (it lives outside this repository), so whether its FK carries `relatedList: false` is not measured; the fix covers the shape the dispatch named. ## Tests - New `RecordDetailView.authoredChildWriteRefreshesParent-26890.test.tsx` (4 tests, real page + real MutationEvent bridge, a fake backend that recomputes the rollup): a create of a line listed only by the authored related list re-reads the parent once and the header shows the new total in place; an update and a delete each re-read it; a write to a child listed only in the reference rail re-reads it; a write to a child that points at the parent but is listed nowhere does not. - New `recordPageChildObjects.test.ts` (2 tests): every related list and rail entry at any depth including the `dataSource.object` arm, sorted and once; other blocks' objects (`element:chart`, `record:line_items`) and non-string values are not named. - `RecordDetailView.childWriteRefreshesParent-12244.test.tsx` still green (5/5) beside the new file. - Ablation (one-off, not kept): with `, ...authoredChildObjects])` replaced in `RecordDetailView.tsx` via `scripts/ablation-replace.mjs` (anchor 1 → 0, mutation blob landed, restored blob equal to HEAD, `git diff HEAD` empty), the new page file goes 3 failed / 1 passed: create, update/delete and rail red, the scope control green. ## Acceptance notes - The subscription covers what the authored page lists, not every object a page binds: a chart or metadata viewer on the record page is not a child list, and a write to its object does not re-read the record. - `record:line_items` on a record page is not added: its save writes the parent leg too, which the record's own invalidation already catches. ## Verification On head `24529675` (main merged): 24 gates derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, exits captured before any pipe; `check-sdui-manifest` 0. 23 exit 0; `pnpm --dir ui exec vitest run scripts/` exited 1 on three tests in `check-i18n-dead-keys.test.ts` and `check-vi-mock-inherit.test.ts` that timed out at 15 s while another suite shared the box (5031 others passed); those two files rerun alone: 223/223 passed. `--ran` reconciles 24 derived / 24 run / 0 NOT-MEASURED. The whole `RecordDetailView*` suite plus the helper test: 76 files, 505 tests passed. Extra: `pnpm --filter @object-ui/app-shell run type-check` 0, eslint on the four touched files 0 errors (warnings pre-existing, none on touched lines), `pnpm --dir ui run check:new-line-citations` 0 new. `@object-ui/app-shell` is `private: true`, so `skip-changeset`. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbZdCt7JoRJa32i5BHs)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 17c34b8 commit 1900c02

4 files changed

Lines changed: 500 additions & 3 deletions

File tree

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#26890 — a child write through a related list the record page's
11+
* AUTHOR declared refreshes the parent record's header, in place.
12+
*
13+
* The reported reach (HotCRM on 18.2.0, quote QTE-0007 → Related → Quote Line
14+
* Items → New → Create): the line-item hook rolled the total onto the quote,
15+
* the server held the new total, and the header kept the old one until a
16+
* reload.
17+
*
18+
* objectui#12244 already re-reads the parent on a child write, but only for the
19+
* children the record page DERIVES from the relationship graph
20+
* (`deriveRelatedLists`). An authored record page declares its own related
21+
* lists — `record:related_list` blocks and `record:reference_rail` entries —
22+
* and a child it lists there need not be one the graph derives: its FK may opt
23+
* out of the derived lists (`relatedList: false`), the page then being the only
24+
* place that child is shown. A write to such a child reached the bus under the
25+
* child's name and matched nothing, so the parent row stayed as first loaded.
26+
*
27+
* What this file pins, through the real page and the real MutationEvent bridge:
28+
*
29+
* 1. A create, an update and a delete of a child the authored page lists in a
30+
* `record:related_list` block each re-read the parent once, and the
31+
* header shows the server's recomputed total.
32+
* 2. The same for a child listed only in a `record:reference_rail` entry.
33+
* 3. Scope stays tight: a write to an object the page does not list and the
34+
* graph does not derive does not re-read the parent.
35+
*
36+
* The fake backend recomputes the rollup itself, as the server's hook does, so
37+
* the figure the header shows can only have come from a fresh read of the
38+
* parent.
39+
*/
40+
41+
import * as React from 'react';
42+
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
43+
import { render, screen, waitFor, cleanup, act } from '@testing-library/react';
44+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
45+
import { MetadataCtx, useMutationInvalidationBridge } from '@object-ui/react';
46+
import { RelatedCountStore } from '@object-ui/components';
47+
import type { DataSourceMutationEvent } from '@object-ui/types';
48+
49+
vi.mock('@object-ui/auth', async (importOriginal) => ({
50+
...(await importOriginal<Record<string, unknown>>()),
51+
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
52+
createAuthenticatedFetch: () => vi.fn(),
53+
}));
54+
55+
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
56+
...(await importOriginal<Record<string, unknown>>()),
57+
useRecordPresence: () => [],
58+
PresenceAvatars: () => null,
59+
}));
60+
61+
vi.mock('sonner', () => ({
62+
toast: Object.assign(vi.fn(), {
63+
success: vi.fn(),
64+
error: vi.fn(),
65+
info: vi.fn(),
66+
warning: vi.fn(),
67+
loading: vi.fn(),
68+
dismiss: vi.fn(),
69+
}),
70+
}));
71+
72+
// Orthogonal chrome — same posture as RecordDetailView.childWriteRefreshesParent-12244.
73+
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
74+
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
75+
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
76+
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
77+
vi.mock('./MetadataInspector', () => ({
78+
MetadataPanel: () => null,
79+
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
80+
}));
81+
82+
import { RecordDetailView } from './RecordDetailView';
83+
84+
beforeAll(() => {
85+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
86+
});
87+
88+
const APP = 'crm';
89+
const PARENT = 'os_26890_quote';
90+
/** Listed in a `record:related_list` block; its FK opts out of the derived lists. */
91+
const LINE = 'os_26890_quote_line';
92+
/** Listed only in the rail; its FK opts out of the derived lists too. */
93+
const NOTE = 'os_26890_quote_note';
94+
/** Points at the parent with `relatedList: false` and appears nowhere on the page. */
95+
const AUDIT = 'os_26890_quote_audit';
96+
const RECORD_ID = 'q-7';
97+
98+
const parentObject = {
99+
name: PARENT,
100+
label: 'Quote',
101+
fields: {
102+
id: { type: 'text', label: 'Id' },
103+
name: { type: 'text', label: 'Name' },
104+
total_price: { type: 'number', label: 'Total' },
105+
},
106+
};
107+
108+
const childOf = (name: string, label: string) => ({
109+
name,
110+
label,
111+
fields: {
112+
id: { type: 'text', label: 'Id' },
113+
name: { type: 'text', label: 'Name' },
114+
quantity: { type: 'number', label: 'Quantity' },
115+
unit_price: { type: 'number', label: 'Unit Price' },
116+
quote: { type: 'master_detail', reference: PARENT, label: 'Quote', relatedList: false },
117+
},
118+
});
119+
120+
const OBJECTS = [parentObject, childOf(LINE, 'Quote Line'), childOf(NOTE, 'Quote Note'), childOf(AUDIT, 'Quote Audit')];
121+
122+
/** The quote's authored record page, reduced to the blocks this card is about. */
123+
const RECORD_PAGE = {
124+
name: 'os_26890_quote_detail',
125+
label: 'Quote',
126+
type: 'record',
127+
object: PARENT,
128+
kind: 'full',
129+
template: 'default',
130+
isDefault: true,
131+
regions: [
132+
{
133+
name: 'main',
134+
width: 'large',
135+
components: [
136+
{ type: 'page:header', properties: { title: '{name}' } },
137+
{ type: 'record:details', properties: { sections: [{ name: 'o', label: 'Overview', fields: ['total_price'] }] } },
138+
{
139+
type: 'record:related_list',
140+
id: 'quote_lines',
141+
properties: { objectName: LINE, relationshipField: 'quote', columns: ['name', 'quantity'], limit: 10 },
142+
},
143+
],
144+
},
145+
{
146+
name: 'aside',
147+
width: 'small',
148+
components: [
149+
{
150+
type: 'record:reference_rail',
151+
id: 'quote_rail',
152+
properties: { entries: [{ objectName: NOTE, relationshipField: 'quote', limit: 3 }] },
153+
},
154+
],
155+
},
156+
],
157+
};
158+
159+
/**
160+
* A backend that owns the rollup: every write to a line recomputes the
161+
* parent's `total_price` server-side; the client only learns the new figure by
162+
* reading the parent again. Writes are announced through `onMutation`, the
163+
* event the console's MutationEvent bridge fans out onto the bus — the create
164+
* the record-form overlay makes included.
165+
*/
166+
function makeBackend() {
167+
const lines = [
168+
{ id: 'l-1', name: 'Seat licence', quantity: 3, unit_price: 2000, quote: RECORD_ID },
169+
{ id: 'l-2', name: 'Onboarding', quantity: 1, unit_price: 3000, quote: RECORD_ID },
170+
];
171+
const rollup = () => lines.reduce((sum, l) => sum + l.quantity * l.unit_price, 0);
172+
const listeners = new Set<(e: DataSourceMutationEvent) => void>();
173+
const emit = (e: DataSourceMutationEvent) => {
174+
for (const l of Array.from(listeners)) l(e);
175+
};
176+
const ds = {
177+
find: vi.fn(async (objectName: string) => {
178+
if (objectName !== LINE) return { data: [], total: 0 };
179+
return { data: lines.map((l) => ({ ...l })), total: lines.length };
180+
}),
181+
findOne: vi.fn(async (objectName: string, id: string) => {
182+
if (objectName !== PARENT || id !== RECORD_ID) return null;
183+
return { id: RECORD_ID, name: 'QTE-0007', total_price: rollup() };
184+
}),
185+
create: vi.fn(async (objectName: string, row: { name: string; quantity: number; unit_price: number }) => {
186+
const created = { id: `l-${lines.length + 1}`, quote: RECORD_ID, ...row };
187+
if (objectName === LINE) lines.push(created);
188+
// The adapter's create event names the resource, not the id.
189+
emit({ type: 'create', resource: objectName, record: created });
190+
return created;
191+
}),
192+
update: vi.fn(async (objectName: string, id: string, patch: { quantity?: number }) => {
193+
if (objectName === LINE) {
194+
const line = lines.find((l) => l.id === id);
195+
if (line) Object.assign(line, patch);
196+
}
197+
emit({ type: 'update', resource: objectName, id });
198+
return { id, ...patch };
199+
}),
200+
delete: vi.fn(async (objectName: string, id: string) => {
201+
if (objectName === LINE) {
202+
const at = lines.findIndex((l) => l.id === id);
203+
if (at >= 0) lines.splice(at, 1);
204+
}
205+
emit({ type: 'delete', resource: objectName, id });
206+
return true;
207+
}),
208+
onMutation: (cb: (e: DataSourceMutationEvent) => void) => {
209+
listeners.add(cb);
210+
return () => listeners.delete(cb);
211+
},
212+
};
213+
return { ds, rollup };
214+
}
215+
216+
type FakeDataSource = ReturnType<typeof makeBackend>['ds'];
217+
218+
/** The console mounts the MutationEvent bridge once, in `AppContent`. */
219+
function ConsoleHost({ dataSource, children }: { dataSource: unknown; children: React.ReactNode }) {
220+
useMutationInvalidationBridge(dataSource);
221+
return <>{children}</>;
222+
}
223+
224+
function renderPage(ds: FakeDataSource) {
225+
const metadata = {
226+
objects: OBJECTS,
227+
pages: [RECORD_PAGE],
228+
loading: false,
229+
error: null,
230+
refresh: async () => {},
231+
invalidate: () => {},
232+
ensureType: async () => [],
233+
getItem: async () => null,
234+
getItemsByType: () => [],
235+
} as unknown as React.ContextType<typeof MetadataCtx>;
236+
return render(
237+
<MemoryRouter initialEntries={[`/apps/${APP}/${PARENT}/record/${RECORD_ID}`]}>
238+
<MetadataCtx.Provider value={metadata}>
239+
<ConsoleHost dataSource={ds}>
240+
<Routes>
241+
<Route
242+
path="/apps/:appName/:objectName/record/:recordId"
243+
element={<RecordDetailView dataSource={ds} objects={OBJECTS} onEdit={() => {}} />}
244+
/>
245+
</Routes>
246+
</ConsoleHost>
247+
</MetadataCtx.Provider>
248+
</MemoryRouter>,
249+
);
250+
}
251+
252+
/** Reads of the PARENT record — the read every header block draws from. */
253+
const parentReads = (ds: FakeDataSource) =>
254+
ds.findOne.mock.calls.filter(([objectName, id]) => objectName === PARENT && id === RECORD_ID).length;
255+
256+
/** Is `figure` (e.g. 9000) on screen, in any grouping the number formatter picks? */
257+
const figureShown = (figure: number) => {
258+
const text = document.body.textContent ?? '';
259+
return text.includes(String(figure)) || text.includes(figure.toLocaleString('en-US'));
260+
};
261+
262+
/** Let any refetch a change could have scheduled land before reading. */
263+
const settle = () => act(async () => {
264+
await new Promise((r) => setTimeout(r, 50));
265+
});
266+
267+
async function settledPage() {
268+
const backend = makeBackend();
269+
renderPage(backend.ds);
270+
await waitFor(() => expect(parentReads(backend.ds)).toBe(1));
271+
await screen.findByText('Seat licence');
272+
await waitFor(() => expect(figureShown(backend.rollup())).toBe(true));
273+
return backend;
274+
}
275+
276+
beforeEach(() => {
277+
cleanup();
278+
RelatedCountStore._reset();
279+
vi.stubGlobal(
280+
'fetch',
281+
vi.fn(async () =>
282+
new Response(JSON.stringify({ data: [] }), {
283+
status: 200,
284+
headers: { 'content-type': 'application/json' },
285+
}),
286+
),
287+
);
288+
});
289+
290+
afterEach(() => {
291+
vi.unstubAllGlobals();
292+
vi.restoreAllMocks();
293+
});
294+
295+
describe('RecordDetailView — a child write through an AUTHORED related list refreshes the parent (objectstack#26890)', () => {
296+
it('a create of a line the authored related list shows re-reads the parent once and shows the recomputed total', async () => {
297+
const { ds, rollup } = await settledPage();
298+
expect(rollup()).toBe(9000);
299+
const headerBefore = document.querySelector('header');
300+
301+
await act(async () => {
302+
await ds.create(LINE, { name: 'Training', quantity: 2, unit_price: 2700 });
303+
});
304+
305+
expect(rollup()).toBe(14400);
306+
await waitFor(() => expect(parentReads(ds)).toBe(2));
307+
await waitFor(() => expect(figureShown(14400)).toBe(true));
308+
expect(figureShown(9000)).toBe(false);
309+
await settle();
310+
expect(parentReads(ds)).toBe(2);
311+
// In place: the same header node.
312+
expect(document.querySelector('header')).toBe(headerBefore);
313+
});
314+
315+
it('an update and a delete of such a line each re-read the parent', async () => {
316+
const { ds } = await settledPage();
317+
318+
await act(async () => {
319+
await ds.update(LINE, 'l-2', { quantity: 2 });
320+
});
321+
await waitFor(() => expect(parentReads(ds)).toBe(2));
322+
await waitFor(() => expect(figureShown(12000)).toBe(true));
323+
324+
await act(async () => {
325+
await ds.delete(LINE, 'l-1');
326+
});
327+
await waitFor(() => expect(parentReads(ds)).toBe(3));
328+
await waitFor(() => expect(figureShown(6000)).toBe(true));
329+
});
330+
331+
it('a write to a child the page lists only in its reference rail re-reads the parent', async () => {
332+
const { ds } = await settledPage();
333+
334+
await act(async () => {
335+
await ds.create(NOTE, { name: 'Discount approved', quantity: 0, unit_price: 0 });
336+
});
337+
338+
await waitFor(() => expect(parentReads(ds)).toBe(2));
339+
});
340+
341+
it('a write to a child the page does not list and the graph does not derive does not re-read the parent', async () => {
342+
const { ds } = await settledPage();
343+
344+
await act(async () => {
345+
await ds.create(AUDIT, { name: 'Viewed', quantity: 0, unit_price: 0 });
346+
});
347+
await settle();
348+
349+
expect(parentReads(ds)).toBe(1);
350+
});
351+
});

‎ui/packages/app-shell/src/views/RecordDetailView.tsx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { SkeletonDetail } from '../skeletons/index.js';
2525
import { ManagedByBadge, useManagedByNote } from '../components/ManagedByBadge.js';
2626
import { isSetupAppSegment } from '../console/setupApp.js';
2727
import { deriveRelatedLists } from '../utils/deriveRelatedLists.js';
28+
import { recordPageChildObjects } from './recordPageChildObjects.js';
2829
import { stripDiscussionNodes, hasExplicitAttachments, hasExplicitApprovals } from '../utils/pageSchemaIntrospect.js';
2930
import { ActionConfirmDialog, type ConfirmDialogState } from './ActionConfirmDialog.js';
3031
import { ActionParamDialog, type ParamDialogState } from './ActionParamDialog.js';
@@ -1851,13 +1852,19 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
18511852
// knows what it depends on. That covers every child-write path at one seam,
18521853
// and costs one parent read per change, however many lists are mounted.
18531854
//
1854-
// The children are the page's derived related lists, keyed by CONTENT
1855-
// (AGENTS.md #10 — `childRelations` is a memo). A change both subscriptions
1855+
// The children are the page's derived related lists, plus the ones its
1856+
// author lists on it (objectstack#26890): an authored page's
1857+
// `record:related_list` blocks and `record:reference_rail` entries may name a
1858+
// child the graph does not derive — its FK opted out with `relatedList:
1859+
// false`, the page being the only place it is shown — and a create through
1860+
// that list left the header's total stale. Keyed by CONTENT (AGENTS.md #10 —
1861+
// `childRelations` and the page are memos). A change both subscriptions
18561862
// match — a `'*'`, or this record when the object references itself — bumps
18571863
// both nonces from one dispatch; React batches the two updates into one
18581864
// render, so the row is still read once.
1865+
const authoredChildObjects = useMemo(() => recordPageChildObjects(effectivePage), [effectivePage]);
18591866
const relatedChildObjectsKey = JSON.stringify(
1860-
Array.from(new Set(childRelations.map((r) => r.childObject))).sort(),
1867+
Array.from(new Set([...childRelations.map((r) => r.childObject), ...authoredChildObjects])).sort(),
18611868
);
18621869
useEffect(() => {
18631870
const childObjects = JSON.parse(relatedChildObjectsKey) as string[];

0 commit comments

Comments
 (0)