diff --git a/.changeset/11642-toolbar-writes-compose.md b/.changeset/11642-toolbar-writes-compose.md new file mode 100644 index 0000000000..e405e36680 --- /dev/null +++ b/.changeset/11642-toolbar-writes-compose.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': patch +--- + +Two grid toolbar changes to one view in one session now both survive a reload (objectui#11642). Before, the second change overwrote the first: changing density and then sorting by a column header kept only the sort. This held on every kind of view row: a served view's ViewItem envelope, a saved view stored flat, and a personalization overlay. + +The console saves a toolbar change with `PUT /api/v1/meta/view/NAME`, which replaces the whole row. It built each body from the view as it was when the page loaded, or, for an overlay, from the new change alone. Nothing refreshed that starting point after a save landed, so each later save dropped the earlier one. + +Each toolbar save now starts from the row the store holds when the save runs. The console reads it back with the same `GET /api/v1/meta/view` request the page load makes, because the save answer carries no row and the save door drops undeclared keys from the body it receives. A saved view's change is placed on that row. An overlay keeps the keys it already stored, the ones `VIEW_OVERLAY_OWNED_KEYS` in `@object-ui/data-objectstack` names, and the new change is added to them, so the overlay still holds nothing the view it shadows owns. Saves to the same view run one after another, so a change made while the previous save is still in flight starts from the row that save stored. Two changes made within the save debounce are still sent as one save. + +**Clause-②: no.** Nothing on the package entry changes. diff --git a/packages/app-shell/src/views/ObjectView.toolbarWritesCompose-11642.test.tsx b/packages/app-shell/src/views/ObjectView.toolbarWritesCompose-11642.test.tsx new file mode 100644 index 0000000000..8d3d8e0642 --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.toolbarWritesCompose-11642.test.tsx @@ -0,0 +1,434 @@ +/** + * 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#11642 — two toolbar changes to one view in one session both survive + * a reload, on every row kind: an envelope row, a flat row and an overlay. + * + * ## The defect this pins + * + * `persistViewPatch` sends a whole-document PUT (`updateViewConfig` → + * `client.meta.saveItem`). It built every body from the active tab as it was + * when the page loaded — or, for an overlay, from the pending patch alone — and + * nothing moved that base after a write landed. So the second change was built + * from a row that no longer existed and dropped the first. Measured live on + * objectstack `main` (showcase): density, then a header sort, then a reload + * showed the sort and the OLD density, on all three row kinds. + * + * Triage's ruling: after a successful write, the base for the next write is the + * row that write stored, on every row kind; no client-side merge of guessed + * fields. The PUT answer carries no row (measured: `{ success, version, seq, + * state, message }`), and the door drops undeclared keys, so the row is READ + * BACK — through `loadViewOverrides`, the reader a reload uses — when each write + * runs. + * + * ## What runs + * + * The real object page and the real `persistViewPatch` (its 300 ms debounce + * included), over the real `ObjectStackAdapter`, whose metadata client is a + * store that judges every PUT with the spec's own `ViewMetadataSchema`, keeps + * the parsed value (so it drops what the door drops), and answers the PUT the + * way the door was measured to answer it: with no row. `ListView` is stubbed to + * capture the schema the page hands it, so a toolbar change is the page's own + * `onDensityChange` / `onSortChange` callback. A "reload" unmounts the page and + * mounts it again over a fresh adapter on the same store. + * + * ## Direction, written before the reverse-verification run + * + * With `persistViewPatch` put back to building the body from the page-load tab + * (envelope and flat) and from the pending patch alone (overlay), every + * `two changes … both survive a reload` case is PREDICTED to go red on the + * FIRST change's key (the density), and the in-flight case red the same way. + * The single-write coalescing case is predicted to stay green: one debounced + * write carrying both keys never depended on the base. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, act, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { normalizeListViewSchema } from '@object-ui/core'; +import { ViewMetadataSchema } from '@objectstack/spec/ui'; +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + // Stable identities: `ListView` names `perms` in its fetch dependencies. + const perms = { + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }; + const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] }; + return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +/** The list schema the object page hands down: captured, not rendered. */ +let listSchema: any = null; +vi.mock('@object-ui/plugin-list', async (importOriginal) => ({ + ...(await importOriginal()), + ListView: (props: any) => { + listSchema = props.schema; + return null; + }, +})); + +import { toast } from 'sonner'; +import { ObjectView } from './ObjectView'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; + +const OBJECT_NAME = 'track_task'; +const ENVELOPE_ID = `${OBJECT_NAME}.in_progress`; +const FLAT_ID = `${OBJECT_NAME}.flat_mine`; +const OVERLAY_ID = `${OBJECT_NAME}.done`; + +const FIELDS = { + id: { type: 'text', label: 'Id' }, + title: { type: 'text', label: 'Title' }, + status: { type: 'text', label: 'Status' }, +}; + +/** A served list view's stored row: the ViewItem envelope. */ +const ENVELOPE_ROW = { + name: ENVELOPE_ID, + object: OBJECT_NAME, + viewKind: 'list', + label: 'In Progress', + config: { + type: 'grid', + data: { provider: 'object', object: OBJECT_NAME }, + columns: ['title', 'status'], + filter: [{ field: 'status', operator: 'equals', value: 'in_progress' }], + }, +}; + +/** A saved view whose row is flat — no `config`, no overlay marker. */ +const FLAT_ROW = { + name: FLAT_ID, + object: OBJECT_NAME, + viewKind: 'list', + label: 'Flat Mine', + type: 'grid', + columns: ['title', 'status'], + filter: [{ field: 'status', operator: 'equals', value: 'todo' }], +}; + +/** The code-defined view an overlay shadows — it has no row of its own. */ +const DONE_SOURCE = { + name: OVERLAY_ID, + label: 'Done', + type: 'grid', + columns: ['title', 'status'], + filter: [{ field: 'status', operator: 'equals', value: 'done' }], +}; + +/** A marked overlay on that view, from an earlier session. */ +const OVERLAY_ROW = { + name: OVERLAY_ID, + object: OBJECT_NAME, + viewKind: 'list', + columnState: { widths: { title: 240 } }, + _isOverride: true, +}; + +/** The object's views as the served object document lists them. */ +const DEFINED_VIEWS = { + [ENVELOPE_ID]: { ...ENVELOPE_ROW.config, name: ENVELOPE_ID, label: ENVELOPE_ROW.label }, + [FLAT_ID]: { ...FLAT_ROW }, + [OVERLAY_ID]: { ...DONE_SOURCE }, +}; + +/** The sort the header click emits — the console's row `id` included, which the door strips. */ +const HEADER_SORT = [{ id: 'row-1', field: 'title', order: 'asc' }]; +const STORED_SORT = [{ field: 'title', order: 'asc' }]; + +/** + * A `sys_metadata`-shaped store: every PUT is judged by the spec's + * `ViewMetadataSchema` and the PARSED value is kept, and the PUT is answered + * the way the door answers it — with no row. + */ +function makeStore(seed: Record[], opts: { putDelayMs?: (n: number) => number } = {}) { + const rows = new Map(); + for (const row of seed) rows.set(row.name, structuredClone(row)); + let seq = 0; + const bodies: any[] = []; + const meta = { + getItems: vi.fn(async (type: string) => ({ + type, + items: type === 'view' ? [...rows.values()].map((r) => structuredClone(r)) : [], + })), + getItem: vi.fn(async (type: string, name: string) => { + const item = type === 'view' ? rows.get(name) : undefined; + if (!item) throw Object.assign(new Error(`Not found: ${type}/${name}`), { status: 404 }); + return { type, name, item: structuredClone(item) }; + }), + saveItem: vi.fn(async (_type: string, name: string, item: any) => { + const n = bodies.push(structuredClone(item)); + const delay = opts.putDelayMs?.(n) ?? 0; + if (delay) await new Promise((resolve) => setTimeout(resolve, delay)); + const judged = ViewMetadataSchema.safeParse(item); + if (!judged.success) { + throw Object.assign(new Error(`422 INVALID_METADATA: ${judged.error.message}`), { status: 422 }); + } + // The parsed value of every key the body carried: an undeclared key is + // gone, and a schema default the body never named is not invented. + const kept = Object.fromEntries( + Object.entries(judged.data as Record).filter(([key]) => key in item), + ); + rows.set(name, kept); + seq += 1; + return { success: true, version: `hmac-sha256:${'0'.repeat(63)}${seq}`, seq, state: 'active', message: `Saved view '${name}'` }; + }), + }; + return { meta, rows, bodies }; +} + +/** The real adapter over the store, and the page's data source over the adapter. */ +function pageDataSource(meta: any) { + const ds: any = new ObjectStackAdapter({ + baseUrl: 'http://test.local', + fetch: vi.fn(async () => + new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })), + }); + ds.connected = true; + ds.connectionState = 'connected'; + ds.client = { meta }; + return { + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + listViews: (objectName: string, o?: any) => ds.listViews(objectName, o), + listViewOverrides: (objectName: string) => ds.listViewOverrides(objectName), + getView: (objectName: string, viewId: string) => ds.getView(objectName, viewId), + updateViewConfig: vi.fn((objectName: string, viewId: string, config: any, o?: any) => + ds.updateViewConfig(objectName, viewId, config, o)), + } as any; +} + +const wait = (ms: number) => act(() => new Promise((resolve) => setTimeout(resolve, ms))); + +/** Mount the object page on `viewId` and wait for the page's list schema. */ +async function openView(meta: any, viewId: string) { + listSchema = null; + const dataSource = pageDataSource(meta); + render( + + + + {}} + /> + } + /> + + + , + ); + await waitFor(() => expect(typeof listSchema?.onDensityChange).toBe('function')); + // Let the page's own reads (saved views, stored rows) land before a toggle. + await wait(50); + return dataSource; +} + +/** Unmount, then open the view again over a fresh adapter: what a reload renders. */ +async function reload(meta: any, viewId: string) { + cleanup(); + await openView(meta, viewId); + await waitFor(() => expect(listSchema?.options).toBeTruthy()); + return { + rowHeight: (normalizeListViewSchema(listSchema) as { rowHeight?: unknown }).rowHeight, + sort: listSchema.sort, + }; +} + +/** Density, wait past the 300 ms debounce and the round trip, then a header sort. */ +async function densityThenSort(gapMs = 600) { + act(() => listSchema.onDensityChange('comfortable')); + await wait(gapMs); + act(() => listSchema.onSortChange(HEADER_SORT)); + await wait(600); +} + +beforeEach(() => { + cleanup(); + listSchema = null; + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +describe('objectui#11642 — two toolbar changes in one session both survive a reload, on every row kind', () => { + it('envelope row: the second write carries the first change, inside `config`', async () => { + const { meta, rows, bodies } = makeStore([ENVELOPE_ROW]); + const ds = await openView(meta, ENVELOPE_ID); + await densityThenSort(); + + expect(ds.updateViewConfig).toHaveBeenCalledTimes(2); + expect(toast.error).not.toHaveBeenCalled(); + // The second body was built from the row the first write stored. + expect(bodies[1].config.rowHeight).toBe('medium'); + expect(bodies[1].config.sort).toEqual(HEADER_SORT); + + const stored = rows.get(ENVELOPE_ID); + expect(stored.config.rowHeight).toBe('medium'); + expect(stored.config.sort).toEqual(STORED_SORT); + // The rest of the view is still the view. + expect(stored.config.filter).toEqual(ENVELOPE_ROW.config.filter); + expect(stored.config.columns).toEqual(ENVELOPE_ROW.config.columns); + expect(stored).not.toHaveProperty('_isOverride'); + + expect(await reload(meta, ENVELOPE_ID)).toEqual({ rowHeight: 'medium', sort: STORED_SORT }); + }); + + it('flat row: the second write carries the first change, flat', async () => { + const { meta, rows, bodies } = makeStore([FLAT_ROW]); + const ds = await openView(meta, FLAT_ID); + await densityThenSort(); + + expect(ds.updateViewConfig).toHaveBeenCalledTimes(2); + expect(toast.error).not.toHaveBeenCalled(); + expect(bodies[1]).not.toHaveProperty('config'); + expect(bodies[1].rowHeight).toBe('medium'); + expect(bodies[1].sort).toEqual(HEADER_SORT); + + const stored = rows.get(FLAT_ID); + expect(stored.rowHeight).toBe('medium'); + expect(stored.sort).toEqual(STORED_SORT); + expect(stored.filter).toEqual(FLAT_ROW.filter); + expect(stored.columns).toEqual(FLAT_ROW.columns); + expect(stored).not.toHaveProperty('_isOverride'); + + expect(await reload(meta, FLAT_ID)).toEqual({ rowHeight: 'medium', sort: STORED_SORT }); + }); + + it('overlay: the overlay becomes the patches composed, and still freezes nothing the shadowed view owns', async () => { + const { meta, rows, bodies } = makeStore([OVERLAY_ROW]); + const ds = await openView(meta, OVERLAY_ID); + await densityThenSort(); + + expect(ds.updateViewConfig).toHaveBeenCalledTimes(2); + expect(toast.error).not.toHaveBeenCalled(); + // Both writes take the overlay branch: the row keeps its marker. + expect(ds.updateViewConfig.mock.calls.map((c: any[]) => c[3])).toEqual([ + { isSavedView: false }, + { isSavedView: false }, + ]); + // The second body is the stored overlay plus the new patch — the earlier + // session's column widths and this session's density ride along. + expect(bodies[1].rowHeight).toBe('medium'); + expect(bodies[1].sort).toEqual(HEADER_SORT); + expect(bodies[1].columnState).toEqual(OVERLAY_ROW.columnState); + + const stored = rows.get(OVERLAY_ID); + expect(stored.rowHeight).toBe('medium'); + expect(stored.sort).toEqual(STORED_SORT); + expect(stored.columnState).toEqual(OVERLAY_ROW.columnState); + expect(stored._isOverride).toBe(true); + // objectui#5233 — the console sends no copy of the source view's keys, on + // the second composed write as on the first, and none is at rest. + for (const body of bodies) { + expect(body).not.toHaveProperty('filter'); + expect(body).not.toHaveProperty('columns'); + expect(body).not.toHaveProperty('label'); + expect(body).not.toHaveProperty('type'); + expect(body).not.toHaveProperty('config'); + } + expect(stored).not.toHaveProperty('filter'); + expect(stored).not.toHaveProperty('columns'); + expect(stored).not.toHaveProperty('config'); + + expect(await reload(meta, OVERLAY_ID)).toEqual({ rowHeight: 'medium', sort: STORED_SORT }); + // …and the source view's own filter still reaches the reloaded tab. + expect(listSchema.filter).toEqual(DONE_SOURCE.filter); + }); +}); + +describe('objectui#11642 — the debounce and the write chain', () => { + it('two changes inside the 300 ms debounce coalesce into ONE write carrying both', async () => { + const { meta, rows } = makeStore([ENVELOPE_ROW]); + const ds = await openView(meta, ENVELOPE_ID); + act(() => listSchema.onDensityChange('comfortable')); + await wait(50); + act(() => listSchema.onSortChange(HEADER_SORT)); + await wait(700); + + expect(ds.updateViewConfig).toHaveBeenCalledTimes(1); + const stored = rows.get(ENVELOPE_ID); + expect(stored.config.rowHeight).toBe('medium'); + expect(stored.config.sort).toEqual(STORED_SORT); + }); + + it('a change made while the previous write is still in flight starts from the row that write stored', async () => { + // The first PUT takes 500 ms. The sort is made 400 ms after the density, + // so its debounce fires while that PUT is still in flight: a read taken + // then returns the row from BEFORE the first write. + const { meta, rows, bodies } = makeStore([ENVELOPE_ROW], { putDelayMs: (n) => (n === 1 ? 500 : 0) }); + const ds = await openView(meta, ENVELOPE_ID); + await densityThenSort(400); + await wait(600); + + expect(ds.updateViewConfig).toHaveBeenCalledTimes(2); + expect(bodies[1].config.rowHeight).toBe('medium'); + const stored = rows.get(ENVELOPE_ID); + expect(stored.config.rowHeight).toBe('medium'); + expect(stored.config.sort).toEqual(STORED_SORT); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index f7c25afb54..7d56c0a422 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -16,7 +16,7 @@ import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlSta import { buildListFilterKey, readListFilterState, writeListFilterState } from './listFilterStorage.js'; import { VALUELESS_FILTER_OPERATORS } from './viewFilterFold.js'; import { parseUrlEqualityFilterTriples } from './drillUrlFilters.js'; -import { narrowPersonalizationOverlay, isViewConfigPermissionDeniedError, formatMetadataError } from '@object-ui/data-objectstack'; +import { narrowPersonalizationOverlay, isViewConfigPermissionDeniedError, formatMetadataError, VIEW_OVERLAY_OWNED_KEYS } from '@object-ui/data-objectstack'; const ObjectChart = lazy(() => import('@object-ui/plugin-charts').then((m) => ({ default: m.ObjectChart })), ); @@ -1193,6 +1193,14 @@ export function dispatchViewPatches( * one. So the issue's three dispositions land as: tolerate on read (shipped), * strip on next write (here), no migration — and both halves are pinned. * + * ## What the caller hands in (objectui#11642) + * + * `persistViewPatch` passes the row the store holds when the write runs, read + * back, not the tab as it was at page load: for a saved view that row is + * `baseViewDef`, and for an overlay the stored overlay's own keys arrive inside + * `patch` while the tab still supplies `viewKind` only. See + * {@link toolbarWriteInputs}. This function's shapes are unchanged. + * * Extracted from `persistViewPatch` so the write shape is assertable without * mounting the view, the same reason `buildViewTabs`, `setDefaultViewPatches` * and `reorderViewPatches` above are exported. @@ -1229,6 +1237,65 @@ export function buildPersistedViewBody( return viewKind === undefined ? { ...patch } : { ...patch, viewKind }; } +/** + * What a toolbar write starts from (objectui#11642): **the row the store holds + * when the write runs**, read through {@link loadViewOverrides} — the reader a + * reload uses — and never the tab as it was when the page loaded. + * + * `updateViewConfig` is a whole-document PUT, so each write REPLACES the row. + * The write used to start from the active tab as it was at page load (or, for + * an overlay, from the pending patch alone), and nothing moved that base after + * a write landed. So a second toolbar change in one session was built from a + * row that no longer existed and silently dropped what the first one stored: + * measured live, a density change reverted after a header sort, on every row + * kind. Triage's ruling: after a successful write, the base for the next write + * is the row that write stored, on every row kind, with no client-side merge + * of guessed fields. + * + * Why the stored row is READ BACK rather than taken from the write itself, + * measured against the platform's save door (objectstack `main`, showcase app): + * the `PUT /api/v1/meta/view/NAME` answer carries no row — `{ success, version, + * seq, state, message }`, the shape `SaveMetaItemResponseSchema` declares — and + * the body sent is not what the door kept: it drops undeclared keys + * (ADR-0005 appendix (c)), a sort entry's row `id` among them. The read is the + * `GET /api/v1/meta/view` the page load takes, and the adapter drops its cache + * on every view write (`invalidateViewKeys`), so it is the post-write row, not + * the page-load copy. Reading when the write runs, rather than keeping a copy + * of the row after each write, also composes with a row another handler on this + * page wrote in between (set as default, pin, rename) and leaves no copy that + * has to be kept fresh. + * + * - **Saved view** (an envelope row or a flat row): the stored row is the base + * {@link buildPersistedViewBody} places the patch on. The active tab is the + * base only when no stored row was read — a draft-only view in preview mode, + * or a failed read — which is how every write behaved before this change. + * - **Overlay**: the stored overlay's own keys ({@link VIEW_OVERLAY_OWNED_KEYS}, + * the keys `loadViewOverrides` narrows a marked overlay to) join the pending + * patch, so the row becomes the user's patches composed. It still carries + * nothing the shadowed view owns (objectui#5233): the tab supplies `viewKind` + * only, exactly as before, and a key the stored row holds outside the owned + * set is not carried forward. + */ +async function toolbarWriteInputs( + dataSource: unknown, + objectName: string, + viewId: string, + activeTab: Record, + pending: Record, + isSavedView: boolean, +): Promise<{ base: Record; patch: Record }> { + const read: unknown = (await loadViewOverrides(dataSource, objectName, [viewId]))[viewId]; + const stored = read && typeof read === 'object' && !Array.isArray(read) + ? (read as Record) + : undefined; + if (isSavedView) return { base: stored ?? activeTab, patch: pending }; + const owned: Record = {}; + for (const key of VIEW_OVERLAY_OWNED_KEYS) { + if (stored?.[key] !== undefined) owned[key] = stored[key]; + } + return { base: activeTab, patch: { ...owned, ...pending } }; +} + /** * Item-level keys a switcher tab carries that belong to the ROW, not to the * view body — the ones this surface's own handlers write through `updateView` @@ -1462,6 +1529,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // network write. const persistTimers = useRef>>({}); const persistPending = useRef>>({}); + // objectui#11642 — the writes to one view, chained. Each write reads the + // stored row it starts from (`toolbarWriteInputs`), so it must not start + // before the previous write to the same view has settled: a read taken + // while that PUT is still in flight returns the row from BEFORE it, and + // the second write would put the first change back. + const persistChains = useRef>>({}); // `persistViewPatch` is defined (and its `useCallback` deps evaluated) // BEFORE `savedViews` state exists below — closing over it directly in // the dependency array would read it in its temporal dead zone. Mirror @@ -1504,38 +1577,59 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // `buildPersistedViewBody` for why the saved-view branch is // deliberately NOT narrowed — its row IS the view, and // `saveItem` is a whole-document PUT. - // Optional call: the guard at the top of `persistViewPatch` - // does not narrow across this `setTimeout` closure, and the - // contract declares `updateViewConfig` optional. `?.()` on the - // member keeps the adapter as `this`. - Promise.resolve( - dataSource.updateViewConfig?.( - objectName, - viewIdLocal, - buildPersistedViewBody(baseViewDef, merged, { isSavedView: targetIsSavedView }), - { isSavedView: targetIsSavedView }, - ) - ).catch((err: any) => { - // objectstack#7494's ruling — the gate refuses ORG-WIDE - // view-config writes for a session without the authoring - // capability. The toggle that triggered this has ALREADY - // moved on screen, so the refusal has to be SAID: swallowing - // it into console.error leaves the operator with a density - // they did not get and no way to learn why until a reload - // silently puts it back. - if (isViewConfigPermissionDeniedError(err)) { - toast.error(t('console.objectView.viewConfigPermissionDenied')); - return; - } - console.error('[ObjectView] Failed to persist view config:', err); - // objectui#11583: every other refusal is said too, with the - // door's message. The client gate above answers only for a - // session whose capabilities were reported; an unreported - // one passes it, and the server's 403 lands here. - toast.error(t('form.saveError'), { - description: formatMetadataError(err), - classNames: { description: 'whitespace-pre-line' }, + // + // objectui#11642 — and what the body starts from is the row + // the store holds when this write runs, read back, not the + // tab as it was at page load: see `toolbarWriteInputs`. The + // write waits for the previous write to this view first. + // + // Written as an assignment of one chain, not through a named + // local function: the write-refusal census + // (`writeRefusalCensus-11583.test.ts`) keys this write by its + // nearest named function, `persistViewPatch`, and reads its + // refusal path off the `.catch` below. + persistChains.current[viewIdLocal] = (persistChains.current[viewIdLocal] ?? Promise.resolve()) + .then(async () => { + const { base, patch: toWrite } = await toolbarWriteInputs( + dataSource, objectName, viewIdLocal, baseViewDef, merged, targetIsSavedView, + ); + // Optional call: the guard at the top of `persistViewPatch` + // does not narrow across this `setTimeout` closure, and the + // contract declares `updateViewConfig` optional. `?.()` on + // the member keeps the adapter as `this`. + await dataSource.updateViewConfig?.( + objectName, + viewIdLocal, + buildPersistedViewBody(base, toWrite, { isSavedView: targetIsSavedView }), + { isSavedView: targetIsSavedView }, + ); + }) + .catch((err: any) => { + // objectstack#7494's ruling — the gate refuses ORG-WIDE + // view-config writes for a session without the authoring + // capability. The toggle that triggered this has ALREADY + // moved on screen, so the refusal has to be SAID: swallowing + // it into console.error leaves the operator with a density + // they did not get and no way to learn why until a reload + // silently puts it back. + if (isViewConfigPermissionDeniedError(err)) { + toast.error(t('console.objectView.viewConfigPermissionDenied')); + return; + } + console.error('[ObjectView] Failed to persist view config:', err); + // objectui#11583: every other refusal is said too, with the + // door's message. The client gate above answers only for a + // session whose capabilities were reported; an unreported + // one passes it, and the server's 403 lands here. + toast.error(t('form.saveError'), { + description: formatMetadataError(err), + classNames: { description: 'whitespace-pre-line' }, + }); }); + // Drop the settled tail so the map holds only writes in flight. + const chained = persistChains.current[viewIdLocal]; + void chained.then(() => { + if (persistChains.current[viewIdLocal] === chained) delete persistChains.current[viewIdLocal]; }); }, 300); },