|
| 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 | +}); |
0 commit comments