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 ; } ``` -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