From d666cd64e82f0758964afd1a0040339221716c89 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:11:31 +0000 Subject: [PATCH 1/4] fix(data-objectstack,app-shell): settle the published view list within an open, dropped by refresh and every console view write (objectui#13057) The adapter stores the answer to GET /api/v1/meta/view under list:view and answers listViews / listViewOverrides from it, so the console order (eager read, then the object page's two readers) reads the URL once. The client read still always asks the server and stores what it got. Every path that should see a fresh list drops it first: - ObjectView refreshData drops it before re-reading the saved views; - useMetadataClient drops it after each MetadataClient write door that can change a published view (item writes typed view, package-wide publishes); - PackagesPage publishDrafts emits the metadata-refresh pulse the other publish paths emit; - org and session switches keep clearing the adapter cache. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- ...ataProvider.oneMetadataRead-13044.test.tsx | 104 +++++++-- ...Provider.switchDropsAdapter-13044.test.tsx | 32 +++ ...ectView.refreshRereadsViews-13057.test.tsx | 192 ++++++++++++++++ packages/app-shell/src/views/ObjectView.tsx | 9 +- .../PackagesPage.publishPulse-13057.test.tsx | 152 +++++++++++++ .../src/views/metadata-admin/PackagesPage.tsx | 9 + .../src/views/metadata-admin/useMetadata.ts | 22 +- .../viewListDrop-13057.test.tsx | 213 ++++++++++++++++++ .../src/views/metadata-admin/viewListDrop.ts | 96 ++++++++ .../cache/MetadataCache.share-13044.test.ts | 24 +- .../src/cache/MetadataCache.ts | 22 +- packages/data-objectstack/src/index.ts | 65 ++++-- .../src/viewCacheInvalidation.pin.test.ts | 35 ++- 13 files changed, 902 insertions(+), 73 deletions(-) create mode 100644 packages/app-shell/src/views/ObjectView.refreshRereadsViews-13057.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/PackagesPage.publishPulse-13057.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/viewListDrop-13057.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/viewListDrop.ts diff --git a/packages/app-shell/src/providers/__tests__/MetadataProvider.oneMetadataRead-13044.test.tsx b/packages/app-shell/src/providers/__tests__/MetadataProvider.oneMetadataRead-13044.test.tsx index 5257151adb..7d38c61cc5 100644 --- a/packages/app-shell/src/providers/__tests__/MetadataProvider.oneMetadataRead-13044.test.tsx +++ b/packages/app-shell/src/providers/__tests__/MetadataProvider.oneMetadataRead-13044.test.tsx @@ -19,12 +19,16 @@ * (a) the provider's by-name object read and `getObjectSchema` ask * `/meta/object/NAME` once, in either order; * (b) the eager view read, `listViews` and `listViewOverrides` share a - * `/meta/view` request that is in flight, and nothing is stored: the - * console order costs two requests where it cost three (ruling - * 6268839381 kept the list uncached); - * (c) the controls: an invalidation still refetches, a later list read and - * a view write still ask again, the preview path still goes to the - * preview client, and each side's readers see the shape they saw before. + * `/meta/view` request that is in flight, and (objectui#13057) the + * adapter's two readers read the list the eager read stored: the console + * order costs ONE request where it cost three, then two; + * (c) the controls: an invalidation still refetches, a provider re-read and + * a view write still ask again, the stored list is dropped by every + * write and refresh path (the console's own drops are pinned in + * `viewListDrop-13057.test.tsx` and + * `ObjectView.refreshRereadsViews-13057.test.tsx`), the preview path + * still goes to the preview client and never fills the stored list, and + * each side's readers see the shape they saw before. */ import { act, cleanup, render, waitFor } from '@testing-library/react'; @@ -81,6 +85,7 @@ function makeServer(): { server: Server; adapter: ObjectStackAdapter } { const answer = (path: string): Response => { if (path.endsWith('/api/v1/discovery')) return json(200, { success: true, data: {} }); if (path === '/api/v1/meta/view') return json(200, { success: true, data: { type: 'view', items: structuredClone(VIEWS) } }); + if (path === '/api/v1/meta/view?preview=draft') return json(200, { success: true, data: { type: 'view', items: [] } }); if (path.startsWith('/api/v1/meta/view/')) return json(200, { success: true }); const byName = /^\/api\/v1\/meta\/object\/([^/?]+)$/.exec(path); if (byName) { @@ -199,7 +204,7 @@ describe('one request per object document across both caches (objectui#13044)', }); }); -describe('the published view list: one request in flight, nothing stored (objectui#13044)', () => { +describe('the published view list: one request per open (objectui#13044, objectui#13057)', () => { it('console order: the eager read lands, then the object page reads the list and the overrides together', async () => { const { server, adapter } = makeServer(); const ctx = mount(adapter); @@ -211,9 +216,10 @@ describe('the published view list: one request in flight, nothing stored (object await adapter.listViewOverrides('account'); expect(views.map((v: any) => v.name).sort()).toEqual(['account.all', 'account.mine']); expect(Object.keys(overrides).sort()).toEqual(['account.all', 'account.mine']); - // Was 3 (one per reader). The eager read is answered before the page asks, - // and no answer is stored, so the page's two readers share one more. - expect(count(server.calls, VIEW_URL)).toBe(2); + // Was 3 (one per reader), then 2 (objectui#13044: the page's two readers + // shared one more request in flight). objectui#13057: the eager read + // stored its answer, and both readers read it, so the open costs one. + expect(count(server.calls, VIEW_URL)).toBe(1); }); it('a reader that asks while the eager read is on the wire joins it', async () => { @@ -260,46 +266,102 @@ describe('controls (objectui#13044)', () => { expect(count(server.calls, OBJECT_URL)).toBe(3); }); - it('every list read after a landing asks again: a refresh (refreshKey) re-reads the views', async () => { + it('the list is settled within an open, and a provider re-read asks again and is what the next list read sees (objectui#13057)', async () => { const { server, adapter } = makeServer(); const ctx = mount(adapter); await waitFor(() => expect(ctx().getTypeStatus!('view')).toBe('ready')); await adapter.listViews('account'); await adapter.listViews('account'); - expect(count(server.calls, VIEW_URL)).toBe(3); + // Answered from the list the eager read stored. + expect(count(server.calls, VIEW_URL)).toBe(1); + // The provider's re-read is a client read: it never reads the stored list. await act(async () => { await ctx().refresh('view'); }); - expect(count(server.calls, VIEW_URL)).toBe(4); + expect(count(server.calls, VIEW_URL)).toBe(2); + // ...and what it was answered is what the adapter's readers read next. + VIEWS.push({ name: 'account.new', object: 'account', viewKind: 'list', label: 'New', config: { type: 'grid', columns: ['name'] } }); + try { + expect((await adapter.listViews('account')).map((v: any) => v.name).sort()).toEqual(['account.all', 'account.mine']); + expect(count(server.calls, VIEW_URL)).toBe(2); + await act(async () => { + await ctx().refresh('view'); + }); + expect((await adapter.listViews('account')).map((v: any) => v.name).sort()).toEqual([ + 'account.all', + 'account.mine', + 'account.new', + ]); + expect(count(server.calls, VIEW_URL)).toBe(3); + } finally { + VIEWS.pop(); + } + }); + + it('a view write drops the stored list: the next list read asks again (objectui#13057)', async () => { + for (const write of [ + (adapter: ObjectStackAdapter) => adapter.invalidateViewKeys('account', 'account.mine'), + (adapter: ObjectStackAdapter) => + adapter.getClient().meta.saveItem('view', 'account.mine', { name: 'account.mine', object: 'account' }), + ]) { + const { server, adapter } = makeServer(); + const ctx = mount(adapter); + await waitFor(() => expect(ctx().getTypeStatus!('view')).toBe('ready')); + await adapter.listViews('account'); + expect(count(server.calls, VIEW_URL)).toBe(1); + await write(adapter); + await adapter.listViews('account'); + // An object whose override map was never read: derived from the stored list. + await adapter.listViewOverrides('contact'); + // The read after the write asked; the one after it reads what it stored. + expect(count(server.calls, VIEW_URL)).toBe(2); + cleanup(); + } }); it('a view write detaches the list request in flight: a read after it asks again', async () => { const { server, adapter } = makeServer(); - const ctx = mount(adapter); - await waitFor(() => expect(ctx().getTypeStatus!('view')).toBe('ready')); server.hold.add(VIEW_URL); + const ctx = mount(adapter); + await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(1)); + // Joins the eager read on the wire (after its own discovery read). const before = adapter.listViews('account'); - await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(2)); + await new Promise((resolve) => setTimeout(resolve, 0)); adapter.invalidateViewKeys('account', 'account.mine'); const after = adapter.listViews('account'); - await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(3)); + await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(2)); server.release(); await Promise.all([before, after]); + await waitFor(() => expect(ctx().getTypeStatus!('view')).toBe('ready')); }); it('a view saved straight through the client detaches the list request in flight too', async () => { const { server, adapter } = makeServer(); - const ctx = mount(adapter); - await waitFor(() => expect(ctx().getTypeStatus!('view')).toBe('ready')); server.hold.add(VIEW_URL); + const ctx = mount(adapter); + await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(1)); const before = adapter.listViews('account'); - await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(2)); + await new Promise((resolve) => setTimeout(resolve, 0)); // The shape a page uses when it writes a view without the adapter's seam. await adapter.getClient().meta.saveItem('view', 'account.mine', { name: 'account.mine', object: 'account' }); const after = adapter.listViews('account'); - await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(3)); + await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(2)); server.release(); await Promise.all([before, after]); + await waitFor(() => expect(ctx().getTypeStatus!('view')).toBe('ready')); + }); + + it('a draft-preview list read never fills the stored list (objectui#13057)', async () => { + const { server, adapter } = makeServer(); + // The adapter's draft-overlaid read is its own request, on its preview client... + await adapter.listViews('account', { previewDrafts: true }); + expect(count(server.calls, `${VIEW_URL}?preview=draft`)).toBe(1); + expect(count(server.calls, VIEW_URL)).toBe(0); + // ...and stores nothing a published read could be answered from. + expect(adapter.getCached('list:view')).toBeUndefined(); + const published = await adapter.listViews('account'); + expect(published.map((v: any) => v.name).sort()).toEqual(['account.all', 'account.mine']); + expect(count(server.calls, VIEW_URL)).toBe(1); }); it('the preview path still reads through the preview client, never the adapter', async () => { diff --git a/packages/app-shell/src/providers/__tests__/MetadataProvider.switchDropsAdapter-13044.test.tsx b/packages/app-shell/src/providers/__tests__/MetadataProvider.switchDropsAdapter-13044.test.tsx index e613b56cef..b34660e1f7 100644 --- a/packages/app-shell/src/providers/__tests__/MetadataProvider.switchDropsAdapter-13044.test.tsx +++ b/packages/app-shell/src/providers/__tests__/MetadataProvider.switchDropsAdapter-13044.test.tsx @@ -127,6 +127,38 @@ describe('a switch drops the adapter metadata the provider read through it (obje await views; }); + it('an organization or session switch drops the stored view list: the next list read asks again (objectui#13057)', async () => { + for (const change of [ + () => { + authState.activeOrganization = { id: 'org_b' }; + }, + () => { + authState.user = { id: 'user_b' }; + }, + ]) { + const { adapter, calls } = makeServer(); + authState.activeOrganization = { id: 'org_a' }; + authState.user = { id: 'user_a' }; + const m = mount(adapter); + await waitFor(() => expect(count(calls, VIEW_URL)).toBe(1)); + await waitFor(() => expect(m.ctx().getTypeStatus!('view')).toBe('ready')); + // Settled within the open: the eager read's answer serves the list read. + await adapter.listViews('account'); + expect(count(calls, VIEW_URL)).toBe(1); + + change(); + m.rerender(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await adapter.listViews('account'); + expect(count(calls, VIEW_URL)).toBe(2); + cleanup(); + authState.activeOrganization = null; + authState.user = null; + } + }); + it('a session switch (another user) drops it the same way', async () => { const { adapter, calls } = makeServer(); authState.user = { id: 'user_a' }; diff --git a/packages/app-shell/src/views/ObjectView.refreshRereadsViews-13057.test.tsx b/packages/app-shell/src/views/ObjectView.refreshRereadsViews-13057.test.tsx new file mode 100644 index 0000000000..d5967919f3 --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.refreshRereadsViews-13057.test.tsx @@ -0,0 +1,192 @@ +/** + * 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. + */ + +/** + * A refresh of the object page re-reads its saved views from the server + * (objectui#13057, objectui#13044 ruling 6268839381 (a)). + * + * The adapter answers `listViews` from the published view list it stored for + * the open, so one open reads `GET /api/v1/meta/view` once. A user refresh + * (`refreshData`: a realtime change, a server action, an import, the page's + * refresh action) must not be answered by that stored list: it drops the list, + * and the saved-views read the refresh re-runs asks the server again. + * + * Real: the page, `plugin-view`'s `ObjectView`, `plugin-list`'s `ListView`, and + * an `ObjectStackAdapter` (its SDK client and `MetadataCache` included) behind + * a fake transport, so the drop is measured against the adapter's own key. Its + * record queries (`find`) are a stand-in: the rows are not under test. The + * refresh is delivered on the page's realtime channel, the harness of + * `ObjectView.refreshInPlace-10035.test.tsx`. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, act } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + // Stable identities: `ListView` names `perms` in its fetch dependency list. + 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: false, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +/** The page's realtime channel; a case sets it and re-renders to deliver one. */ +const realtime = vi.hoisted(() => ({ + lastMessage: null as unknown, + conflicts: { hasConflicts: false, resolveAllConflicts: () => {} }, +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: realtime.lastMessage }), + useConflictResolution: () => realtime.conflicts, +})); + +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 })); + +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { ObjectView } from './ObjectView'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; + +const OBJECT_NAME = 'duly_task'; +const VIEW_URL = '/api/v1/meta/view'; +const OBJECTS = [ + { + name: OBJECT_NAME, + label: 'Task', + fields: { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } }, + listViews: { all: { label: 'All', type: 'grid', columns: ['name'] } }, + }, +]; +const SAVED = [ + { name: `${OBJECT_NAME}.mine`, object: OBJECT_NAME, viewKind: 'list', label: 'Mine', config: { type: 'grid', columns: ['name'] } }, +]; + +const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + +let seq = 0; + +function makeAdapter() { + const calls: string[] = []; + const adapter = new ObjectStackAdapter({ + baseUrl: `http://refresh-views-${(seq += 1)}.test`, + autoReconnect: false, + fetch: (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + if (url.pathname.endsWith('/discovery')) return json({ success: true, data: {} }); + calls.push(url.pathname); + if (url.pathname === VIEW_URL) return json({ success: true, data: { type: 'view', items: structuredClone(SAVED) } }); + return json({ success: true, data: {} }); + }) as typeof fetch, + }); + // The record rows are not under test; the page's list query is answered here. + (adapter as unknown as { find: unknown }).find = vi.fn(async () => ({ data: [{ id: 't1', name: 'Task 1' }], total: 1 })); + return { adapter, viewReads: () => calls.filter((c) => c === VIEW_URL).length }; +} + +/** A window long enough for every effect a step schedules to have fired. */ +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 400))); + +async function mountPage(adapter: ObjectStackAdapter): Promise<() => Promise> { + let rerender: () => void = () => {}; + function Harness() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + rerender = () => setTick((n) => n + 1); + }, []); + return ( + + + + {}} />} + /> + + + + ); + } + render(); + await settle(); + return async () => { + await act(async () => { + realtime.lastMessage = { type: 'update', objectName: OBJECT_NAME, at: Date.now() }; + rerender(); + }); + await settle(); + }; +} + +beforeEach(() => { + cleanup(); + realtime.lastMessage = null; + vi.stubGlobal('fetch', vi.fn(async () => json({ data: [] }))); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('a refresh of the object page re-reads its saved views (objectui#13057)', () => { + it('the open reads the view list once, and refreshData asks the server again', async () => { + const { adapter, viewReads } = makeAdapter(); + const listViews = vi.spyOn(adapter, 'listViews'); + const refresh = await mountPage(adapter); + // One open: `listViews` and `listViewOverrides` share one request. + expect(viewReads()).toBe(1); + const readsBefore = listViews.mock.calls.length; + + await refresh(); + + // The refresh re-ran the saved-views read, and it was not answered by the + // list stored for the open. + expect(listViews.mock.calls.length).toBeGreaterThan(readsBefore); + expect(viewReads()).toBe(2); + }); + + it('CONTROL: with no refresh, a re-render reads nothing again', async () => { + const { adapter, viewReads } = makeAdapter(); + await mountPage(adapter); + await settle(); + expect(viewReads()).toBe(1); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index ccb1c142a8..6cc2e5cc98 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -63,6 +63,7 @@ import { detectStatusField, isSystemManagedField } from '@object-ui/types'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { ViewConfigPanel } from './ViewConfigPanel.js'; import { useMetadataClient } from './metadata-admin/useMetadata.js'; +import { dropPublishedViewList } from './metadata-admin/viewListDrop.js'; import { persistRuntimeMetadata, createRuntimeMetadata, viewEnvelope, type ViewEnvelope } from './runtime-metadata-persistence.js'; import { buildNewViewSpec } from './newViewSpec.js'; import { ListViewSchema as SpecListViewSchema, normalizeFilterOperator, VIEW_CONSOLE_ROUND_TRIP_KEYS } from '@objectstack/spec/ui'; @@ -1694,11 +1695,17 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co * View CRUD (rename, pin, reorder, config save) keeps bumping the counter * alone: it changes the view's metadata, not the object's data, and the new * view config reaches every renderer as props. + * + * objectui#13057 — a refresh also re-reads the saved views from the server. + * The adapter answers `listViews` from the published view list it stored + * for this open, so the refresh drops that list first; the counter bump + * then re-runs the saved-views read below, which asks the server again. */ const refreshData = useCallback(() => { + dropPublishedViewList(dataSource); setRefreshKey((k) => k + 1); if (objectDef.name) notifyDataChanged({ objectName: objectDef.name }); - }, [objectDef.name]); + }, [dataSource, objectDef.name]); // Shared console action runtime: confirm/param/result dialogs, the // authenticated api/flow/server-action handlers, SPA navigation, and the diff --git a/packages/app-shell/src/views/metadata-admin/PackagesPage.publishPulse-13057.test.tsx b/packages/app-shell/src/views/metadata-admin/PackagesPage.publishPulse-13057.test.tsx new file mode 100644 index 0000000000..acb997e152 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/PackagesPage.publishPulse-13057.test.tsx @@ -0,0 +1,152 @@ +/** + * 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. + */ + +/** + * The package sheet's *Publish app* announces the publish, as every other + * publish path does (objectui#13057). + * + * `publishDrafts` promotes a package's drafts, views among them, and used to + * emit no metadata-refresh pulse, so the console kept serving what it had read + * before the publish: no `MetadataProvider` re-read its types or dropped the + * adapter's caches. It now emits the pulse Studio's publish-all, Home's and the + * chat bar's emit. And the read after it asks the server: the adapter's stored + * published view list is dropped by the console client's package door + * (`viewListDrop.ts`). + * + * Real: the sheet, `useMetadataClient` and its client, the assistant bus, and + * an `ObjectStackAdapter` behind a fake transport. Stubbed: `globalThis.fetch` + * (the console client's server), as `PackagesPage.publishRefusal-11922.test.tsx` + * stubs it. + */ + +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AdapterCtx } from '@object-ui/react'; +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; + +import { PackageDetailSheet, type InstalledPackageRow } from './PackagesPage'; +import { t, tFormat } from './i18n'; +import { subscribeMetadataRefresh } from '../../assistant/assistantBus'; + +const PKG_ID = 'com.acme.crm'; +const PKG: InstalledPackageRow = { + manifest: { id: PKG_ID, name: 'Acme CRM', version: '1.0.0', type: 'app' }, + enabled: true, + status: 'installed', + source: 'authored', + writable: true, +}; +const DRAFTS = [{ type: 'view', name: 'account.mine' }]; +const VIEW_URL = '/api/v1/meta/view'; + +const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + +let batchBody: unknown; +const posts: string[] = []; + +beforeEach(() => { + posts.length = 0; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + const url = String(input); + if (url.endsWith('/publish-drafts')) { + posts.push(url); + return json(batchBody); + } + if (url.startsWith('/api/v1/meta/_drafts')) return json({ success: true, data: { drafts: DRAFTS } }); + return json({ success: true, data: {} }); + }) as unknown as typeof fetch, + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function makeAdapter() { + const calls: string[] = []; + const adapter = new ObjectStackAdapter({ + baseUrl: 'http://publish-pulse.test', + autoReconnect: false, + fetch: (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + if (url.pathname.endsWith('/discovery')) return json({ success: true, data: {} }); + calls.push(url.pathname); + return json({ success: true, data: { type: 'view', items: [{ name: 'account.all', object: 'account' }] } }); + }) as typeof fetch, + }); + return { adapter, viewReads: () => calls.filter((c) => c === VIEW_URL).length }; +} + +async function publishApp(adapter: ObjectStackAdapter): Promise { + let pulses = 0; + const off = subscribeMetadataRefresh(() => { + pulses += 1; + }); + try { + render( + + + + + , + ); + fireEvent.click( + await screen.findByRole('button', { + name: tFormat('engine.packages.detail.publishApp', 'en', { count: DRAFTS.length }), + }), + ); + await waitFor(() => expect(posts).toHaveLength(1)); + await screen.findByText((_, node) => !!node && node.childElementCount === 0 && /\S/.test(node.textContent ?? '') && ( + node.textContent === t('engine.packages.detail.publishDraftsOk', 'en') || + (node.textContent ?? '').startsWith(tFormat('engine.packages.detail.publishDraftsPartial', 'en', { published: 0, failed: 1 })) + )); + return pulses; + } finally { + off(); + } +} + +describe('Publish app announces the publish, and the next view read asks again (objectui#13057)', () => { + it('a published batch emits the metadata-refresh pulse once', async () => { + batchBody = { + success: true, + data: { success: true, outcome: 'published', publishedCount: 1, failedCount: 0, published: DRAFTS, failed: [] }, + }; + const { adapter, viewReads } = makeAdapter(); + await adapter.getClient().meta.getItems('view'); + await adapter.listViews('account'); + expect(viewReads()).toBe(1); + + expect(await publishApp(adapter)).toBe(1); + + await adapter.listViews('account'); + expect(viewReads()).toBe(2); + }); + + it('a batch answered with failed[] still announces it, as Studio\'s publish-all does (a pre-15.1 server published the rest)', async () => { + batchBody = { + success: true, + data: { + success: false, + outcome: 'refused', + publishedCount: 0, + failedCount: 1, + published: [], + failed: [{ type: 'view', name: 'account.mine', error: 'bad', code: 'X' }], + }, + }; + const { adapter } = makeAdapter(); + expect(await publishApp(adapter)).toBe(1); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx index 8e07ea217d..98ee995396 100644 --- a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx @@ -81,6 +81,7 @@ import { PageHeader } from '../../layout/PageHeader.js'; import { errorCodeIs } from '@object-ui/types'; import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js'; import { refusedItemsText } from '../../preview/publishRefusal.js'; +import { emitMetadataRefresh } from '../../assistant/assistantBus.js'; import { duplicatePackage, isSpecPackageId } from '../studio-design/packages-io.js'; import { PackageIdInput } from '../studio-design/PackageIdInput.js'; import { useWorkspaceAdminStatus } from '@object-ui/auth'; @@ -574,6 +575,14 @@ export function PackageDetailSheet({ t('engine.packages.detail.actionFailed', locale), ); } + // objectui#13057 — announce the publish, as every other publish path + // does (Studio's publish-all, Home's and the chat bar's): each mounted + // `MetadataProvider` drops the adapter's caches and re-reads its loaded + // types, so the views, forms and nav this tree already read show what + // just went live, and the pending-drafts surfaces converge. Sent before + // the `failed[]` verdict below, as Studio sends it: a pre-15.1 server's + // partial publish still made some drafts live. + emitMetadataRefresh(); try { const fresh = await apiJson<{ drafts?: Array<{ type: string; name: string }> }>( `/api/v1/meta/_drafts?packageId=${encodeURIComponent(id)}`, diff --git a/packages/app-shell/src/views/metadata-admin/useMetadata.ts b/packages/app-shell/src/views/metadata-admin/useMetadata.ts index 6c19b20f33..b506b259d9 100644 --- a/packages/app-shell/src/views/metadata-admin/useMetadata.ts +++ b/packages/app-shell/src/views/metadata-admin/useMetadata.ts @@ -18,9 +18,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type MetadataClient, type MetadataDiagnosticsSummary, type MetadataDiagnosticsEntry, type MetadataSaveAdvisoryEvent } from '@object-ui/data-objectstack'; import { useObjectTranslation } from '@object-ui/i18n'; +import { useAdapter } from '@object-ui/react'; import { toast } from 'sonner'; import { usePreviewDrafts } from '../../preview/PreviewModeContext.js'; import { createConsoleMetadataClient } from './metadataClientFactory.js'; +import { dropPublishedViewList, installViewListDrop } from './viewListDrop.js'; import { emitSaveAdvisories, type TranslateFn } from '../../providers/saveAdvisoryToast.js'; import type { FormViewSpec } from './form-spec.js'; @@ -157,8 +159,26 @@ export function useMetadataClient(environmentId?: string): MetadataClient { emitSaveAdvisories(ev, tRef.current as TranslateFn, toast); }, []); + // objectui#13057 — the adapter answers its view-list readers from the list + // it stored, and this client writes views over a transport the adapter never + // sees. Each write of this client that can change a published view drops + // that list (see `viewListDrop.ts`), here for the same reason the advisory + // sink is here: every app-shell write path takes its client from this hook. + // The adapter is read through a ref, as `t` is, so an adapter swap does not + // re-mint the client and a write always drops from the current adapter. + const adapter = useAdapter(); + const adapterRef = useRef(adapter); + adapterRef.current = adapter; + return useMemo( - () => createConsoleMetadataClient({ previewDrafts, environmentId, onSaveAdvisory }), + () => + installViewListDrop(createConsoleMetadataClient({ previewDrafts, environmentId, onSaveAdvisory }), () => { + try { + dropPublishedViewList(adapterRef.current); + } catch { + /* a drop must never turn a settled write into a thrown error */ + } + }), [environmentId, previewDrafts, onSaveAdvisory], ); } diff --git a/packages/app-shell/src/views/metadata-admin/viewListDrop-13057.test.tsx b/packages/app-shell/src/views/metadata-admin/viewListDrop-13057.test.tsx new file mode 100644 index 0000000000..c7eca886a0 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/viewListDrop-13057.test.tsx @@ -0,0 +1,213 @@ +/** + * 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. + */ + +/** + * Every view write of the console's `MetadataClient` drops the adapter's stored + * published view list (objectui#13057, completing objectui#13044 ruling + * 6268839381 (b)). + * + * The adapter answers `listViews` / `listViewOverrides` from the list it stored + * for the open. The console's metadata pages (`metadata-admin/**`) and Studio + * (`studio-design/**`) write views through `MetadataClient`, whose transport the + * adapter never sees, so `useMetadataClient` drops the list after each write + * door that can change a published view (`viewListDrop.ts`). + * + * Real: `ObjectStackAdapter` (its SDK client and `MetadataCache` included, + * driven through a fake `fetch`), `useMetadataClient` and the client it builds. + * Stubbed: the console client's transport (`globalThis.fetch`). + * + * The writers are enumerated from the client, not listed by hand: every door of + * `MetadataClient` that sends a write is one of `ITEM_WRITE_DOORS` / + * `PACKAGE_WRITE_DOORS`, and every console client comes from the hook. So a new + * write door, or a page that builds its own client, turns a pin here red + * instead of writing views silently past the stored list. + */ + +import * as React from 'react'; +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import { AdapterCtx } from '@object-ui/react'; +import { MetadataClient, ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { useMetadataClient } from './useMetadata'; +import { ITEM_WRITE_DOORS, PACKAGE_WRITE_DOORS } from './viewListDrop'; +import { maskComments } from '../../../../../scripts/js-comment-mask.mjs'; + +/** Local annotation, since the import above is untyped. */ +const mask: (source: string) => string = maskComments; + +const VIEW_URL = '/api/v1/meta/view'; +const VIEWS = [{ name: 'account.all', object: 'account', viewKind: 'list', label: 'All', config: { type: 'grid' } }]; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +let seq = 0; + +/** A real adapter whose own transport is a fake server that counts by URL. */ +function makeAdapter() { + const calls: string[] = []; + const adapter = new ObjectStackAdapter({ + baseUrl: `http://view-list-drop-${(seq += 1)}.test`, + autoReconnect: false, + fetch: (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + if (url.pathname.endsWith('/discovery')) return json(200, { success: true, data: {} }); + calls.push(url.pathname + url.search); + if (url.pathname === VIEW_URL) return json(200, { success: true, data: { type: 'view', items: structuredClone(VIEWS) } }); + return json(200, { success: true, data: {} }); + }) as typeof fetch, + }); + return { adapter, viewReads: () => calls.filter((c) => c === VIEW_URL).length }; +} + +/** The console client, as a page gets it, under `adapter`. */ +function consoleClient(adapter: ObjectStackAdapter): MetadataClient { + let client: MetadataClient | null = null; + function Probe() { + client = useMetadataClient(); + return null; + } + render( + + + , + ); + return client as unknown as MetadataClient; +} + +/** What the console client's server answers: every write lands, unless refused. */ +let refuse = false; +beforeEach(() => { + refuse = false; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + if (refuse) return json(409, { success: false, error: { code: 'CONFLICT', message: 'refused' } }); + const url = String(input); + if (url.includes('/publish-drafts')) { + return json(200, { success: true, data: { success: true, outcome: 'published', published: [], failed: [] } }); + } + return json(200, { success: true, data: { success: true, version: 2 } }); + }) as unknown as typeof fetch, + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +/** + * One write of each class, each addressed to a view (the package-wide door + * names a package, and a package can carry views). + */ +const VIEW_WRITES: Array<[string, (client: MetadataClient) => Promise]> = [ + ['save (draft)', (c) => c.save('view', 'account.mine', { name: 'account.mine', object: 'account' }, { mode: 'draft' })], + ['save (live)', (c) => c.save('view', 'account.mine', { name: 'account.mine', object: 'account' })], + ['publish', (c) => c.publish('view', 'account.mine')], + ['publishDraft', (c) => c.publishDraft('view', 'account.mine')], + ['reset', (c) => c.reset('view', 'account.mine')], + ['rollback', (c) => c.rollback('view', 'account.mine', 1)], + ['publishPackageDrafts', (c) => c.publishPackageDrafts('com.acme.crm')], +]; + +/** Store the list the way the console's eager read does, and read it once. */ +async function settleList(adapter: ObjectStackAdapter, viewReads: () => number) { + await adapter.getClient().meta.getItems('view'); + await adapter.listViews('account'); + expect(viewReads()).toBe(1); +} + +describe('each console view write drops the stored list: the next read asks again (objectui#13057)', () => { + it.each(VIEW_WRITES)('%s', async (_name, write) => { + const { adapter, viewReads } = makeAdapter(); + const client = consoleClient(adapter); + await settleList(adapter, viewReads); + await write(client); + await adapter.listViews('account'); + await adapter.listViewOverrides('account'); + // One more read, then served from what it stored. + expect(viewReads()).toBe(2); + }); + + it('a refused view write drops it too: what landed before it is not known', async () => { + const { adapter, viewReads } = makeAdapter(); + const client = consoleClient(adapter); + await settleList(adapter, viewReads); + refuse = true; + await expect(client.save('view', 'account.mine', { name: 'account.mine', object: 'account' })).rejects.toMatchObject({ + status: 409, + }); + await adapter.listViews('account'); + expect(viewReads()).toBe(2); + }); +}); + +describe('controls (objectui#13057)', () => { + it('a write of another type keeps the stored list', async () => { + const { adapter, viewReads } = makeAdapter(); + const client = consoleClient(adapter); + await settleList(adapter, viewReads); + await client.save('object', 'lead', { name: 'lead', fields: {} }, { mode: 'draft' }); + await client.reset('flow', 'lead_flow'); + await adapter.listViews('account'); + expect(viewReads()).toBe(1); + }); + + it('a write hands back its own answer, untouched by the drop', async () => { + const { adapter } = makeAdapter(); + const client = consoleClient(adapter); + await expect(client.publish('view', 'account.mine')).resolves.toMatchObject({ data: { version: 2 } }); + }); + + it('with no adapter in context the writes still land', async () => { + let client: MetadataClient | null = null; + function Probe() { + client = useMetadataClient(); + return null; + } + render(); + await expect((client as unknown as MetadataClient).publish('view', 'account.mine')).resolves.toMatchObject({ + data: { version: 2 }, + }); + }); +}); + +describe('the writers are enumerated, so a new one cannot be silent (objectui#13057)', () => { + it('every MetadataClient door that sends a write is one the console client drops after', () => { + const proto = MetadataClient.prototype as unknown as Record; + const members = Object.getOwnPropertyNames(proto).filter( + (name) => name !== 'constructor' && typeof proto[name] === 'function', + ); + // Positive control: the write funnel the scan keys on is still there. + expect(members).toContain('sendWrite'); + const writeDoors = members + .filter((name) => name !== 'sendWrite') + .filter((name) => /\bthis\.sendWrite\(/.test(mask(String(proto[name])))) + .sort(); + expect(writeDoors.length).toBeGreaterThan(0); + expect(writeDoors).toEqual([...ITEM_WRITE_DOORS, ...PACKAGE_WRITE_DOORS].sort()); + }); + + it('every console MetadataClient is built by useMetadataClient (the provider builds a read-only one)', () => { + const srcRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const builders: string[] = []; + for (const entry of readdirSync(srcRoot, { recursive: true, encoding: 'utf8' })) { + if (!/\.(ts|tsx)$/.test(entry) || /\.test\.(ts|tsx)$/.test(entry) || entry.includes('__tests__')) continue; + const code = mask(readFileSync(path.join(srcRoot, entry), 'utf8')); + if (/(? void): MetadataClient { + const doors = client as unknown as Record Promise>; + for (const door of ITEM_WRITE_DOORS) { + const write = doors[door].bind(client); + doors[door] = (...args: unknown[]) => (args[0] === 'view' ? write(...args).finally(drop) : write(...args)); + } + for (const door of PACKAGE_WRITE_DOORS) { + const write = doors[door].bind(client); + doors[door] = (...args: unknown[]) => write(...args).finally(drop); + } + return client; +} diff --git a/packages/data-objectstack/src/cache/MetadataCache.share-13044.test.ts b/packages/data-objectstack/src/cache/MetadataCache.share-13044.test.ts index 7f801a177b..307707c140 100644 --- a/packages/data-objectstack/src/cache/MetadataCache.share-13044.test.ts +++ b/packages/data-objectstack/src/cache/MetadataCache.share-13044.test.ts @@ -13,8 +13,9 @@ import { MetadataCache } from './MetadataCache'; * `share` and the in-flight half of `invalidate` (objectui#13044). * * `share` is the door for a caller whose contract is "ask the server": it - * joins a request already in flight, and neither reads nor writes a settled - * entry. `invalidate` detaches the request in flight, so a + * joins a request already in flight and is never answered from a settled + * entry. Since objectui#13057 its landing is stored, so a later `get` may be + * answered from it. `invalidate` detaches the request in flight, so a * request sent before an invalidation neither answers a later caller nor * overwrites a newer answer. */ @@ -41,17 +42,30 @@ describe('MetadataCache.share (objectui#13044)', () => { expect(calls).toBe(1); }); - it('is never answered from the settled entry, and stores nothing', async () => { + it('is never answered from the settled entry, and stores what it was answered (objectui#13057)', async () => { const cache = new MetadataCache(); let calls = 0; const fetcher = async () => `answer ${(calls += 1)}`; expect(await cache.get('k', fetcher)).toBe('answer 1'); + // A settled entry is there; `share` asks the server anyway. expect(await cache.share('k', fetcher)).toBe('answer 2'); - expect(await cache.get('k', fetcher)).toBe('answer 1'); + // ...and what it was answered is what a later `get` is answered from. + expect(await cache.get('k', fetcher)).toBe('answer 2'); expect(await cache.share('fresh', fetcher)).toBe('answer 3'); - expect(cache.has('fresh')).toBe(false); + expect(cache.has('fresh')).toBe(true); + expect(await cache.get('fresh', fetcher)).toBe('answer 3'); expect(calls).toBe(3); }); + + it('a request detached by an invalidation stores nothing (objectui#13057)', async () => { + const cache = new MetadataCache(); + const wire = deferred(); + const shared = cache.share('k', () => wire.promise); + cache.invalidate('k'); + wire.resolve('before the invalidation'); + expect(await shared).toBe('before the invalidation'); + expect(cache.has('k')).toBe(false); + }); }); describe('MetadataCache.invalidate detaches the read in flight (objectui#13044)', () => { diff --git a/packages/data-objectstack/src/cache/MetadataCache.ts b/packages/data-objectstack/src/cache/MetadataCache.ts index a45419d8cb..ef17fde15e 100644 --- a/packages/data-objectstack/src/cache/MetadataCache.ts +++ b/packages/data-objectstack/src/cache/MetadataCache.ts @@ -135,34 +135,38 @@ export class MetadataCache { } } - return this.load(key, fetcher, true); + return this.load(key, fetcher); } /** - * Join the read in flight for `key`, or start one. Nothing is read from or - * written to the settled entries: a caller of this always gets an answer - * from a request that was on the wire when it asked, and its answer is - * never served to anyone who asks after it lands. + * Join the read in flight for `key`, or start one, and never answer from the + * settled entry: a caller of this always gets an answer from a request that + * was on the wire when it asked. * * objectui#13044. This is the door for a caller whose own contract is "ask * the server", which should still not put a second identical request on the * wire while one is already there. + * + * objectui#13057. The landing is stored, exactly as one {@link get} started + * would store it, so a later {@link get} for `key` may be answered from it. + * The caller asked the server; a reader that may be served what the cache + * already holds is served the answer that caller just received. */ share(key: string, fetcher: () => Promise): Promise { - return this.load(key, fetcher, false); + return this.load(key, fetcher); } /** * The in-flight half of {@link get} and {@link share}: join the request in * flight for `key`, or start one. * - * A landing is stored (when `store`), and its in-flight entry cleared, only + * A landing is stored, and its in-flight entry cleared, only * while it is still the request registered for `key` (objectui#13044). {@link invalidate} * and {@link clear} detach the request in flight, so a request sent before an * invalidation can neither answer a caller who asks after it nor write its * answer over a newer one. */ - private load(key: string, fetcher: () => Promise, store: boolean): Promise { + private load(key: string, fetcher: () => Promise): Promise { const existing = this.inflight.get(key); if (existing) { this.stats.coalesced++; @@ -176,7 +180,7 @@ export class MetadataCache { const run = async (): Promise => { try { const data = await fetcher(); - if (store && this.inflight.get(key) === self.promise) this.set(key, data); + if (this.inflight.get(key) === self.promise) this.set(key, data); return data; } finally { if (this.inflight.get(key) === self.promise) this.inflight.delete(key); diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 8ef74198e0..9ef6559106 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -908,13 +908,25 @@ export function serializeOrderBy(orderby: QueryParams['$orderby']): string | und * `MetadataProvider`'s eager `view` read (through {@link ObjectStackAdapter.getClient}), * {@link ObjectStackAdapter.listViews} and * {@link ObjectStackAdapter.listViewOverrides}. They share the request IN - * FLIGHT under this key: whoever asks while one is on the wire joins it. No - * answer is stored under it, so a reader that asks after a request landed - * asks the server again, exactly as before (objectui#13044 ruling 6268839381: - * a settled list would have needed every refresh, view write and organization - * switch in the console to drop it, and several of those paths write through - * a client this adapter never sees). The draft-overlaid list (`listViews` - * with `previewDrafts`) is a different request on a different client. + * FLIGHT under this key: whoever asks while one is on the wire joins it. + * + * The answer is also stored under it (objectui#13057, completing objectui#13044 + * ruling 6268839381 (a)-(c)). The client read still always asks the server; the + * adapter's own two readers are answered from what it stored, so the console's + * order (the eager read lands, then the object page reads the list and the + * overrides) costs one request. The settled entry is dropped by every path that + * should see a fresh list: + * + * - every view write: {@link ObjectStackAdapter.invalidateViewKeys}, the client + * write wrap in `installViewListShare`, and app-shell's console + * `MetadataClient` (`useMetadataClient`), which drops it after each of its + * view-capable write doors; + * - a user refresh (app-shell `ObjectView`'s `refreshData`); + * - an organization or session switch, and the publish pulse, which clear the + * whole cache (app-shell `MetadataProvider`). + * + * The draft-overlaid list (`listViews` with `previewDrafts`) is a different + * request on a different client, and never fills this key. */ const VIEW_LIST_KEY = 'list:view'; @@ -4204,12 +4216,13 @@ export class ObjectStackAdapter implements DataSource { * read the same `GET /api/v1/meta/view`. Each used to put its own request on * the wire, so one list-page open asked the server three times for one URL. * - * A caller of the client keeps its contract, "ask the server": nothing is - * answered from, or written to, a settled entry. What changes is only that - * it joins a request already in flight for the same URL instead of sending - * a second one (see {@link MetadataCache.share}). Only the bare call is - * routed: any argument after the type changes the URL, so such a call goes - * straight to the SDK. + * A caller of the client keeps its contract, "ask the server": it is never + * answered from a settled entry. It joins a request already in flight for + * the same URL instead of sending a second one, and its answer is stored + * under {@link VIEW_LIST_KEY} (see {@link MetadataCache.share}), which is + * what {@link readViewList} answers this adapter's own readers from + * (objectui#13057). Only the bare call is routed: any argument after the + * type changes the URL, so such a call goes straight to the SDK. * * Every caller gets its own copy of the answer. Before this, every reader * got its own parsed response, so a reader that changed what it was handed @@ -4230,9 +4243,9 @@ export class ObjectStackAdapter implements DataSource { // A write to a `view` row through this client, from any caller (a page // that saves a view with `getClient().meta.saveItem` directly, not only - // through {@link invalidateViewKeys}), detaches the list request in - // flight once it settles, so a read asked after the write never joins a - // request sent before it. + // through {@link invalidateViewKeys}), drops the stored list and detaches + // the list request in flight once it settles, so a read asked after the + // write is never answered by a list read before it. for (const door of ['saveItem', 'deleteItem', 'publishItem', 'rollbackItem'] as const) { const write = meta[door] as ((...args: any[]) => Promise) | undefined; if (typeof write !== 'function') continue; @@ -4245,14 +4258,17 @@ export class ObjectStackAdapter implements DataSource { } /** - * The published view list for this adapter's own readers: the client's - * read, which joins the request in flight for {@link VIEW_LIST_KEY} or - * starts one (objectui#13044). Never a stored answer; always a copy the - * caller may keep. A write to a view row detaches the request in flight - * through {@link invalidateViewKeys}, so a read asked after the write never - * joins one sent before it. + * The published view list for this adapter's own readers + * ({@link listViews}, {@link listViewOverrides}): the answer stored under + * {@link VIEW_LIST_KEY} while it holds one (objectui#13057), else the + * client's read, which joins the request in flight for that key or starts + * one, and stores its answer (objectui#13044). Always a copy the caller may + * keep. Every path that should see a fresh list drops the key first; the + * list is in {@link VIEW_LIST_KEY}'s doc. */ private readViewList(): Promise { + const settled = this.metadataCache.getCachedSync(VIEW_LIST_KEY); + if (settled !== undefined) return Promise.resolve(structuredClone(settled)); return this.client.meta.getItems('view'); } @@ -5544,8 +5560,9 @@ export class ObjectStackAdapter implements DataSource { // suite records, so a reordering is a visible decision rather than drift. this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`); this.metadataCache.invalidate?.(`view-overrides:${objectName}`); - // The list both readers derive from (objectui#13044): a read asked after - // this write must not join a list request sent before it. + // The list both readers derive from (objectui#13044, objectui#13057): a + // read asked after this write is answered neither by the stored list nor + // by a list request sent before it. this.metadataCache.invalidate?.(VIEW_LIST_KEY); } diff --git a/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts b/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts index fd44f2d786..b50e02d4fa 100644 --- a/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts +++ b/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts @@ -19,17 +19,20 @@ import { ObjectStackAdapter } from './index'; * | `getView` | `view:{object}:{viewId}` | * | `listViewOverrides` | `view-overrides:{object}` | * - * `listViews` has no key of its own. Five write paths used to invalidate a + * `listViews` has no per-object key of its own. Five write paths used to invalidate a * `views:{object}` key that no read path has ever populated — five permanent * no-ops. They are gone (#3778); the surviving invalidations still name the * keys that DO have readers, so the deletion cannot be mistaken for "cache * invalidation was dropped". * - * `listViews` stays uncached. objectui#13044 lets it, `listViewOverrides` and - * the console's eager view read JOIN one `GET /meta/view` already in flight, - * under the key `list:view`, and stores nothing there; every write path drops - * that key through the seam with the two keys above, so a read asked after a - * write never joins a request sent before it. + * objectui#13044 lets `listViews`, `listViewOverrides` and the console's eager + * view read JOIN one `GET /meta/view` already in flight, under the key + * `list:view`. objectui#13057 stores that answer there, and `listViews` and + * `listViewOverrides` read the stored list while it is held (settled within an + * open), so every write path drops that key through the seam with the two keys + * above: a read asked after a write is answered neither by the stored list nor + * by a request sent before it. The console's own drops of it (a user refresh, + * its `MetadataClient` view writes) are pinned in app-shell, where they live. * * ## objectui#4363 — every write path names BOTH keys * @@ -159,23 +162,31 @@ const VIEW = { }; describe('view metadata cache — invalidation names only keys with readers (#3778)', () => { - it('listViews reads the transport on every call and consults no cache key', async () => { + it('listViews reads the stored list:view while one is held, else the transport, and no other key (objectui#13057)', async () => { const { ds, getItems, cacheReads } = makeDS({ items: [VIEW] }); const probed: string[] = []; + let held: unknown; ds.metadataCache.getCachedSync = (key: string) => { probed.push(key); - return undefined; + return held; }; + // Nothing held: each call goes to the transport. Storing the answer is + // the real client's (its `getItems` wrap); this stand-in client has none. await ds.listViews('account'); await ds.listViews('account'); - - // Uncached: two calls, two round trips. objectui#13044 shares only a - // request already in flight (on the real client); nothing is stored. expect(getItems).toHaveBeenCalledTimes(2); expect(getItems).toHaveBeenCalledWith('view'); + + // A list held under `list:view` answers the read: no third round trip. + held = { items: [VIEW] }; + const views = await ds.listViews('account'); + expect(views.map((v: any) => v.name)).toEqual(['account.all']); + expect(getItems).toHaveBeenCalledTimes(2); + + // The one key it consults is the list; no per-object key is read. expect(cacheReads).toEqual([]); - expect(probed).toEqual([]); + expect(probed).toEqual(['list:view', 'list:view', 'list:view']); }); it('listViewOverrides reads back under the key the write paths invalidate', async () => { From 885a35a04c6d924890f809403c75b6f6745ea30b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:11:51 +0000 Subject: [PATCH 2/4] chore(changeset): settled view list within an open (objectui#13057) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .changeset/13057-settled-view-list.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/13057-settled-view-list.md diff --git a/.changeset/13057-settled-view-list.md b/.changeset/13057-settled-view-list.md new file mode 100644 index 0000000000..ed8b20d2b8 --- /dev/null +++ b/.changeset/13057-settled-view-list.md @@ -0,0 +1,6 @@ +--- +"@object-ui/app-shell": patch +"@object-ui/data-objectstack": patch +--- + +One object-page open reads the published view list (`GET /meta/view`) once (objectui#13057). The adapter now stores the list its client read and answers `listViews` and `listViewOverrides` from it, so the provider's eager view read serves the page's two readers. The client read itself still always asks the server. Every path that should see a fresh list drops it first: a refresh of the object page, every view write (through the adapter, its client, or the console's `MetadataClient`, whose view saves, publishes, resets, rollbacks and package publishes now drop it), and an organization or session switch. The package sheet's *Publish app* now announces the publish like every other publish path, so open views, forms and navigation re-read what went live. The draft-preview list read is unchanged and never fills the stored list. From 959a53f2b5f745ce188e895c3fa3504d31beb683 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:14:39 +0000 Subject: [PATCH 3/4] test(data-objectstack): hold the settled list in a const record (objectui#13057) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .../data-objectstack/src/viewCacheInvalidation.pin.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts b/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts index b50e02d4fa..b522f8f970 100644 --- a/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts +++ b/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts @@ -165,10 +165,10 @@ describe('view metadata cache — invalidation names only keys with readers (#37 it('listViews reads the stored list:view while one is held, else the transport, and no other key (objectui#13057)', async () => { const { ds, getItems, cacheReads } = makeDS({ items: [VIEW] }); const probed: string[] = []; - let held: unknown; + const cache: { held?: unknown } = {}; ds.metadataCache.getCachedSync = (key: string) => { probed.push(key); - return held; + return cache.held; }; // Nothing held: each call goes to the transport. Storing the answer is @@ -179,7 +179,7 @@ describe('view metadata cache — invalidation names only keys with readers (#37 expect(getItems).toHaveBeenCalledWith('view'); // A list held under `list:view` answers the read: no third round trip. - held = { items: [VIEW] }; + cache.held = { items: [VIEW] }; const views = await ds.listViews('account'); expect(views.map((v: any) => v.name)).toEqual(['account.all']); expect(getItems).toHaveBeenCalledTimes(2); From ca09edc9596c51e6025e4c70ac90f24b6b0311ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:21:39 +0000 Subject: [PATCH 4/4] test(app-shell): mark the untyped comment-mask import (objectui#13057) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .../src/views/metadata-admin/viewListDrop-13057.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app-shell/src/views/metadata-admin/viewListDrop-13057.test.tsx b/packages/app-shell/src/views/metadata-admin/viewListDrop-13057.test.tsx index c7eca886a0..15029de61f 100644 --- a/packages/app-shell/src/views/metadata-admin/viewListDrop-13057.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/viewListDrop-13057.test.tsx @@ -38,6 +38,7 @@ import { AdapterCtx } from '@object-ui/react'; import { MetadataClient, ObjectStackAdapter } from '@object-ui/data-objectstack'; import { useMetadataClient } from './useMetadata'; import { ITEM_WRITE_DOORS, PACKAGE_WRITE_DOORS } from './viewListDrop'; +// @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) import { maskComments } from '../../../../../scripts/js-comment-mask.mjs'; /** Local annotation, since the import above is untyped. */