Skip to content

Commit e339d60

Browse files
os-zhuangclaude
andauthored
fix(plugin-form): swapping recordId no longer leaves the previous record on screen (#3005)
`loading` in ModalForm / DrawerForm / TabbedForm / SplitForm was only ever set `true` once, by `useState(true)`, and thereafter only ever set `false`. A `recordId` change therefore re-entered the fetch effect WITHOUT going back through the loading branch: the form stayed mounted showing — and accepting edits to — record A's values, with nothing indicating a different record had been asked for, until B's response landed and replaced them in place. Anything typed in that window read as A's on screen and would have been submitted against B. The same effect had no staleness guard either, so two overlapping reads landed in COMPLETION order rather than request order: ask for B then C, and a slow B arriving last left the form showing B while the caller had asked for C. Both are the same defect from the user's side — the form displays a record nobody asked for — so both are fixed: - a change of record re-enters the loading state before the read, so the previous record is off screen while the next one is in flight. Gated on the record actually changing: the effect also re-runs on `initialData`/`initialValues` identity churn (callers rebuild those objects every render), and flashing the loading state for that would thrash; - the effect's cleanup marks its read stale, so a response that is no longer the one being awaited is dropped instead of overwriting a newer record. ObjectForm already re-entered loading before its fetch, which is why this only ever reproduced on the four sectioned variants. Also fixed, a consequence of the above: hiding the form unmounts the inner renderer, and that renderer is the only thing that reports dirtiness via `onDirtyChange` — it gets no chance to report `false` on the way out. Without clearing the flag, the overlay's unsaved-input guards (#2998) would stay armed for input belonging to a record no longer on screen: a plain refresh would prompt, and closing would offer to discard nothing. Verified by mutation — dropping only that line fails the two guard tests with `expected true to be false`. New `recordSwapLoading.test.tsx` drives each container through a real record swap with a `findOne` the test resolves by id, so both the stale-record and the out-of-order cases are deterministic rather than timing-dependent; all 11 fail against the previous code (8 of them on the two core assertions). Verified: `npx vitest run packages/plugin-form/ packages/components/ packages/app-shell/` — 315 files / 2627 tests pass; `@object-ui/plugin-form` type-check clean, lint 0 errors (the 2 unused-disable warnings are pre-existing, in EmbeddableForm/MasterDetailForm). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 8864971 commit e339d60

6 files changed

Lines changed: 358 additions & 8 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@object-ui/plugin-form": patch
3+
---
4+
5+
fix(plugin-form): swapping `recordId` no longer leaves the previous record on screen
6+
7+
`loading` in `ModalForm` / `DrawerForm` / `TabbedForm` / `SplitForm` was only ever
8+
set `true` once, by `useState(true)`, and thereafter only ever set `false`. A
9+
`recordId` change therefore re-entered the fetch effect **without** going back
10+
through the loading branch: the form stayed mounted showing — and accepting edits
11+
to — record A's values, with nothing indicating a different record had been asked
12+
for, until B's response landed and replaced them in place. Anything typed in that
13+
window read as A's on screen and would have been submitted against B.
14+
15+
The same effect had no staleness guard either, so two overlapping reads landed in
16+
**completion** order rather than request order: ask for B then C, and a slow B
17+
arriving last left the form showing B while the caller had asked for C.
18+
19+
Both are the same defect from the user's side — the form displays a record nobody
20+
asked for — so both are fixed:
21+
22+
- a change of record re-enters the loading state before the read, so the previous
23+
record is off screen while the next one is in flight. Gated on the record
24+
actually changing: the effect also re-runs on `initialData`/`initialValues`
25+
identity churn (callers rebuild those objects every render), and flashing the
26+
loading state for that would thrash;
27+
- the effect's cleanup marks its read stale, so a response that is no longer the
28+
one being awaited is dropped instead of overwriting a newer record.
29+
30+
`ObjectForm` already re-entered loading before its fetch, which is why this only
31+
ever reproduced on the four sectioned variants.
32+
33+
**Also fixed, a consequence of the above:** hiding the form unmounts the inner
34+
renderer, and that renderer is the only thing that reports dirtiness via
35+
`onDirtyChange` — it gets no chance to report `false` on the way out. Without
36+
clearing the flag, the overlay's unsaved-input guards would stay armed for input
37+
belonging to a record no longer on screen: a plain refresh would prompt, and
38+
closing would offer to discard nothing.

packages/plugin-form/src/DrawerForm.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,24 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
216216
fetchSchema();
217217
}, [schema.objectName, dataSource]);
218218

219+
// The record whose data `formData` currently holds. The fetch effect reads it
220+
// to tell a genuine record SWAP from a re-run of its own making —
221+
// `initialData`/`initialValues` are objects callers commonly rebuild every
222+
// render, and flashing the loading state for those would thrash.
223+
const loadedRecordIdRef = useRef<string | number | undefined>(undefined);
224+
219225
// Fetch initial data
220226
useEffect(() => {
227+
// A `recordId` change re-enters this effect with the form still MOUNTED on
228+
// the previous record, which needs handling on two fronts (pinned by
229+
// recordSwapLoading.test.tsx):
230+
// - go back to the loading state, so record A's values are not left on
231+
// screen AND EDITABLE while B is in flight, to be swapped underneath in
232+
// place when it lands. Anything typed there read as A's on screen but
233+
// would have been submitted against B.
234+
// - ignore a response that is no longer the one being awaited, so two
235+
// overlapping reads land in REQUEST order, not completion order.
236+
let cancelled = false;
221237
const fetchData = async () => {
222238
if (schema.mode === 'create' || !schema.recordId) {
223239
setFormData(schema.initialData || schema.initialValues || {});
@@ -231,19 +247,33 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
231247
return;
232248
}
233249

250+
// Only a change of RECORD hides the form. Hiding it UNMOUNTS the inner
251+
// renderer, which is the only thing that reports dirtiness — it cannot
252+
// emit a final `onDirtyChange(false)` on its way out, so clear the flag
253+
// here or the close/unload guards stay armed for input that belongs to a
254+
// record no longer on screen.
255+
if (loadedRecordIdRef.current !== schema.recordId) {
256+
setLoading(true);
257+
setIsDirty(false);
258+
}
259+
234260
try {
235261
const data = await dataSource.findOne(schema.objectName, schema.recordId);
262+
if (cancelled) return;
263+
loadedRecordIdRef.current = schema.recordId;
236264
setFormData(data || {});
237265
} catch (err) {
266+
if (cancelled) return;
238267
setError(err as Error);
239268
} finally {
240-
setLoading(false);
269+
if (!cancelled) setLoading(false);
241270
}
242271
};
243272

244273
if (objectSchema || !dataSource) {
245274
fetchData();
246275
}
276+
return () => { cancelled = true; };
247277
}, [objectSchema, schema.mode, schema.recordId, schema.initialData, schema.initialValues, dataSource, schema.objectName]);
248278

249279
// Build form fields from section config

packages/plugin-form/src/ModalForm.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,8 +290,24 @@ export const ModalForm: React.FC<ModalFormProps> = ({
290290
fetchSchema();
291291
}, [schema.objectName, dataSource]);
292292

293+
// The record whose data `formData` currently holds. The fetch effect reads it
294+
// to tell a genuine record SWAP from a re-run of its own making —
295+
// `initialData`/`initialValues` are objects callers commonly rebuild every
296+
// render, and flashing the loading state for those would thrash.
297+
const loadedRecordIdRef = useRef<string | number | undefined>(undefined);
298+
293299
// Fetch initial data
294300
useEffect(() => {
301+
// A `recordId` change re-enters this effect with the form still MOUNTED on
302+
// the previous record, which needs handling on two fronts (pinned by
303+
// recordSwapLoading.test.tsx):
304+
// - go back to the loading state, so record A's values are not left on
305+
// screen AND EDITABLE while B is in flight, to be swapped underneath in
306+
// place when it lands. Anything typed there read as A's on screen but
307+
// would have been submitted against B.
308+
// - ignore a response that is no longer the one being awaited, so two
309+
// overlapping reads land in REQUEST order, not completion order.
310+
let cancelled = false;
295311
const fetchData = async () => {
296312
if (schema.mode === 'create' || !schema.recordId) {
297313
setFormData(schema.initialData || schema.initialValues || {});
@@ -305,19 +321,33 @@ export const ModalForm: React.FC<ModalFormProps> = ({
305321
return;
306322
}
307323

324+
// Only a change of RECORD hides the form. Hiding it UNMOUNTS the inner
325+
// renderer, which is the only thing that reports dirtiness — it cannot
326+
// emit a final `onDirtyChange(false)` on its way out, so clear the flag
327+
// here or the close/unload guards stay armed for input that belongs to a
328+
// record no longer on screen.
329+
if (loadedRecordIdRef.current !== schema.recordId) {
330+
setLoading(true);
331+
setIsDirty(false);
332+
}
333+
308334
try {
309335
const data = await dataSource.findOne(schema.objectName, schema.recordId);
336+
if (cancelled) return;
337+
loadedRecordIdRef.current = schema.recordId;
310338
setFormData(data || {});
311339
} catch (err) {
340+
if (cancelled) return;
312341
setError(err as Error);
313342
} finally {
314-
setLoading(false);
343+
if (!cancelled) setLoading(false);
315344
}
316345
};
317346

318347
if (objectSchema || !dataSource) {
319348
fetchData();
320349
}
350+
return () => { cancelled = true; };
321351
}, [objectSchema, schema.mode, schema.recordId, schema.initialData, schema.initialValues, dataSource, schema.objectName]);
322352

323353
// Build form fields from section config

packages/plugin-form/src/SplitForm.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* Aligns with @objectstack/spec FormView type: 'split'
1515
*/
1616

17-
import React, { useState, useCallback, useEffect, useMemo } from 'react';
17+
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
1818
import type { FormField, DataSource } from '@object-ui/types';
1919
import {
2020
ResizablePanelGroup,
@@ -114,8 +114,24 @@ export const SplitForm: React.FC<SplitFormProps> = ({
114114
fetchSchema();
115115
}, [schema.objectName, dataSource]);
116116

117+
// The record whose data `formData` currently holds. The fetch effect reads it
118+
// to tell a genuine record SWAP from a re-run of its own making —
119+
// `initialData`/`initialValues` are objects callers commonly rebuild every
120+
// render, and flashing the loading state for those would thrash.
121+
const loadedRecordIdRef = useRef<string | number | undefined>(undefined);
122+
117123
// Fetch initial data
118124
useEffect(() => {
125+
// A `recordId` change re-enters this effect with the form still MOUNTED on
126+
// the previous record, which needs handling on two fronts (pinned by
127+
// recordSwapLoading.test.tsx):
128+
// - go back to the loading state, so record A's values are not left on
129+
// screen AND EDITABLE while B is in flight, to be swapped underneath in
130+
// place when it lands. Anything typed there read as A's on screen but
131+
// would have been submitted against B.
132+
// - ignore a response that is no longer the one being awaited, so two
133+
// overlapping reads land in REQUEST order, not completion order.
134+
let cancelled = false;
119135
const fetchData = async () => {
120136
if (schema.mode === 'create' || !schema.recordId) {
121137
setFormData(schema.initialData || schema.initialValues || {});
@@ -129,19 +145,26 @@ export const SplitForm: React.FC<SplitFormProps> = ({
129145
return;
130146
}
131147

148+
// Only a change of RECORD hides the form.
149+
if (loadedRecordIdRef.current !== schema.recordId) setLoading(true);
150+
132151
try {
133152
const data = await dataSource.findOne(schema.objectName, schema.recordId);
153+
if (cancelled) return;
154+
loadedRecordIdRef.current = schema.recordId;
134155
setFormData(data || {});
135156
} catch (err) {
157+
if (cancelled) return;
136158
setError(err as Error);
137159
} finally {
138-
setLoading(false);
160+
if (!cancelled) setLoading(false);
139161
}
140162
};
141163

142164
if (objectSchema || !dataSource) {
143165
fetchData();
144166
}
167+
return () => { cancelled = true; };
145168
}, [objectSchema, schema.mode, schema.recordId, schema.initialData, schema.initialValues, dataSource, schema.objectName]);
146169

147170
// Build form fields from section config

packages/plugin-form/src/TabbedForm.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* Aligns with @objectstack/spec FormView type: 'tabbed'
1414
*/
1515

16-
import React, { useState, useCallback } from 'react';
16+
import React, { useState, useCallback, useRef } from 'react';
1717
import type { FormField, DataSource } from '@object-ui/types';
1818
import { cn } from '@object-ui/components';
1919
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
@@ -210,28 +210,51 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
210210
fetchSchema();
211211
}, [schema.objectName, dataSource]);
212212

213+
// The record whose data `formData` currently holds. The fetch effect reads it
214+
// to tell a genuine record SWAP from a re-run of its own making —
215+
// `initialData`/`initialValues` are objects callers commonly rebuild every
216+
// render, and flashing the loading state for those would thrash.
217+
const loadedRecordIdRef = useRef<string | number | undefined>(undefined);
218+
213219
// Fetch initial data for edit/view modes
214220
React.useEffect(() => {
221+
// A `recordId` change re-enters this effect with the form still MOUNTED on
222+
// the previous record, which needs handling on two fronts (pinned by
223+
// recordSwapLoading.test.tsx):
224+
// - go back to the loading state, so record A's values are not left on
225+
// screen AND EDITABLE while B is in flight, to be swapped underneath in
226+
// place when it lands. Anything typed there read as A's on screen but
227+
// would have been submitted against B.
228+
// - ignore a response that is no longer the one being awaited, so two
229+
// overlapping reads land in REQUEST order, not completion order.
230+
let cancelled = false;
215231
const fetchData = async () => {
216232
if (schema.mode === 'create' || !schema.recordId || !dataSource) {
217233
setFormData(schema.initialData || schema.initialValues || {});
218234
setLoading(false);
219235
return;
220236
}
221-
237+
238+
// Only a change of RECORD hides the form.
239+
if (loadedRecordIdRef.current !== schema.recordId) setLoading(true);
240+
222241
try {
223242
const data = await dataSource.findOne(schema.objectName, schema.recordId);
243+
if (cancelled) return;
244+
loadedRecordIdRef.current = schema.recordId;
224245
setFormData(data || {});
225246
} catch (err) {
247+
if (cancelled) return;
226248
setError(err as Error);
227249
} finally {
228-
setLoading(false);
250+
if (!cancelled) setLoading(false);
229251
}
230252
};
231-
253+
232254
if (objectSchema || !dataSource) {
233255
fetchData();
234256
}
257+
return () => { cancelled = true; };
235258
}, [objectSchema, schema.mode, schema.recordId, schema.initialData, schema.initialValues, dataSource, schema.objectName]);
236259

237260
// Build form fields from section config

0 commit comments

Comments
 (0)