diff --git a/.changeset/10572-app-shell-external-refresh-same-commit.md b/.changeset/10572-app-shell-external-refresh-same-commit.md new file mode 100644 index 0000000000..74403fe4f4 --- /dev/null +++ b/.changeset/10572-app-shell-external-refresh-same-commit.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': patch +--- + +The object page reads the console's refresh counter (`externalRefreshKey`: record-form save, undo, redo) in the render that carries it, instead of copying it into its own counter one commit later (objectui#10572). The list now also re-reads on the data-invalidation bus, and undo and redo declare their change there in the same tick as the counter bump, so the delayed copy made one undo cost two list reads. Every reader of the page's refresh signal — the list, the saved-view and draft reads, the record count — still sees each external bump. diff --git a/.changeset/10572-plugin-calendar-reads-invalidation-bus.md b/.changeset/10572-plugin-calendar-reads-invalidation-bus.md new file mode 100644 index 0000000000..853ad03773 --- /dev/null +++ b/.changeset/10572-plugin-calendar-reads-invalidation-bus.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-calendar': patch +--- + +`object-calendar` re-reads its events when the data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a change to the object it queries (objectui#10572). A write that bypasses the data source — a page action over raw HTTP, a flow, a server action — fires no `onMutation`, so a calendar on a page used to show it only when the whole page was remounted. Inline and external events are still the host's to refresh. diff --git a/.changeset/10572-plugin-dashboard-reads-invalidation-bus.md b/.changeset/10572-plugin-dashboard-reads-invalidation-bus.md new file mode 100644 index 0000000000..a1634f26e8 --- /dev/null +++ b/.changeset/10572-plugin-dashboard-reads-invalidation-bus.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-dashboard': patch +--- + +`object-metric` (`ObjectMetricWidget`) re-reads its value when the data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a change to the object it aggregates (objectui#10572). A write that bypasses the data source — a page action over raw HTTP, a flow, a server action — used to leave the tile stale until the whole page was remounted. diff --git a/.changeset/10572-plugin-form-reads-invalidation-bus.md b/.changeset/10572-plugin-form-reads-invalidation-bus.md new file mode 100644 index 0000000000..72a58fe492 --- /dev/null +++ b/.changeset/10572-plugin-form-reads-invalidation-bus.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-form': patch +--- + +`object-form` in edit mode re-reads its record when the data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a change to that record, its object, or everything (objectui#10572). The re-read is gated on pristine: an untouched form refreshes in place without remounting, while a form holding unsaved input keeps both the typed values and the version token its edit started from, so a real conflict still surfaces at save. One held re-read is replayed once the edit is saved or the form returns to pristine. A change to another record of the object reads nothing. diff --git a/.changeset/10572-plugin-kanban-reads-invalidation-bus.md b/.changeset/10572-plugin-kanban-reads-invalidation-bus.md new file mode 100644 index 0000000000..49a9c4652a --- /dev/null +++ b/.changeset/10572-plugin-kanban-reads-invalidation-bus.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-kanban': patch +--- + +`object-kanban` re-reads its cards when the data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a change to the object it queries (objectui#10572). A write that bypasses the data source — a page action over raw HTTP, a flow, a server action — fires no `onMutation`, so a board on a page used to show it only when the whole page was remounted. External, bound and inline cards are still the host's to refresh. diff --git a/.changeset/10572-plugin-list-reads-invalidation-bus.md b/.changeset/10572-plugin-list-reads-invalidation-bus.md new file mode 100644 index 0000000000..d9d1a15e0c --- /dev/null +++ b/.changeset/10572-plugin-list-reads-invalidation-bus.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-list': patch +--- + +`list-view` re-reads its rows when the data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a change to the object it queries (objectui#10572). A write that bypasses the data source — a page action over raw HTTP, a flow, a server action — fires no `onMutation`, so a list on a page used to show it only when the whole page was remounted. The re-read keeps the list mounted; rows a host hands the list inline are still the host's to refresh. diff --git a/packages/app-shell/src/views/ObjectView.externalRefreshSum-10572.test.tsx b/packages/app-shell/src/views/ObjectView.externalRefreshSum-10572.test.tsx new file mode 100644 index 0000000000..47d1f2000e --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.externalRefreshSum-10572.test.tsx @@ -0,0 +1,272 @@ +/** + * 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#10572 — the console's `externalRefreshKey` reaches the object page's + * readers IN THE COMMIT that carries it, so one write is one list read. + * + * `ListView` now reads the data-invalidation bus as well as `refreshTrigger`. + * The console's undo / redo bump `externalRefreshKey` AND declare the change on + * the bus in the same tick; the page used to MIRROR the prop into its own + * counter through a passive effect, one commit later, so the list saw the bus + * nonce in one commit and the counter in the next and read twice + * (`ObjectView.refreshInPlace-10035.test.tsx` went red on exactly that). The + * page now sums the prop into its refresh signal in the render. + * + * What this file adds to that pin: the sum still reaches the page's OTHER + * `refreshKey` readers — the record-count probe (`$top: 0`) is the one counted + * here — and a counter-only bump (nothing on the bus) still reaches the list, + * once. Harness copied from the objectui#10035 pin, minus its gantt stand-in. + */ + +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, + // so a fresh object per call would loop the fetch on its own. + 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. + * `resolveAllConflicts` is ONE function for the file's life: the page's realtime + * effect names it, and the real hook hands back a `useCallback` result, so a + * fresh function per render would re-fire that effect on every render. + */ +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 { notifyDataChanged } from '@object-ui/react'; +import { ObjectView } from './ObjectView'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; + +const OBJECT_NAME = 'duly_task'; + +/** + * The view under test is always `all`. Its `description` renders as + * `view-description` INSIDE `ListView`, which is the node whose identity (a) + * reads. + */ +function objectsWith(allView: Record) { + return [ + { + name: OBJECT_NAME, + label: 'Task', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + stage: { type: 'select', label: 'Stage', options: [{ label: 'A', value: 'a' }] }, + starts_on: { type: 'date', label: 'Starts' }, + ends_on: { type: 'date', label: 'Ends' }, + }, + listViews: { + all: { label: 'All', columns: ['name', 'stage'], description: 'Every task', ...allView }, + }, + }, + ]; +} + +/** The list queries `ListView` issued; the page's `$top: 0` count probe is excluded. */ +let listQueries = 0; +/** The page's own record-count probe (`$top: 0`), a non-list `refreshKey` reader. */ +let countQueries = 0; +/** + * One row, never none: `ListView` draws its loading skeleton IN PLACE of the + * visualization while it refetches an EMPTY list, which would unmount the + * gantt for reasons that have nothing to do with the key under test. + */ +const ROW = { id: 't1', name: 'Task 1', stage: 'a', starts_on: '2026-01-01', ends_on: '2026-01-05' }; +function makeDataSource() { + return { + find: vi.fn(async (_object: string, params: any) => { + if (params?.$top !== 0) listQueries++; + else countQueries++; + return { data: [{ ...ROW }], total: 1 }; + }), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + } as any; +} + +/** A window long enough for every effect a step schedules to have fired. */ +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 400))); + +/** + * Mounts the page and returns three ways a write reaches it: + * - `write` — the console's own write, as `AppContent` delivers an undo: it + * bumps `externalRefreshKey` AND declares the change on the bus (a form + * save's declaration comes from the dataSource bridge instead; the two + * signals the page receives are the same); + * - `bumpOnly` — `externalRefreshKey` alone, with nothing on the bus (a + * host that bumps the counter and declares nothing); + * - `realtime` — another user's write, which the page learns of on its + * realtime channel and must declare on the bus itself. + */ +async function mountPage( + allView: Record, +): Promise<{ write: () => Promise; bumpOnly: () => Promise; realtime: () => Promise }> { + const dataSource = makeDataSource(); + const objects = objectsWith(allView); + let bump: () => void = () => {}; + let rerender: () => void = () => {}; + function Harness() { + const [externalRefreshKey, setExternalRefreshKey] = React.useState(0); + const [, setTick] = React.useState(0); + // Handed out from an effect, not assigned during render (react-hooks/globals). + React.useEffect(() => { + bump = () => setExternalRefreshKey((n) => n + 1); + rerender = () => setTick((n) => n + 1); + }, []); + return ( + + + + {}} + externalRefreshKey={externalRefreshKey} + /> + } + /> + + + + ); + } + render(); + await settle(); + return { + write: async () => { + await act(async () => { + bump(); + notifyDataChanged({ objectName: OBJECT_NAME }); + }); + await settle(); + }, + bumpOnly: async () => { + await act(async () => { + bump(); + }); + await settle(); + }, + realtime: async () => { + await act(async () => { + realtime.lastMessage = { type: 'update', objectName: OBJECT_NAME, at: Date.now() }; + rerender(); + }); + await settle(); + }, + }; +} + +beforeEach(() => { + cleanup(); + listQueries = 0; + countQueries = 0; + realtime.lastMessage = null; + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +const GRID = { type: 'grid' }; + +describe('the console refresh counter reaches the object page in the commit that carries it (objectui#10572)', () => { + it('an undo-shaped write (counter bump AND bus notice, one tick) costs one list read and one count read', async () => { + const page = await mountPage(GRID); + const list = listQueries; + const count = countQueries; + + await page.write(); + + expect(listQueries - list, 'one write, one list read — the counter and the bus notice landed in different commits').toBe(1); + expect(countQueries - count, 'the record-count probe must re-read on an external bump').toBe(1); + }); + + it('a counter-only bump (nothing on the bus) still re-reads the list once and the count once', async () => { + const page = await mountPage(GRID); + const list = listQueries; + const count = countQueries; + + await page.bumpOnly(); + + expect(listQueries - list, 'the external counter no longer reaches `ListView` as `refreshTrigger`').toBe(1); + expect(countQueries - count, 'the external counter no longer reaches the page\'s other `refreshKey` readers').toBe(1); + }); + + it('a realtime change (the page\'s own counter AND its own bus notice) costs one list read', async () => { + const page = await mountPage(GRID); + const list = listQueries; + + await page.realtime(); + + expect(listQueries - list).toBe(1); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 51a823a8ab..14b4d42131 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -1509,8 +1509,26 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an // mounted — every hook below can therefore run unconditionally. const objectDef = objects.find((o: any) => o.name === objectName); - // Refresh trigger — bumped after view CRUD or external data mutations. - const [refreshKey, setRefreshKey] = useState(0); + // Refresh trigger — bumped after view CRUD or this page's own data writes. + const [ownRefreshKey, setRefreshKey] = useState(0); + /** + * objectui#10572 — the page's refresh signal: its own counter plus the + * console's `externalRefreshKey` (record-form save, undo, redo), summed IN + * THE RENDER that receives the prop. Both only grow, so the sum moves + * whenever either does, and every reader below sees an external bump in the + * same commit as the prop. + * + * It used to be MIRRORED instead: a passive effect copied each external + * bump into this counter one commit later. The console declares the same + * undo / redo on the data-invalidation bus in the same tick as the bump, + * and `ListView` reads that bus, so the mirror split one write into two + * list reads — the bus nonce in one commit, `refreshTrigger` in the next. + * PR objectui#10494's contract is that a writer's two notices land in one + * render; summing here keeps this host inside it. ⛔ Do not reintroduce the + * mirror, and do not add `externalRefreshKey` on top of a value that still + * mirrors it — either one reaches the list twice. + */ + const refreshKey = ownRefreshKey + (typeof externalRefreshKey === 'number' ? externalRefreshKey : 0); /** * objectui#10035 — this page learned that the object's DATA changed by a @@ -1617,12 +1635,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an [objectDef, getObjectApiOperations], ); - // Propagate externally-triggered refreshes (e.g. global ModalForm submit) - // into our internal refreshKey so list/data effects re-run. - useEffect(() => { - if (externalRefreshKey === undefined || externalRefreshKey === 0) return; - setRefreshKey(k => k + 1); - }, [externalRefreshKey]); + // Externally-triggered refreshes (e.g. global ModalForm submit, undo, redo) + // reach every `refreshKey` reader through the sum declared with the counter + // above (objectui#10572), not through a mirroring effect. /** * [#5153] The object-list toolbar's CREATE predicates — the `create` half diff --git a/packages/plugin-calendar/src/ObjectCalendar.invalidationRefetch-10572.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.invalidationRefetch-10572.test.tsx new file mode 100644 index 0000000000..535ef6eca3 --- /dev/null +++ b/packages/plugin-calendar/src/ObjectCalendar.invalidationRefetch-10572.test.tsx @@ -0,0 +1,95 @@ +/** + * 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#10572 — an `object-calendar` block re-reads its events when the data-invalidation + * bus (`notifyDataChanged` from `@object-ui/react`) reports a write to the + * object it queries. + * + * Before this card a page action over raw HTTP (an `api` target, a flow, a + * server action) fires no `onMutation`, so this block on a page learned of such + * a write only because `PageView` remounted the whole page — the remount + * objectui#10519 removes. Its fetch effect now names the + * `useDataInvalidation` nonce for the object it queries (the objectui#10494 + * shape). + * + * Rendered through the real `SchemaRenderer` and this package's own + * registration, over a fake data source that counts reads. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, notifyDataChanged } from '@object-ui/react'; +// Registers `object-calendar`. +import './index'; + +beforeEach(() => { + // Best-effort metadata probes are not what these cases are about. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); +}); +afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); +}); + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 150))); + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [{ id: '1', name: 'Kickoff', due: '2026-09-01' }], total: 1 })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: 'event', fields: { name: { type: 'text' }, due: { type: 'date' } } })), + }; +} + +const renderBlock = (schema: Record, ds: ReturnType) => + render( + + + , + ); + +const BLOCK = { type: 'object-calendar', objectName: 'event', dateField: 'due', titleField: 'name' }; + +describe('object-calendar re-reads on the data-invalidation bus (objectui#10572)', () => { + it('an unscoped change (objectName "*") re-runs its query once', async () => { + const ds = makeDataSource(); + renderBlock(BLOCK, ds); + await settle(); + expect(ds.find).toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + + expect(ds.find, 'the block never re-read after the bus reported a change').toHaveBeenCalledTimes(2); + }); + + it('a change to its own object re-runs its query once; another object does not', async () => { + const ds = makeDataSource(); + renderBlock(BLOCK, ds); + await settle(); + expect(ds.find).toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: 'some_other_object' }); + }); + await settle(); + expect(ds.find).toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: 'event', recordId: '1' }); + }); + await settle(); + expect(ds.find).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 83f8524455..e1f8c0f2d4 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -35,6 +35,7 @@ import { declaredUserMessage, useSettledSchema, NonGridRowCeilingNote, + useDataInvalidation, } from '@object-ui/react'; import { RECORD_OVERLAY_DEFAULT_WIDTH, @@ -576,6 +577,18 @@ export const ObjectCalendar: React.FC = ({ } }, [externalLoading, hasExternalData]); + // objectui#10572 — the data-invalidation bus (`notifyDataChanged` from + // `@object-ui/react`), read the objectui#10494 way: the nonce moves when a + // write to the object this calendar QUERIES is declared, and the fetch + // effect below names it, so the events are re-read. The `onMutation` + // subscription above cannot see a write that bypasses the data source (a + // page action over raw HTTP); the bus can. Subscribed only on the `object` + // provider without external data — inline and external events are not this + // effect's query. + const invalidationNonce = useDataInvalidation( + !hasExternalData && dataProvider === 'object' ? schemaObjectName || undefined : undefined, + ); + // Fetch data based on provider useEffect(() => { // Skip internal fetch when data is managed by a parent component @@ -758,7 +771,7 @@ export const ObjectCalendar: React.FC = ({ fetchData(); return () => { isMounted = false; }; }, [hasExternalData, dataProvider, schemaObjectName, dataItems, dataSource, hasInlineData, - schema.filter, schema.sort, refreshKey, objectSchemaReady, objectSchema, perms]); + schema.filter, schema.sort, refreshKey, objectSchemaReady, objectSchema, perms, invalidationNonce]); // Transform data to calendar events, and separate out the records that have // no date to be placed on at all (objectui#7071 — see the early return in the diff --git a/packages/plugin-dashboard/src/ObjectMetricWidget.invalidationRefetch-10572.test.tsx b/packages/plugin-dashboard/src/ObjectMetricWidget.invalidationRefetch-10572.test.tsx new file mode 100644 index 0000000000..4463f97b2b --- /dev/null +++ b/packages/plugin-dashboard/src/ObjectMetricWidget.invalidationRefetch-10572.test.tsx @@ -0,0 +1,96 @@ +/** + * 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#10572 — an `object-metric` block (`ObjectMetricWidget`) re-reads its value when the + * data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a + * write to the object it aggregates. + * + * Before this card a page action over raw HTTP (an `api` target, a flow, a + * server action) fires no `onMutation`, so this block on a page learned of such + * a write only because `PageView` remounted the whole page — the remount + * objectui#10519 removes. Its fetch effect now names the + * `useDataInvalidation` nonce for the object it queries (the objectui#10494 + * shape). + * + * Rendered through the real `SchemaRenderer` and this package's own + * registration, over a fake data source that counts reads. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, notifyDataChanged } from '@object-ui/react'; +// Registers `object-metric`. +import './index'; + +beforeEach(() => { + // Best-effort metadata probes are not what these cases are about. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); +}); +afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); +}); + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 150))); + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(), + aggregate: vi.fn(async () => [{ amount: 7 }]), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: 'deal', fields: { amount: { type: 'number' } } })), + }; +} + +const renderBlock = (schema: Record, ds: ReturnType) => + render( + + + , + ); + +const BLOCK = { type: 'object-metric', objectName: 'deal', aggregate: { field: 'amount', function: 'sum' }, label: 'Pipeline' }; + +describe('object-metric re-reads on the data-invalidation bus (objectui#10572)', () => { + it('an unscoped change (objectName "*") re-runs its query once', async () => { + const ds = makeDataSource(); + renderBlock(BLOCK, ds); + await settle(); + expect(ds.aggregate).toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + + expect(ds.aggregate, 'the block never re-read after the bus reported a change').toHaveBeenCalledTimes(2); + }); + + it('a change to its own object re-runs its query once; another object does not', async () => { + const ds = makeDataSource(); + renderBlock(BLOCK, ds); + await settle(); + expect(ds.aggregate).toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: 'some_other_object' }); + }); + await settle(); + expect(ds.aggregate).toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: 'deal', recordId: '1' }); + }); + await settle(); + expect(ds.aggregate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/plugin-dashboard/src/ObjectMetricWidget.tsx b/packages/plugin-dashboard/src/ObjectMetricWidget.tsx index f7b0a772c1..bebcf3b416 100644 --- a/packages/plugin-dashboard/src/ObjectMetricWidget.tsx +++ b/packages/plugin-dashboard/src/ObjectMetricWidget.tsx @@ -7,7 +7,7 @@ */ import React, { useState, useEffect, useContext, useCallback, useMemo } from 'react'; -import { SchemaRendererContext, useFilterScope } from '@object-ui/react'; +import { SchemaRendererContext, useFilterScope, useDataInvalidation } from '@object-ui/react'; import { isDrillEnabled, resolveDrillTitle, isStructuredGroupBy, objectAggregateSpecQuery } from '@object-ui/core'; import type { DrillDownConfig, I18nLabel, ObjectChartSchema } from '@object-ui/types'; import { @@ -421,6 +421,13 @@ export const ObjectMetricWidget: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [objectName, aggregateKey, resolvedFilterKey, compareToKey, computeOne]); + // objectui#10572 — the data-invalidation bus (`notifyDataChanged` from + // `@object-ui/react`), read the objectui#10494 way: the nonce moves when a + // write to the object this metric AGGREGATES is declared, and the fetch + // effect below names it, so the value is re-read in place. Without it a page + // action over raw HTTP left the tile stale unless the page was remounted. + const invalidationNonce = useDataInvalidation(dataSource ? objectName || undefined : undefined); + useEffect(() => { const mounted = { current: true }; @@ -434,7 +441,7 @@ export const ObjectMetricWidget: React.FC = ({ } return () => { mounted.current = false; }; - }, [dataSource, objectName, fetchMetric]); + }, [dataSource, objectName, fetchMetric, invalidationNonce]); // Determine the display value: // - If we fetched a value from the server, use it diff --git a/packages/plugin-form/src/ObjectForm.invalidationRefetch-10572.test.tsx b/packages/plugin-form/src/ObjectForm.invalidationRefetch-10572.test.tsx new file mode 100644 index 0000000000..b765df2dbb --- /dev/null +++ b/packages/plugin-form/src/ObjectForm.invalidationRefetch-10572.test.tsx @@ -0,0 +1,179 @@ +/** + * 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#10572 — an `object-form` in edit mode re-reads its record on the + * data-invalidation bus (`notifyDataChanged` from `@object-ui/react`), GATED ON + * PRISTINE (the seat's ruling on the card's fork, option A). + * + * Measured before the gate: a bare re-read overwrote a dirty field — through + * the loading branch (the form unmounted) and, without it, through the form + * renderer's by-value reset to the new `defaultValues` — and advanced the OCC + * token a save sends. So: + * (a) a pristine form re-reads in place, 1 → 2 reads, same input node; + * (b) a dirty form HOLDS the change: the typed value stays on screen and the + * save still sends the `ifMatch` the edit started from; + * (c) once that edit is saved — or reverted to pristine — exactly one held + * re-read is replayed; + * (d) a change scoped to another record of the object reads nothing. + * (b) runs on both the flat and the sectioned render paths. + * + * Rendered through the real `SchemaRenderer` and this package's registration, + * over a fake data source whose record moves on the server. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup, fireEvent, screen } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, notifyDataChanged } from '@object-ui/react'; +// Registers `object-form`. +import './index'; + +beforeEach(() => { + // Best-effort metadata probes are not what these cases are about. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); +}); +afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); +}); + +const settle = (ms = 150) => act(() => new Promise((resolve) => setTimeout(resolve, ms))); + +/** A server holding one `deal` record whose name and version can move. */ +function makeServer() { + const record = { id: '1', name: 'Server v1', note: 'n1', updated_at: '2026-01-01T00:00:00.000Z' }; + const ds = { + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(async () => ({ ...record })), + create: vi.fn(), + update: vi.fn(async (_object: string, _id: string, data: Record) => { + Object.assign(record, data, { updated_at: '2026-01-03T00:00:00.000Z' }); + return { ...record }; + }), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ + name: 'deal', + fields: { name: { type: 'text', label: 'Name' }, note: { type: 'text', label: 'Note' } }, + })), + }; + /** Another writer moves the record on the server. */ + const moveOnServer = () => { + record.name = 'Server v2'; + record.updated_at = '2026-01-02T00:00:00.000Z'; + }; + return { ds, moveOnServer }; +} + +const FLAT = { type: 'object-form', objectName: 'deal', mode: 'edit', recordId: '1' }; +const SECTIONED = { ...FLAT, sections: [{ label: 'Main', fields: ['name', 'note'] }] }; + +const renderForm = (schema: Record, ds: ReturnType['ds']) => + render( + + + , + ); + +const nameInput = () => document.querySelector('input[name="name"]') as HTMLInputElement; + +async function mountEditForm(schema: Record = FLAT) { + const server = makeServer(); + renderForm(schema, server.ds); + await settle(400); + expect(nameInput().value).toBe('Server v1'); + expect(server.ds.findOne).toHaveBeenCalledTimes(1); + return server; +} + +async function type(value: string) { + fireEvent.change(nameInput(), { target: { value } }); + await settle(); +} + +async function bus(change: { objectName: string; recordId?: string }) { + await act(async () => { + notifyDataChanged(change); + }); + await settle(); +} + +async function save() { + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Update' })); + }); + await settle(300); +} + +describe('object-form edit mode re-reads on the bus, gated on pristine (objectui#10572)', () => { + it('(a) a pristine form re-reads in place: 1 → 2 reads, the same input shows the new value', async () => { + const { ds, moveOnServer } = await mountEditForm(); + const before = nameInput(); + moveOnServer(); + + await bus({ objectName: '*' }); + + expect(ds.findOne, 'a pristine form never re-read after the bus reported a change').toHaveBeenCalledTimes(2); + expect(nameInput().value).toBe('Server v2'); + expect(nameInput(), 'the re-read went through the loading branch and remounted the form').toBe(before); + }); + + for (const [path, schema] of [['flat', FLAT], ['sectioned', SECTIONED]] as const) { + it(`(b) ${path}: a dirty form holds the change — the typed value stays and the save sends the original token`, async () => { + const { ds, moveOnServer } = await mountEditForm(schema); + await type('User typed'); + moveOnServer(); + + await bus({ objectName: 'deal', recordId: '1' }); + + expect(nameInput().value, 'the bus event overwrote a dirty field').toBe('User typed'); + expect(ds.findOne, 'a dirty form re-read while it held unsaved input').toHaveBeenCalledTimes(1); + + await save(); + expect(ds.update).toHaveBeenCalledTimes(1); + const [, , payload, options] = ds.update.mock.calls[0] as unknown as [string, string, Record, { ifMatch?: string }]; + expect(payload.name).toBe('User typed'); + expect(options?.ifMatch, 'the held change advanced the OCC token under the dirty edit').toBe('2026-01-01T00:00:00.000Z'); + }); + } + + it('(c) after the dirty edit is saved, exactly one held re-read is replayed', async () => { + const { ds, moveOnServer } = await mountEditForm(); + await type('User typed'); + moveOnServer(); + await bus({ objectName: '*' }); + await bus({ objectName: 'deal' }); + expect(ds.findOne).toHaveBeenCalledTimes(1); + + await save(); + + expect(ds.findOne, 'two held changes are replayed as ONE re-read after the save').toHaveBeenCalledTimes(2); + expect(nameInput().value).toBe('User typed'); + }); + + it('(c) after the dirty edit is reverted to pristine, exactly one held re-read is replayed', async () => { + const { ds, moveOnServer } = await mountEditForm(); + await type('User typed'); + moveOnServer(); + await bus({ objectName: '*' }); + expect(ds.findOne).toHaveBeenCalledTimes(1); + + await type('Server v1'); + + expect(ds.findOne, 'the held change was not replayed when the form returned to pristine').toHaveBeenCalledTimes(2); + expect(nameInput().value).toBe('Server v2'); + }); + + it('(d) a change scoped to another record of the object reads nothing', async () => { + const { ds } = await mountEditForm(); + + await bus({ objectName: 'deal', recordId: '2' }); + await bus({ objectName: 'some_other_object' }); + + expect(ds.findOne).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index b06ec3d68f..104a4503ac 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -15,7 +15,7 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import type { ObjectFormSchema, FormField, FormSchema, DataSource } from '@object-ui/types'; -import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react'; +import { SchemaRenderer, useSafeFieldLabel, useDataInvalidation } from '@object-ui/react'; import { mapFieldTypeToFormType, buildValidationRules, formatFileSize } from '@object-ui/fields'; import { useIsMobile, toast } from '@object-ui/components'; import { resolveEffectiveCrudAffordances } from '@object-ui/core'; @@ -716,8 +716,62 @@ const SimpleObjectForm: React.FC = ({ } }, [schema.objectName, dataSource, hasInlineFields]); + // objectui#10572 — the data-invalidation bus (`notifyDataChanged` from + // `@object-ui/react`), read the objectui#10494 way for the record this form + // READS (edit/view mode, no inline fields): the nonce moves on a change to + // this record, to its object as a whole, or `'*'`. A change scoped to another + // record of the object does not move it. + // + // Unlike a list, a form holds the user's in-progress input, and a re-read + // replaces `initialData` — which the form renderer resets to BY VALUE and + // which supplies the OCC token a save sends. So the re-read is GATED ON + // PRISTINE (the seat's ruling on the objectui#10572 fork, option A): + // - pristine → re-read in place (no loading branch, so no remount); + // - dirty → HOLD the change: the typed values and the OCC token the edit + // started from both stay, so a real conflict still surfaces at save + // through the conflict dialog; ONE re-read is replayed when the form is + // pristine again (the renderer's `onDirtyChange(false)` after a reset or + // a revert) or when this form's save lands. + // Dirtiness is read from the form renderer's existing `onDirtyChange` + // channel into a private ref; nothing new is declared on any schema. + const readsRecord = !!schema.recordId && schema.mode !== 'create' && !hasInlineFields; + const busNonce = useDataInvalidation( + readsRecord ? schema.objectName || undefined : undefined, + readsRecord ? String(schema.recordId) : undefined, + ); + const formDirtyRef = React.useRef(false); + const heldChangeRef = React.useRef(false); + const seenBusNonceRef = React.useRef(busNonce); + // Bumped once per re-read this form decides to run; the fetch effect names it. + const [recordRefetch, setRecordRefetch] = useState(0); + // The `recordRefetch` value the fetch effect last ran for — a run that moved + // it is a re-read IN PLACE and must not enter the loading branch. + const appliedRecordRefetchRef = React.useRef(0); + useEffect(() => { + if (busNonce === seenBusNonceRef.current) return; + seenBusNonceRef.current = busNonce; + if (formDirtyRef.current) { + heldChangeRef.current = true; + return; + } + setRecordRefetch((n) => n + 1); + }, [busNonce]); + const replayHeldChange = useCallback(() => { + if (!heldChangeRef.current) return; + heldChangeRef.current = false; + setRecordRefetch((n) => n + 1); + }, []); + // Memoised for cost only (the renderer re-subscribes on a new identity); + // nothing here depends on the identity it returns. + const handleDirtyChange = useCallback((dirty: boolean) => { + formDirtyRef.current = dirty; + if (!dirty) replayHeldChange(); + }, [replayHeldChange]); + // Fetch initial data for edit/view modes (skip if using inline data) useEffect(() => { + const inPlace = recordRefetch !== appliedRecordRefetchRef.current; + appliedRecordRefetchRef.current = recordRefetch; const fetchInitialData = async () => { if (!schema.recordId || schema.mode === 'create') { // Seeded from something other than a read: no baseline to diff against. @@ -738,7 +792,9 @@ const SimpleObjectForm: React.FC = ({ return; } - setLoading(true); + // A bus re-read keeps the form mounted (objectui#10572): the loading + // branch would unmount it and drop the fields' own UI state. + if (!inPlace) setLoading(true); try { const data = await dataSource.findOne(schema.objectName, schema.recordId); // Tagged with the object and record it was read for, so a save that @@ -756,7 +812,7 @@ const SimpleObjectForm: React.FC = ({ if (objectSchema && !hasInlineFields) { fetchInitialData(); } - }, [schema.objectName, schema.recordId, schema.mode, schema.initialValues, schema.initialData, dataSource, objectSchema, hasInlineFields]); + }, [schema.objectName, schema.recordId, schema.mode, schema.initialValues, schema.initialData, dataSource, objectSchema, hasInlineFields, recordRefetch]); // FormField `visibleOn` (spec FormFieldSchema CEL expression) is consumed // directly by the form renderer via the canonical engine — it accepts both @@ -1175,6 +1231,13 @@ const SimpleObjectForm: React.FC = ({ // The write landed: the next save from this still-mounted form diffs // against the record as it now stands, not as first read. loadedRecordRef.current = advanceLoadedRecord(loadedRecordRef.current, schema, writePayload); + // objectui#10572 — the edit has landed, so what the form shows is what the + // server holds: a change held while it was dirty (its own write's bus + // echo included) is replayed now, and a later echo re-reads in place + // instead of being held behind input that is no longer unsaved. The next + // keystroke reports dirty again through `onDirtyChange`. + formDirtyRef.current = false; + replayHeldChange(); // Call success callback if provided, else give default feedback. Skip the // default when a `submitHandler` owns persistence (e.g. MasterDetailForm @@ -1315,7 +1378,7 @@ const SimpleObjectForm: React.FC = ({ throw err; } - }, [schema, dataSource, hasInlineFields, perms, objectSchema, saveWithOcc, initialData, uploadGate.uploading, uploadGate.reason]); + }, [schema, dataSource, hasInlineFields, perms, objectSchema, saveWithOcc, initialData, uploadGate.uploading, uploadGate.reason, replayHeldChange]); // Handle form cancellation const handleCancel = useCallback(() => { @@ -1645,6 +1708,7 @@ const SimpleObjectForm: React.FC = ({ cancelLabel: schema.cancelText, onSubmit: handleSubmit, onCancel: handleCancel, + onDirtyChange: handleDirtyChange, } as FormSchema} /> @@ -1800,6 +1864,7 @@ const SimpleObjectForm: React.FC = ({ previousValues, onSubmit: handleSubmit, onCancel: handleCancel, + onDirtyChange: handleDirtyChange, className: schema.className, mobileStickyActions: Boolean(mobileOpts?.stickyActions), }; diff --git a/packages/plugin-kanban/src/ObjectKanban.invalidationRefetch-10572.test.tsx b/packages/plugin-kanban/src/ObjectKanban.invalidationRefetch-10572.test.tsx new file mode 100644 index 0000000000..a28c23ae66 --- /dev/null +++ b/packages/plugin-kanban/src/ObjectKanban.invalidationRefetch-10572.test.tsx @@ -0,0 +1,112 @@ +/** + * 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#10572 — an `object-kanban` block re-reads its cards when the data-invalidation + * bus (`notifyDataChanged` from `@object-ui/react`) reports a write to the + * object it queries. + * + * Before this card a page action over raw HTTP (an `api` target, a flow, a + * server action) fires no `onMutation`, so this block on a page learned of such + * a write only because `PageView` remounted the whole page — the remount + * objectui#10519 removes. Its fetch effect now names the + * `useDataInvalidation` nonce for the object it queries (the objectui#10494 + * shape). + * + * Rendered through the real `SchemaRenderer` and this package's own + * registration, over a fake data source that counts reads. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, notifyDataChanged } from '@object-ui/react'; +// Registers `object-kanban`. +import './index'; +// The board renders inside `KanbanRenderer`'s `React.lazy` boundary; importing +// the chunk at module scope keeps the cold transform out of the settle window +// (the objectui#3010 rule, same specifier as `index.tsx`'s factory). +import './KanbanImpl'; + +beforeEach(() => { + // Best-effort metadata probes are not what these cases are about. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); +}); +afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); +}); + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 150))); + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [{ id: '1', name: 'Acme', status: 'open' }], total: 1 })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: 'deal', fields: { name: { type: 'text' }, status: { type: 'text' } } })), + }; +} + +const renderBlock = (schema: Record, ds: ReturnType) => + render( + + + , + ); + +const BLOCK = { type: 'object-kanban', objectName: 'deal', groupBy: 'status', columns: [{ id: 'open', title: 'Open' }] }; + +describe('object-kanban re-reads on the data-invalidation bus (objectui#10572)', () => { + it('an unscoped change (objectName "*") re-runs its query once', async () => { + const ds = makeDataSource(); + renderBlock(BLOCK, ds); + await settle(); + expect(ds.find).toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + + expect(ds.find, 'the block never re-read after the bus reported a change').toHaveBeenCalledTimes(2); + }); + + it('a change to its own object re-runs its query once; another object does not', async () => { + const ds = makeDataSource(); + renderBlock(BLOCK, ds); + await settle(); + expect(ds.find).toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: 'some_other_object' }); + }); + await settle(); + expect(ds.find).toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: 'deal', recordId: '1' }); + }); + await settle(); + expect(ds.find).toHaveBeenCalledTimes(2); + }); + + it('a block drawing inline rows queries nothing on an invalidation', async () => { + const ds = makeDataSource(); + renderBlock({ ...BLOCK, data: [{ id: '1', name: 'Inline', status: 'open' }] }, ds); + await settle(); + + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + + expect(ds.find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index e6b3302de9..e9ef966402 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -17,6 +17,7 @@ import { isPermissionError, declaredUserMessage, useSettledSchema, + useDataInvalidation, } from '@object-ui/react'; import { NavigationOverlay, @@ -560,6 +561,17 @@ export const ObjectKanban: React.FC = ({ // below re-keys on what it says, never on which array it is. const sortKey = JSON.stringify(schema.sort ?? null); + // objectui#10572 — the data-invalidation bus (`notifyDataChanged` from + // `@object-ui/react`), read the objectui#10494 way: the nonce moves when a + // write to the object this board QUERIES is declared, and the fetch effect + // below names it, so the cards are re-read in place. The `onMutation` + // subscription above cannot see a write that bypasses the data source (a + // page action over raw HTTP); the bus can. Subscribed only when the board + // fetches for itself — external, bound or inline cards are the host's. + const invalidationNonce = useDataInvalidation( + !hasExternalData && !boundData && !schema.data ? schema.objectName || undefined : undefined, + ); + useEffect(() => { // Skip internal fetch when data is managed by a parent component if (hasExternalData) return; @@ -726,7 +738,7 @@ export const ObjectKanban: React.FC = ({ // `ObjectTimeline` spelling: an array rebuilt with the same members must not // refetch the board. // eslint-disable-next-line react-hooks/exhaustive-deps -- `schema.sort` is tracked by CONTENT (sortKey) on purpose; see above - }, [schema.objectName, schemaKey, dataSource, boundData, schema.data, schema.filter, sortKey, schema.limit, hasExternalData, objectDefReady, objectDef, refreshKey, perms]); + }, [schema.objectName, schemaKey, dataSource, boundData, schema.data, schema.filter, sortKey, schema.limit, hasExternalData, objectDefReady, objectDef, refreshKey, perms, invalidationNonce]); // Determine which data to use: external -> bound -> inline -> fetched const rawData = (hasExternalData ? externalData : undefined) || boundData || schema.data || fetchedData; diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index d44a1bbb86..65c1d1eaf1 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -15,7 +15,7 @@ import { VALUELESS_FILTER_BUILDER_OPERATORS, isFilterValueComplete } from '@obje import { ViewSwitcherDropdown, ViewType } from './ViewSwitcher'; import { ViewSettingsPopover } from './components/ViewSettingsPopover'; import { UserFilters } from './UserFilters'; -import { SchemaRenderer, useNavigationOverlay, classifyLoadError, usePredicateScope } from '@object-ui/react'; +import { SchemaRenderer, useNavigationOverlay, classifyLoadError, usePredicateScope, useDataInvalidation } from '@object-ui/react'; import type { LoadErrorKind } from '@object-ui/react'; import { useDensityMode } from '@object-ui/react'; import type { ListViewSchema, ObjectMapConfig } from '@object-ui/types'; @@ -2117,6 +2117,18 @@ export const ListView = React.forwardRef(({ */ const surfaceDrawsFetchedRows = currentView !== 'gantt'; + // objectui#10572 — the data-invalidation bus (`notifyDataChanged` from + // `@object-ui/react`), read the objectui#10494 way: the nonce moves when a + // write to the object this list QUERIES is declared, and the fetch effect + // below names it, so the rows are re-read in place (`RefreshIndicator` over + // the current rows, no remount). A page action over raw HTTP fires no + // `onMutation`, so the subscription above cannot see it; the bus can. + // Subscribed only when the list fetches for itself — inline rows and a + // gantt that owns its endpoint are not this effect's query. + const listFetchesForItself = + !Array.isArray(schema.data) && (schema.data as any)?.provider !== 'value' && !ganttOwnsData; + const invalidationNonce = useDataInvalidation(listFetchesForItself ? schema.objectName || undefined : undefined); + // Fetch data effect — supports schema.data (ViewDataSchema) provider modes React.useEffect(() => { let isMounted = true; @@ -2696,7 +2708,7 @@ export const ListView = React.forwardRef(({ // silently un-suppresses nothing, because the finding it was suppressing // simply moves elsewhere. Add prose ABOVE this point, never below it. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schema.objectName, schema.data, dataSource, schema.filter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, schema.map, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, ganttOwnsData]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change + }, [schema.objectName, schema.data, dataSource, schema.filter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, schema.map, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, ganttOwnsData, invalidationNonce]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change // Any change to the result-defining inputs (object, filters, sort, search, // grouping, page size) invalidates the current page number — snap back to diff --git a/packages/plugin-list/src/__tests__/ListView.invalidationRefetch-10572.test.tsx b/packages/plugin-list/src/__tests__/ListView.invalidationRefetch-10572.test.tsx new file mode 100644 index 0000000000..11b3bc7454 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.invalidationRefetch-10572.test.tsx @@ -0,0 +1,107 @@ +/** + * 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#10572 — a `list-view` block re-reads its rows when the + * data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a + * write to the object it queries. + * + * Before this card the list's only refresh inputs were `refreshTrigger`, the + * imperative `refresh()` and `dataSource.onMutation`. A page action over raw + * HTTP (an `api` target, a flow, a server action) fires no `onMutation`, so the + * list on a page learned of such a write only because `PageView` remounted the + * whole page — the remount objectui#10519 removes. + * + * Rendered through the real `SchemaRenderer` and this package's own + * registration, over a fake data source that counts reads. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, notifyDataChanged } from '@object-ui/react'; +// Registers `list-view` (and `view:list`). +import '../index'; + +beforeEach(() => { + // Best-effort metadata probes are not what these cases are about. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); +}); +afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); +}); + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 150))); + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [{ id: '1', name: 'Acme' }], total: 1 })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: 'account', fields: { name: { type: 'text', label: 'Name' } } })), + }; +} + +const renderBlock = (schema: Record, ds: ReturnType) => + render( + + + , + ); + +const LIST = { type: 'list-view', objectName: 'account', columns: ['name'], viewType: 'grid' }; + +describe('list-view re-reads on the data-invalidation bus (objectui#10572)', () => { + it('an unscoped change (objectName "*") re-runs its query once', async () => { + const ds = makeDataSource(); + renderBlock(LIST, ds); + await settle(); + expect(ds.find).toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + + expect(ds.find, 'the list never re-read after the bus reported a change').toHaveBeenCalledTimes(2); + }); + + it('a change to its own object re-runs its query once; another object does not', async () => { + const ds = makeDataSource(); + renderBlock(LIST, ds); + await settle(); + expect(ds.find).toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: 'some_other_object' }); + }); + await settle(); + expect(ds.find).toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: 'account', recordId: '1' }); + }); + await settle(); + expect(ds.find).toHaveBeenCalledTimes(2); + }); + + it('a list drawing inline rows queries nothing on an invalidation', async () => { + const ds = makeDataSource(); + renderBlock({ ...LIST, data: [{ id: '1', name: 'Inline' }] }, ds); + await settle(); + + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + + expect(ds.find).not.toHaveBeenCalled(); + }); +});