diff --git a/.changeset/10887-object-view-non-grid-bus-reader.md b/.changeset/10887-object-view-non-grid-bus-reader.md
index abfea1447b..6d8490b2ee 100644
--- a/.changeset/10887-object-view-non-grid-bus-reader.md
+++ b/.changeset/10887-object-view-non-grid-bus-reader.md
@@ -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
diff --git a/.changeset/10887-react-page-data-invalidation.md b/.changeset/10887-react-page-data-invalidation.md
new file mode 100644
index 0000000000..25ebd96831
--- /dev/null
+++ b/.changeset/10887-react-page-data-invalidation.md
@@ -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.
diff --git a/content/docs/guide/react-pages.md b/content/docs/guide/react-pages.md
index 51395cad3e..500723920a 100644
--- a/content/docs/guide/react-pages.md
+++ b/content/docs/guide/react-pages.md
@@ -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
@@ -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
{rows.map((r) =>
{r.name}
)}
;
}
```
-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 `` 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`,
diff --git a/packages/components/src/__tests__/react-page-invalidation.test.tsx b/packages/components/src/__tests__/react-page-invalidation.test.tsx
new file mode 100644
index 0000000000..0d889b0076
--- /dev/null
+++ b/packages/components/src/__tests__/react-page-invalidation.test.tsx
@@ -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 (`` 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 (
+
+
+
{rows.map((r) =>
{r.name}
)}
+
+ );
+}`;
+
+/** 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;
+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 {nonce};
+}
+
+function Host() {
+ return (
+ }>
+
+
+
+ );
+}
+
+/** Mount the page and settle on its first read. */
+async function mountPage() {
+ const utils = render();
+ // 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');
+ });
+});
diff --git a/packages/components/src/renderers/layout/react-page.tsx b/packages/components/src/renderers/layout/react-page.tsx
index f70b8f4251..ab712dface 100644
--- a/packages/components/src/renderers/layout/react-page.tsx
+++ b/packages/components/src/renderers/layout/react-page.tsx
@@ -25,6 +25,10 @@
* expressed in HTML are injected.
* - `Block` — escape hatch: ``.
* - `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
@@ -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');
@@ -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 ?? {},
diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx
index 34f0656469..ec1635a5fb 100644
--- a/packages/plugin-view/src/ObjectView.tsx
+++ b/packages/plugin-view/src/ObjectView.tsx
@@ -1162,10 +1162,12 @@ export const ObjectView: React.FC = ({
// 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
@@ -2513,15 +2515,18 @@ export const ObjectView: React.FC = ({
// 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