Skip to content
4 changes: 2 additions & 2 deletions .changeset/10035-objectview-refresh-in-place.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
'@object-ui/app-shell': patch
---

A save, delete or other write no longer rebuilds the object view it happened in for most view types — the view refetches its rows in place and keeps its component state (objectui#10035).
A save, delete or other write no longer rebuilds the object view it happened in — the view refetches its rows in place and keeps its component state (objectui#10035).

Both `ObjectView` layers carried their refresh counter in a React `key`, so every write remounted the whole view and reset everything held in it: a calendar jumped back to the current month and its default mode, the object page's list lost its scroll position and the visualization picked in its own switcher. The counter now reaches the rendered view as a data signal instead:

- `@object-ui/app-shell`: the object page hands its refresh counter to `ListView` as `refreshTrigger`, the input `ListView`'s fetch already follows, and keys the list on the object and view alone.
- `@object-ui/plugin-view`: kanban, calendar, gallery, timeline, map and tree views keep their instance across a write and receive the refetched rows.

Views whose renderer fetches for itself and reads no refresh input are still remounted after a write, because that remount is the only way they show it: gantt and chart views in both layers, any object-page list whose visualization whitelist offers gantt or chart, and the standalone grid `plugin-view` renders when no host list view is supplied.
Views whose renderer fetches for itself — gantt and chart views in both layers, any object-page list whose visualization whitelist offers gantt or chart, and the standalone grid `plugin-view` renders when no host list view is supplied — refresh in place too, through the data-invalidation bus; see the `plugin-grid` / `plugin-gantt` / `plugin-charts` note for objectui#10035 in this release.
19 changes: 19 additions & 0 deletions .changeset/10035-renderers-read-invalidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/plugin-grid': patch
'@object-ui/plugin-gantt': patch
'@object-ui/plugin-charts': patch
'@object-ui/plugin-view': patch
'@object-ui/app-shell': patch
---

Gantt, chart and grid views refresh in place after a write instead of being rebuilt (objectui#10035).

`ObjectGrid`, `ObjectGantt` and `ObjectChart` query for themselves and read no refresh input, so both object-view layers could show them a write only by remounting them — which lost a gantt's scroll, collapsed groups and zoom, a chart's open drill-down, and a grid's selection, column state and in-progress edit. Each now refetches when the data-invalidation bus (`notifyDataChanged` / `useDataInvalidation` from `@object-ui/react`) reports a change to the object it reads:

- `@object-ui/plugin-grid`: `ObjectGrid` re-reads its rows into the same table.
- `@object-ui/plugin-gantt`: `ObjectGantt` re-reads silently and keeps the chart mounted, as its toolbar refresh does.
- `@object-ui/plugin-charts`: `ObjectChart` re-runs its query on both binding shapes — an object-bound aggregate follows its `objectName`, a dataset-bound chart follows the dataset's base object as the `queryDataset` answer names it.
- `@object-ui/plugin-view`: the object view keys every view on its identity alone, and declares on the bus its own saves and deletes and each `onMutation` write it hears.
- `@object-ui/app-shell`: the object page keys its list and its chart view on identity alone, and declares on the bus the changes it learns of without a data-source write — server actions, flows, imports, realtime events and an explicit refresh.

Rows a host hands these components (`data`, `bind`, inline values) are still the host's to refresh; only the components' own queries follow the bus.
163 changes: 133 additions & 30 deletions packages/app-shell/src/views/ObjectView.refreshInPlace-10035.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,27 @@
* key" fix would ship: the page's own counter used to reach the list through
* the key alone.
*
* ## The remount that stays, pinned as the refresh it still is
* ## The lists that draw a self-fetching visualization
*
* A list that can draw `gantt` or `chart` — as its own type, or through the
* author's visualization whitelist — keeps the counter in its key, because
* those renderers fetch for themselves and read nothing that moves on a write
* (`REMOUNT_TO_REFRESH_VISUALIZATIONS`). Those cases assert the fresh tree.
* author's visualization whitelist — used to keep the counter in its key,
* because those renderers query for themselves and read no refresh input
* (`REMOUNT_TO_REFRESH_VISUALIZATIONS`), and these cases asserted the fresh
* tree. The renderers now refetch in place on the data-invalidation bus, so the
* list keeps its tree and the gantt re-issues ITS OWN query, exactly once, when
* the write is declared on the bus — by the console for its own writes (the
* bridge, undo / redo), and by this page for the changes it learns of another
* way (realtime, import, server actions: `refreshData`).
*
* Everything here renders for real — the page, `plugin-view`'s `ObjectView`
* and `plugin-list`'s `ListView` — on the harness of
* `ObjectView.hostRerenderRefetch-10046.test.tsx`.
* `ObjectView.hostRerenderRefetch-10046.test.tsx`. The gantt is a stand-in
* registered as `object-gantt` (the `ObjectView.stableFilterIdentity-7237`
* precedent: `app-shell` does not depend on `plugin-gantt`). It reads the REAL
* bus hook and queries on it exactly as `ObjectGantt` now does —
* `ObjectGantt.invalidationRefetch-10035.test.tsx` proves the real component
* does that — and carries an instance id from a `useState` initializer, so a
* remount of the visualization itself is visible too.
*/

import * as React from 'react';
Expand Down Expand Up @@ -79,10 +90,21 @@ vi.mock('@object-ui/auth', async (importOriginal) => ({
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<Record<string, unknown>>()),
useRealtimeSubscription: () => ({ lastMessage: null }),
useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }),
useRealtimeSubscription: () => ({ lastMessage: realtime.lastMessage }),
useConflictResolution: () => realtime.conflicts,
}));

vi.mock('sonner', () => ({
Expand All @@ -98,6 +120,8 @@ vi.mock('./MetadataInspector', () => ({
}));
vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null }));

import { ComponentRegistry } from '@object-ui/core';
import { notifyDataChanged, useDataInvalidation } from '@object-ui/react';
import { ObjectView } from './ObjectView';
import { ExpressionProvider } from '../providers/ExpressionProvider';

Expand Down Expand Up @@ -129,11 +153,20 @@ function objectsWith(allView: Record<string, unknown>) {

/** The list queries `ListView` issued; the page's `$top: 0` count probe is excluded. */
let listQueries = 0;
/** The stand-in gantt's own queries, and the instance ids it mounted with. */
let ganttQueries = 0;
let ganttInstanceSeq = 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++;
return { data: [], total: 0 };
return { data: [{ ...ROW }], total: 1 };
}),
findOne: vi.fn(async () => null),
create: vi.fn(async () => ({})),
Expand All @@ -146,16 +179,29 @@ function makeDataSource() {
const settle = () => act(() => new Promise<void>((resolve) => setTimeout(resolve, 400)));

/**
* Mounts the page and returns the console's write signal: `externalRefreshKey`
* is what `AppContent` bumps after a record-form save, an undo and a redo.
* Mounts the page and returns two 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);
* - `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<string, unknown>): Promise<{ write: () => Promise<void> }> {
async function mountPage(
allView: Record<string, unknown>,
): Promise<{ write: () => Promise<void>; realtime: () => Promise<void> }> {
const dataSource = makeDataSource();
const objects = objectsWith(allView);
let bump: () => void = () => {};
let rerender: () => void = () => {};
function Harness() {
const [externalRefreshKey, setExternalRefreshKey] = React.useState(0);
bump = () => setExternalRefreshKey((n) => n + 1);
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 (
<ExpressionProvider user={{ id: 'u1', name: 'Ada', profile: 'admin' }}>
<MemoryRouter initialEntries={[`/apps/demo/${OBJECT_NAME}/view/all`]}>
Expand All @@ -180,18 +226,43 @@ async function mountPage(allView: Record<string, unknown>): Promise<{ write: ()
await settle();
return {
write: async () => {
await act(async () => bump());
await act(async () => {
bump();
notifyDataChanged({ objectName: OBJECT_NAME });
});
await settle();
},
realtime: async () => {
await act(async () => {
realtime.lastMessage = { type: 'update', objectName: OBJECT_NAME, at: Date.now() };
rerender();
});
await settle();
},
};
}

/** A gantt that queries for itself and refetches on the bus, like `ObjectGantt`. */
function GanttStandIn({ schema }: any) {
const [id] = React.useState(() => ++ganttInstanceSeq);
const nonce = useDataInvalidation(schema?.objectName);
React.useEffect(() => {
ganttQueries++;
}, [nonce]);
return <div data-testid="gantt-stand-in" data-instance={id} />;
}
const ganttInstance = () => screen.getByTestId('gantt-stand-in').dataset.instance;

/** A node rendered inside `ListView` — replaced wholesale by a key remount. */
const listNode = () => screen.getByTestId('view-description');

beforeEach(() => {
cleanup();
listQueries = 0;
ganttQueries = 0;
ganttInstanceSeq = 0;
realtime.lastMessage = null;
ComponentRegistry.register('object-gantt', GanttStandIn as any);
vi.stubGlobal(
'fetch',
vi.fn(async () =>
Expand All @@ -212,6 +283,18 @@ describe('the object page refreshes its list in place after a write (objectui#10
it.each([
['grid', { type: 'grid' }],
['kanban', { type: 'kanban', kanban: { groupByField: 'stage' } }],
// Used to keep the counter in its key: this host cannot see which
// visualization the in-list switcher shows, so a whitelist offering a
// self-fetching one remounted the list for EVERY visualization.
[
'grid whose whitelist offers gantt and chart',
{
type: 'grid',
gantt: { startDateField: 'starts_on', endDateField: 'ends_on' },
chart: { xAxisField: 'stage', yAxisFields: ['name'] },
appearance: { allowedVisualizations: ['grid', 'gantt', 'chart'] },
},
],
])('%s: the same list re-issues its query once', async (_label, allView) => {
const page = await mountPage(allView);
const node = listNode();
Expand All @@ -230,32 +313,52 @@ describe('the object page refreshes its list in place after a write (objectui#10
'(a) The write REMOUNTED the list: the node read inside `ListView` before the\n'
+ 'write is gone. The refresh counter is back in the `<ListView>` key, so every\n'
+ 'save throws the list\'s UI state away (AGENTS.md #8: refresh data, don\'t\n'
+ 'rebuild UI). Only lists that can draw a `REMOUNT_TO_REFRESH_VISUALIZATIONS`\n'
+ 'member may keep it.',
+ 'rebuild UI).',
).toBe(node);
});
});

describe('a list that can draw a view with no in-place refetch path still shows a write — by remount (objectui#10035)', () => {
it.each([
['its own type is gantt', { type: 'gantt', gantt: { startDateField: 'starts_on', endDateField: 'ends_on' } }],
[
'gantt is in its visualization whitelist',
{ type: 'grid', gantt: { startDateField: 'starts_on', endDateField: 'ends_on' }, appearance: { allowedVisualizations: ['grid', 'gantt'] } },
],
])('%s: a write mounts a fresh list', async (_label, allView) => {
const page = await mountPage(allView);
const GANTT_VIEW = { type: 'gantt', gantt: { startDateField: 'starts_on', endDateField: 'ends_on' } };

describe('a list drawing a self-fetching visualization keeps its tree and the visualization refetches in place (objectui#10035)', () => {
it('its own type is gantt: a console write re-issues the gantt\'s own query once, in the same list', async () => {
const page = await mountPage(GANTT_VIEW);
const node = listNode();
expect(ganttQueries, 'the gantt must be drawing this list').toBeGreaterThan(0);
const instance = ganttInstance();
const before = ganttQueries;

await page.write();

expect(
ganttQueries - before,
'(b) The gantt did not re-query after the write, or re-queried more than once. It\n'
+ 'queries for itself and must refetch on the data-invalidation bus now that the\n'
+ 'list is no longer remounted to show it the write.',
).toBe(1);
expect(
listNode(),
'The list was NOT remounted after a write although it can draw a gantt or chart.\n'
+ 'Those renderers fetch for themselves and read no refresh input, so without the\n'
+ 'remount they silently keep showing the pre-write rows. Remove a member from\n'
+ '`REMOUNT_TO_REFRESH_VISUALIZATIONS` only together with a change that makes its\n'
+ 'renderer refetch in place.',
).not.toBe(node);
'(a) The write REMOUNTED the list: the refresh counter is back in the `<ListView>`\n'
+ 'key (AGENTS.md #8: refresh data, don\'t rebuild UI).',
).toBe(node);
expect(ganttInstance(), '(a) the write REMOUNTED the gantt itself').toBe(instance);
});

it('its own type is gantt: a realtime change is declared on the bus by the page itself', async () => {
const page = await mountPage(GANTT_VIEW);
const node = listNode();
const instance = ganttInstance();
const before = ganttQueries;

await page.realtime();

expect(
ganttQueries - before,
'(b) The gantt did not re-query after a realtime change. The page learns of that\n'
+ 'change from no data-source write, so it must declare it on the bus itself\n'
+ '(`refreshData`).',
).toBe(1);
expect(listNode(), '(a) the realtime change REMOUNTED the list').toBe(node);
expect(ganttInstance(), '(a) the realtime change REMOUNTED the gantt itself').toBe(instance);
});
});
Loading
Loading