Skip to content

Commit e72223f

Browse files
committed
fix(studio): the Data rail sends an edit inside the autosave delay before it switches objects (#27511)
An edit made less than 1.5 s before a click on another object was dropped: the autosave's timer sends nothing once the open object has changed. The rail's switch now sends it with the autosave's own flush and opens the next object once the save has landed, waits out a save in flight, and asks before it drops an edit that cannot be sent. Pins the switch in DataPillar.railSwitchFlush-27511 and moves the objectui#11232 Data control to the new behaviour. Claude-Session: https://claude.ai/code/session_01Utp6CbCrKUjGeTReNEmUJx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 59a0510 commit e72223f

3 files changed

Lines changed: 277 additions & 5 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(studio): switching objects in the Data pillar's rail sends an edit made inside the autosave delay instead of dropping it (#27511)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The Studio autosave sends a draft 1.5 s after the last edit, and sends nothing once another object has been opened. An edit made less than 1.5 s before a click on another object in the Data pillar's rail (a field removed, a label renamed) was therefore dropped, with nothing on screen, and was gone after a reload. The switch now sends that edit to its own object first and opens the next object once the save has landed; a save already in flight is waited out the same way. An edit that cannot be sent (its save was refused, or Studio holds it as unfinished) is not dropped silently: the switch asks first, and declining keeps the open object and its edit.
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27511 — an object switch in the Data pillar's rail never drops
5+
* an edit still inside the autosave's debounce.
6+
*
7+
* The autosave sends 1.5 s after the last edit, and its timer sends nothing
8+
* once the open object has changed (objectui#11232): the rail's switch reloads
9+
* the one buffer the edit lives in. So an edit made less than 1.5 s before a
10+
* click on another object was dropped with nothing on screen, and reverted on
11+
* the next visit. The switch now sends the pending edit first with the
12+
* autosave's own `flush` and opens the next object once its save has landed; a
13+
* save already in flight is waited out the same way; an edit that cannot be
14+
* sent (its save was refused) is the author's to drop, and the switch asks.
15+
*
16+
* The client is a server double that records every save and can hold or
17+
* refuse one; the records grid is a double that draws only its "+" column.
18+
*/
19+
20+
import '@testing-library/jest-dom/vitest';
21+
import * as React from 'react';
22+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
23+
// Module scope as well as in the `vi.mock` factory below: the cold transform is paid
24+
// during collection, which no test or hook timeout clocks (AGENTS.md, flaky-test discipline).
25+
import '@object-ui/components';
26+
import { render, screen, fireEvent, cleanup, waitFor, act } from '@testing-library/react';
27+
import { MemoryRouter } from 'react-router-dom';
28+
29+
const PKG = 'com.acme.app';
30+
31+
const TASK = {
32+
name: 'acme_task',
33+
label: 'Task',
34+
fields: [
35+
{ name: 'title', label: 'Title', type: 'text' },
36+
{ name: 'status', label: 'Status', type: 'text' },
37+
],
38+
};
39+
const NOTE = { name: 'acme_note', label: 'Note', fields: [{ name: 'body', label: 'Body', type: 'text' }] };
40+
41+
const server = vi.hoisted(() => ({
42+
active: new Map<string, Record<string, unknown>>(),
43+
drafts: new Map<string, Record<string, unknown>>(),
44+
/** Every save, as the server received it. */
45+
saves: [] as Array<{ name: string; body: Record<string, unknown> }>,
46+
/** While set, the next save waits on it (a save held in flight). */
47+
saveHold: null as Promise<void> | null,
48+
/** While true, every save is refused. */
49+
refuse: false,
50+
}));
51+
52+
// A server double: a save lands as the object's draft, and a later load serves it.
53+
const mockClient = vi.hoisted(() => ({
54+
list: vi.fn(async () => [...server.active.values()].map((row) => ({ name: row.name, label: row.label }))),
55+
listDrafts: vi.fn(async () => []),
56+
listTypes: vi.fn(async () => ({ entries: [] })),
57+
get: vi.fn(async () => null),
58+
withPreviewDrafts: vi.fn((): unknown => mockClient),
59+
references: vi.fn(async () => []),
60+
layered: vi.fn(async (_type: string, name: string) => {
61+
const eff = server.active.get(name) ?? null;
62+
return { code: null, overlay: eff, overlayScope: eff ? 'env' : null, effective: eff, editable: true, deletable: true, resettable: false, lock: 'none' };
63+
}),
64+
getDraft: vi.fn(async (type: string, name: string) => {
65+
const draft = server.drafts.get(name);
66+
if (draft) return { item: JSON.parse(JSON.stringify(draft)) as Record<string, unknown> };
67+
throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 });
68+
}),
69+
save: vi.fn(async (_type: string, name: string, item: unknown) => {
70+
const body = JSON.parse(JSON.stringify(item)) as Record<string, unknown>;
71+
server.saves.push({ name, body });
72+
const hold = server.saveHold;
73+
server.saveHold = null;
74+
if (hold) await hold;
75+
if (server.refuse) throw Object.assign(new Error('The server refused this draft.'), { status: 500 });
76+
server.drafts.set(name, body);
77+
return { type: 'object', name, item };
78+
}),
79+
}));
80+
81+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
82+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
83+
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ loading: false, error: null, entries: [] }) };
84+
});
85+
86+
vi.mock('./packages-io', async (importOriginal) => {
87+
const mod = await importOriginal<typeof import('./packages-io')>();
88+
return { ...mod, fetchPackages: vi.fn(async () => [{ id: PKG, name: 'Acme', writable: true, namespace: 'acme' }]) };
89+
});
90+
91+
vi.mock('@object-ui/react', async (importOriginal) => {
92+
const mod = await importOriginal<typeof import('@object-ui/react')>();
93+
return { ...mod, useAdapter: () => dataSource };
94+
});
95+
96+
// The records grid: only its "+" column, drawn from the grid's authoring
97+
// context the way the real grid draws it (objectui#12511).
98+
vi.mock('@object-ui/plugin-view', async (importOriginal) => {
99+
const mod = await importOriginal<Record<string, unknown>>();
100+
const { useGridFieldAuthoring } = await import('@object-ui/components');
101+
function GridDouble() {
102+
const authoring = useGridFieldAuthoring();
103+
return authoring?.onAddColumn ? (
104+
<button type="button" data-testid="grid-add-column" onClick={authoring.onAddColumn}>
105+
Add column
106+
</button>
107+
) : null;
108+
}
109+
return { ...mod, ObjectView: GridDouble };
110+
});
111+
112+
vi.mock('sonner', () => ({
113+
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), dismiss: vi.fn() },
114+
Toaster: () => null,
115+
}));
116+
117+
import { DataPillar } from './StudioDesignSurface';
118+
import { addFieldFromGrid } from './__tests__/dataPillarAddField';
119+
import { createEmptyDataSource } from './__tests__/emptyDataSource';
120+
import { readFields } from '../metadata-admin/previews/object-fields-io';
121+
122+
const dataSource = createEmptyDataSource();
123+
124+
window.matchMedia = ((query: string) => ({
125+
matches: false,
126+
media: query,
127+
onchange: null,
128+
addEventListener: () => {},
129+
removeEventListener: () => {},
130+
addListener: () => {},
131+
removeListener: () => {},
132+
dispatchEvent: () => false,
133+
})) as unknown as typeof window.matchMedia;
134+
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
135+
(globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver ??
136+
class {
137+
observe() {}
138+
unobserve() {}
139+
disconnect() {}
140+
};
141+
142+
// This environment ships no window.confirm: the pillar's discard question.
143+
let confirmSpy: ReturnType<typeof vi.fn>;
144+
145+
beforeEach(() => {
146+
server.active.clear();
147+
server.drafts.clear();
148+
server.saves.length = 0;
149+
server.saveHold = null;
150+
server.refuse = false;
151+
server.active.set(TASK.name, JSON.parse(JSON.stringify(TASK)));
152+
server.active.set(NOTE.name, JSON.parse(JSON.stringify(NOTE)));
153+
confirmSpy = vi.fn(() => false);
154+
window.confirm = confirmSpy as unknown as typeof window.confirm;
155+
vi.stubGlobal(
156+
'fetch',
157+
vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })),
158+
);
159+
});
160+
161+
afterEach(() => {
162+
cleanup();
163+
vi.unstubAllGlobals();
164+
});
165+
166+
const SLOW = { timeout: 8000 };
167+
168+
const fieldNamesOf = (body: Record<string, unknown>) => readFields(body.fields).entries.map((e) => e.name);
169+
const savesRead = () => server.saves.map((s) => [s.name, fieldNamesOf(s.body)]);
170+
const fieldCount = () => screen.getByText(/^\d+ fields$/);
171+
172+
async function openTask(): Promise<void> {
173+
render(
174+
<MemoryRouter initialEntries={[`/studio/${PKG}/data`]}>
175+
<DataPillar packageId={PKG} />
176+
</MemoryRouter>,
177+
);
178+
await waitFor(() => expect(fieldCount()).toHaveTextContent('2 fields'), SLOW);
179+
}
180+
181+
/** Waits past the autosave's debounce: what was sent by then is what will be. */
182+
async function pastDebounce(ms = 2500): Promise<void> {
183+
await act(async () => {
184+
await new Promise((r) => setTimeout(r, ms));
185+
});
186+
}
187+
188+
describe('Data pillar — an object switch inside the autosave debounce (objectstack#27511)', () => {
189+
it('sends the pending edit to its own object first, then opens the next one', async () => {
190+
await openTask();
191+
await addFieldFromGrid();
192+
// Inside the debounce: nothing has been sent yet.
193+
expect(server.saves).toEqual([]);
194+
fireEvent.click(screen.getByRole('button', { name: /Note/ }));
195+
196+
await waitFor(() => expect(fieldCount()).toHaveTextContent('1 fields'), SLOW);
197+
await pastDebounce();
198+
// Exactly one save, to Task, carrying the edit; nothing was sent as Note's.
199+
expect(savesRead()).toEqual([['acme_task', ['title', 'status', 'new_field']]]);
200+
expect(confirmSpy).not.toHaveBeenCalled();
201+
202+
// The edit is Task's draft: back on Task, it is there.
203+
fireEvent.click(screen.getByRole('button', { name: /Task/ }));
204+
await waitFor(() => expect(fieldCount()).toHaveTextContent('3 fields'), SLOW);
205+
}, 30000);
206+
207+
it('waits out a save in flight, and opens the next object once it lands', async () => {
208+
await openTask();
209+
let release!: () => void;
210+
server.saveHold = new Promise<void>((r) => (release = r));
211+
await addFieldFromGrid();
212+
// The timer sends it, and the save is held in flight.
213+
await waitFor(() => expect(server.saves).toHaveLength(1), SLOW);
214+
215+
fireEvent.click(screen.getByRole('button', { name: /Note/ }));
216+
await pastDebounce(300);
217+
// Still on Task while its save is in flight.
218+
expect(fieldCount()).toHaveTextContent('3 fields');
219+
220+
await act(async () => {
221+
release();
222+
await new Promise((r) => setTimeout(r, 0));
223+
});
224+
await waitFor(() => expect(fieldCount()).toHaveTextContent('1 fields'), SLOW);
225+
await pastDebounce();
226+
expect(savesRead()).toEqual([['acme_task', ['title', 'status', 'new_field']]]);
227+
}, 30000);
228+
229+
it('an edit whose save was refused is not dropped silently: the switch asks, and a declined ask keeps it', async () => {
230+
await openTask();
231+
server.refuse = true;
232+
await addFieldFromGrid();
233+
await waitFor(() => expect(server.saves).toHaveLength(1), SLOW);
234+
await pastDebounce(300);
235+
236+
fireEvent.click(screen.getByRole('button', { name: /Note/ }));
237+
expect(confirmSpy).toHaveBeenCalledTimes(1);
238+
await pastDebounce(300);
239+
expect(fieldCount()).toHaveTextContent('3 fields');
240+
241+
// Agreed to: the edit is dropped and Note opens; nothing more was sent.
242+
confirmSpy.mockReturnValue(true);
243+
fireEvent.click(screen.getByRole('button', { name: /Note/ }));
244+
await waitFor(() => expect(fieldCount()).toHaveTextContent('1 fields'), SLOW);
245+
expect(server.saves).toHaveLength(1);
246+
}, 30000);
247+
248+
it('a switch with nothing pending opens the next object at once and sends nothing', async () => {
249+
await openTask();
250+
fireEvent.click(screen.getByRole('button', { name: /Note/ }));
251+
await waitFor(() => expect(fieldCount()).toHaveTextContent('1 fields'), SLOW);
252+
await pastDebounce();
253+
expect(server.saves).toEqual([]);
254+
expect(confirmSpy).not.toHaveBeenCalled();
255+
}, 30000);
256+
});

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveSwitch-11232.test.tsx‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
* After a switch it sends nothing until the caller's dirty flag has fallen
1919
* (the new item's load installs its own buffer and clears it), and a save's
2020
* claim on its buffer reads moved once the item has changed. The previous
21-
* item's pending edit is dropped, which is what the Data pillar's switch has
22-
* always done, and what every pillar did whenever the new load landed inside
23-
* the debounce. The Data pillar is the control.
21+
* item's pending edit is dropped by the hook, which is what every pillar did
22+
* whenever the new load landed inside the debounce. The Data pillar is the
23+
* control: its rail sends the pending edit to its own object with the hook's
24+
* `flush` before it switches (objectstack#27511), so nothing of it reaches the
25+
* object opened next either.
2426
*
2527
* objectui#11203 (folded in): a package switch keeps the Interfaces pillar
2628
* mounted, so the nav editor's dirty flag and open editing survived into the
@@ -431,7 +433,7 @@ describe('Interfaces page inspector — a save in flight across a page switch (o
431433
});
432434

433435
describe('Data pillar — an object switch inside the debounce, the control (objectui#11232)', () => {
434-
it('sends nothing to the newly opened object, as it always has', async () => {
436+
it('sends nothing to the newly opened object; the pending edit goes to its own (objectstack#27511)', async () => {
435437
render(
436438
<MemoryRouter initialEntries={[`/studio/${PKG}/data`]}>
437439
<DataPillar packageId={PKG} />
@@ -445,7 +447,11 @@ describe('Data pillar — an object switch inside the debounce, the control (obj
445447
await pastDebounce();
446448

447449
expect(savesOf('object', 'acme_note')).toEqual([]);
448-
expect(server.saves).toEqual([]);
450+
// objectstack#27511 — the switch sent the edit to Task before it opened Note.
451+
expect(savesOf('object', 'acme_task').map((b) => readFields(b.fields).entries.map((e) => e.name))).toEqual([
452+
['title', 'status', 'new_field'],
453+
]);
454+
server.saves.length = 0;
449455

450456
await release();
451457
await waitFor(() => expect(screen.getByText(/^\d+ fields$/)).toHaveTextContent('1 fields'), SLOW);

0 commit comments

Comments
 (0)