Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions .changeset/10887-object-view-non-grid-bus-reader.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ fix(plugin-view): an `object-view` in a non-grid view re-reads its rows on the d

For the non-grid views (kanban, calendar, gallery, timeline, map, gantt),
`ObjectView` fetches the rows itself and hands them to the inner view as
`data`, which switches off that view's own bus reader. That fetch
now names the `useDataInvalidation` nonce for `schema.objectName`, so a write
declared on the bus (`notifyDataChanged`, as a page action over raw HTTP does),
or an unscoped `'*'`, re-reads the rows in place: the inner view is not
remounted. A change to another object does not re-read. Before, such a write
reached these views only when their host remounted them, and `PageView` is
about to stop doing that (objectui#10519).
`data`, which switches off that view's own bus reader. A gantt handed zero rows
is the one exception: it still queries for itself and keeps its own bus reader
beside this one (objectui#7333), so both re-read, and both answers are correct.
`ObjectView`'s fetch now names the `useDataInvalidation` nonce for
`schema.objectName`, so a write declared on the bus (`notifyDataChanged`, as a
page action over raw HTTP does), or an unscoped `'*'`, re-reads the rows in
place: the inner view is not remounted. A change to another object does not
re-read. Before, such a write reached these views only when their host
remounted them, and `PageView` is about to stop doing that (objectui#10519).

The subscription follows the rows the view draws: a host `renderListView` (its
`ListView` reads the bus itself) and the grid (`ObjectGrid` does too) do not
Expand Down
33 changes: 33 additions & 0 deletions .changeset/10887-react-page-data-invalidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@object-ui/components': minor
---

feat(components): a `kind: 'react'` page's author scope injects `useDataInvalidation`

A react page reads data through the injected `useAdapter`, in an effect it
writes itself. The scope gave that effect no data-invalidation reader to name,
so the read the react-pages guide taught, keyed on `[adapter]`, re-ran only
when the page was remounted (as `PageView` does after a page action) or its
adapter changed.

The scope now injects `useDataInvalidation` from `@object-ui/react` beside
`useAdapter`: the same hook `ListView` reads to refresh its rows.
`useDataInvalidation('showcase_project')` returns a number that moves when the
data-invalidation bus reports a write to that object, or an unscoped `'*'`.
Named in the dependency list of the effect that reads through `useAdapter`, it
re-runs that read in place: the page is not remounted and keeps its own state.
A write to another object does not re-run it, and a page that does not name the
hook behaves as before. The hook is a module-level function, so the scope's
identity is as stable as it was and injecting it recompiles no page.

The react-pages guide (`content/docs/guide/react-pages.md`) lists it in the
scope table, and its Live data example names the nonce in the effect's
dependencies.

**Clause-②: yes** — one identifier joins the published author scope of
`kind: 'react'` pages, so the set of names a page's source can resolve widens.
Nothing is removed or renamed. The one source this can break is a page that
declares its own top-level `const` or `let` named `useDataInvalidation`: the
scope's names are the parameters of the function the source is evaluated in,
so that declaration is now a `SyntaxError`, shown in the page's error panel.
Renaming the page's own identifier resolves it.
12 changes: 10 additions & 2 deletions content/docs/guide/react-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Nothing is imported. These identifiers are injected as closure variables:
| The public data blocks | Every public non-container block, as a PascalCase tag *on this tier* — but *what resolves* and *what you author against* are two different sets, below. |
| `Block` | Escape hatch for anything not injected. |
| `useAdapter` | The live data source — query/create/update. |
| `useDataInvalidation` | The data-invalidation bus reader: `useDataInvalidation('object')` returns a number that moves when a write to that object is reported. Name it in the dependencies of an effect that reads through `useAdapter` — see *Live data*, below. |
| `data`, `variables`, `page` | The page's own data, local variables, and schema. |

#### Two tiers: what resolves, and what you author against
Expand Down Expand Up @@ -238,19 +239,26 @@ Any registered component, including ones outside the public contract:
```jsx
function Page() {
const adapter = useAdapter();
const changed = useDataInvalidation('showcase_project');
const [rows, setRows] = React.useState([]);

React.useEffect(() => {
adapter
.find('showcase_project', { $filter: ['status', '=', 'open'] })
.then((res) => setRows(res.data ?? []));
}, [adapter]);
}, [adapter, changed]);

return <ul>{rows.map((r) => <li key={r._id}>{r.name}</li>)}</ul>;
}
```

Two things in that call are easy to get wrong, and neither one errors:
`changed` is in the dependency array so the page's own read follows the data:
the nonce moves each time the data-invalidation bus reports a write to
`showcase_project` (the same bus `<ListView>` refreshes from), and the effect
reads again in place, without remounting the page, so the page keeps its own
state.

Two things in the `find` call are easy to get wrong, and neither one errors:

**The `$` prefixes are load-bearing.** Every query key starts with `$` —
`$select`, `$filter`, `$orderby`, `$skip`, `$top`, `$expand`, `$search`,
Expand Down
154 changes: 154 additions & 0 deletions packages/components/src/__tests__/react-page-invalidation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* 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 `kind:'react'` page's own reads re-run on the data-invalidation bus
* (objectui#10887, member 3, ruled A).
*
* A react page reads data through the injected `useAdapter`, in an effect it
* writes itself. The data blocks inside the page (`<ListView>` and the view
* blocks) re-read on the bus on their own, but a page's own effect had nothing
* to name: the scope injected no bus reader, so the effect re-ran only when the
* host remounted the whole page (`PageView`'s `key={refreshKey}` after a page
* action, which objectui#10519 removes). The scope now injects
* `useDataInvalidation`, the same hook the blocks read, and the taught
* pattern names its nonce in the effect's dependency list.
*
* Everything here is real: the page is compiled from source by
* `@object-ui/react-runtime` inside the real `ReactKindPage` (dispatched by the
* real `PageRenderer` for `type: 'home'`), and the events go through the real
* bus, `notifyDataChanged` from `@object-ui/react`. Only the adapter is a
* stand-in, so the reads can be counted.
*
* What is pinned:
* - one bus event on the page's object re-runs the page's read exactly once,
* the re-read rows reach the page, and the page keeps its own `useState`
* (a remount would reset the counter the user clicked);
* - the lit control: an event on another object does not re-run the read,
* while a bare `useDataInvalidation` reader mounted beside the page for
* that other object DOES move, so the event demonstrably reached the bus.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, fireEvent, waitFor, act } from '@testing-library/react';
import React from 'react';
import { SchemaRenderer, AdapterCtx, notifyDataChanged, useDataInvalidation } from '@object-ui/react';
// `ReactKindPage` loads the page runtime with `import('@object-ui/react-runtime')`.
// Importing the same specifier here bills its cold transform to this module's
// import phase instead of to a `findBy` window (AGENTS.md, flaky-test
// discipline: an unbounded module load inside a bounded wait).
import '@object-ui/react-runtime';
// Registers PageRenderer for `type:'home'`, which dispatches kind:'react'.
import '../renderers';

/** The Live data pattern the react-pages guide teaches, nonce included. */
const SOURCE = `
function Page() {
const adapter = useAdapter();
const changed = useDataInvalidation('showcase_project');
const [rows, setRows] = React.useState([]);
const [clicks, setClicks] = React.useState(0);

React.useEffect(() => {
adapter
.find('showcase_project', { $top: 10 })
.then((res) => setRows(res.data ?? []));
}, [adapter, changed]);

return (
<div>
<button data-testid="clicks" onClick={() => setClicks(clicks + 1)}>{clicks}</button>
<ul data-testid="rows">{rows.map((r) => <li key={r._id}>{r.name}</li>)}</ul>
</div>
);
}`;

/** Module-scope so the schema identity is stable across host re-renders. */
const SCHEMA = { type: 'home', kind: 'react', name: 'invalidation_page', source: SOURCE };

let find: ReturnType<typeof vi.fn>;
let adapter: { find: typeof find };

beforeEach(() => {
// Each answer names the read that produced it, so a re-read is visible on
// screen and not only in the call count.
find = vi.fn(async () => ({
data: [{ _id: 'p1', name: `read ${find.mock.calls.length}` }],
total: 1,
}));
adapter = { find };
});

/** A bare bus reader beside the page: the positive control for delivery. */
function BusProbe({ objectName }: { objectName: string }) {
const nonce = useDataInvalidation(objectName);
return <span data-testid={`probe-${objectName}`}>{nonce}</span>;
}

function Host() {
return (
<AdapterCtx.Provider value={adapter as unknown as React.ContextType<typeof AdapterCtx>}>
<BusProbe objectName="showcase_invoice" />
<SchemaRenderer schema={SCHEMA} />
</AdapterCtx.Provider>
);
}

/** Mount the page and settle on its first read. */
async function mountPage() {
const utils = render(<Host />);
// Settle on whichever the page reaches first: its list, or the page-level
// error panel `ReactKindPage` renders when the source does not compile or
// throws — which is what an identifier missing from the scope produces.
await waitFor(() =>
expect(utils.queryByTestId('rows') ?? utils.queryByText('React page error')).toBeTruthy(),
);
expect(
utils.queryByText('React page error'),
`the page failed against its injected scope: ${utils.container.textContent}`,
).toBeNull();
await waitFor(() => expect(utils.getByTestId('rows').textContent).toBe('read 1'));
expect(find).toHaveBeenCalledTimes(1);
return utils;
}

describe("kind:'react' page scope — useDataInvalidation (objectui#10887)", () => {
it('one bus event on the page object re-runs the page read once, in place, with no remount', async () => {
const { getByTestId } = await mountPage();

// State the user produced before the write: a remount would reset it.
fireEvent.click(getByTestId('clicks'));
expect(getByTestId('clicks').textContent).toBe('1');

// A record-scoped write, the shape a save announces. The page's reader
// names no record, so it is a list reader and every write to the object
// stales it.
act(() => {
notifyDataChanged({ objectName: 'showcase_project', recordId: 'p1' });
});

await waitFor(() => expect(getByTestId('rows').textContent).toBe('read 2'));
expect(find, 'the page read did not re-run exactly once after the bus reported a change').toHaveBeenCalledTimes(2);
expect(find).toHaveBeenLastCalledWith('showcase_project', { $top: 10 });
// In place: the page's own state survived the re-read.
expect(getByTestId('clicks').textContent).toBe('1');
});

it('lit control: an event on another object reaches the bus and does not re-run the page read', async () => {
const { getByTestId } = await mountPage();
expect(getByTestId('probe-showcase_invoice').textContent).toBe('0');

await act(async () => {
notifyDataChanged({ objectName: 'showcase_invoice' });
});

// Delivered: the bare reader for that object moved.
await waitFor(() => expect(getByTestId('probe-showcase_invoice').textContent).toBe('1'));
// Not for this page: its read did not re-run, and its rows are the first answer.
expect(find).toHaveBeenCalledTimes(1);
expect(getByTestId('rows').textContent).toBe('read 1');
});
});
14 changes: 13 additions & 1 deletion packages/components/src/renderers/layout/react-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
* expressed in HTML are injected.
* - `Block` — escape hatch: `<Block type="object-grid" .../>`.
* - `useAdapter` — live data hook: query/create/update objects.
* - `useDataInvalidation` — the data-invalidation bus reader: a nonce that
* moves when a write to the named object is reported on the bus. A page
* names it in the effect that reads through `useAdapter`, so that read
* re-runs in place (objectui#10887).
* - `data` / `variables` — page data + local variables, for convenience.
*
* Styling — page source is metadata, not build input. A react page styles with
Expand All @@ -40,7 +44,7 @@

import * as React from 'react';
import { ComponentRegistry, isCapabilityEnabled, CAP_REACT_PAGES } from '@object-ui/core';
import { SchemaRenderer, SchemaRendererProvider, useAdapter } from '@object-ui/react';
import { SchemaRenderer, SchemaRendererProvider, useAdapter, useDataInvalidation } from '@object-ui/react';

type RuntimeModule = typeof import('@object-ui/react-runtime');

Expand Down Expand Up @@ -186,6 +190,14 @@ export const ReactKindPage: React.FC<{ schema: any }> = ({ schema }) => {
// adapter.find('object', {...}) / .create / .update. Hooks injected as
// closure vars; the page calls them from its own component body.
useAdapter,
// The data-invalidation bus reader (objectui#10887), the same hook
// `ListView` reads to refresh its rows. `useDataInvalidation('object')`
// returns a nonce that moves when the bus reports a write to that object
// (or `'*'`); a page names it in the dependency list of the effect that
// reads through `useAdapter`, and the read re-runs in place, with no
// remount. A module-level function, so it leaves the scope's identity
// exactly as stable as before.
useDataInvalidation,
data: schema?.data ?? schema?.variables ?? {},
variables: schema?.variables ?? {},
page: schema ?? {},
Expand Down
31 changes: 18 additions & 13 deletions packages/plugin-view/src/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1162,10 +1162,12 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
// objectui#10853 way: the nonce moves when the bus reports a change to the
// object this fetch QUERIES (or `'*'`), and the effect below names it, so
// the rows are re-read in place. The inner view receives them as `data`,
// which switches off its own bus reader, and `refreshKey` moves only on this
// view's own write and `onMutation`; a page action over raw HTTP fires
// neither, so before this the rows were re-read only when `PageView`
// remounted the page (objectui#10519 removes that remount).
// which switches off its own bus reader (a gantt handed zero rows is the
// exception: it queries for itself and keeps its reader, objectui#7333),
// and `refreshKey` moves only on this view's own write and `onMutation`;
// a page action over raw HTTP fires neither, so before this the rows were
// re-read only when `PageView` remounted the page (objectui#10519 removes
// that remount).
//
// Subscribed exactly when these rows are what the view draws. A host
// `renderListView` (its `ListView` reads the bus itself) and the grid
Expand Down Expand Up @@ -2513,15 +2515,18 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
// The view's IDENTITY — switching object, view or type is a real remount,
// and it is the ONLY thing in the key (objectui#10035; AGENTS.md #8's
// corollary: refresh data, don't rebuild UI). A write no longer remounts
// any view: `kanban`, `calendar`, `gallery`, `timeline` and `map` draw
// `data={data}`, the rows the non-grid fetch effect re-reads when
// `refreshKey` moves, and `tree` re-queries when that array changes;
// `ObjectGrid`, `ObjectGantt` and `ObjectChart` query for themselves and
// refetch in place on the data-invalidation bus, which every site that
// moves `refreshKey` also notifies (`announceOwnWrite`, the `onMutation`
// subscription). ⛔ Do not put `refreshKey` back in a key: that is the
// remount the corollary forbids, and it throws away the view's scroll,
// selection, open drawers and in-progress edits on every save.
// any view. `kanban`, `calendar`, `gallery`, `timeline`, `map` and a
// `gantt` handed rows draw `data={data}`, the rows the non-grid fetch
// effect re-reads when `refreshKey` moves or when the data-invalidation
// bus reports a change to this object (objectui#10887). `tree` re-queries
// when that array changes and reads the bus itself (objectui#10778).
// `ObjectGrid`, `ObjectChart` and a gantt handed zero rows
// (objectui#7333) query for themselves and refetch in place on the bus,
// which every site that moves `refreshKey` also notifies
// (`announceOwnWrite`, the `onMutation` subscription). ⛔ Do not put
// `refreshKey` back in a key: that is the remount the corollary forbids,
// and it throws away the view's scroll, selection, open drawers and
// in-progress edits on every save.
const identityKey = `${schema.objectName}-${activeNamedView || activeView?.id || 'default'}-${currentViewType}`;

// If a custom renderListView is provided, use it
Expand Down
Loading