Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 9f0c84a

Browse files
fix(plugin-view,plugin-dashboard): object-view's non-grid views and a dashboard filter's optionsFrom options re-read on the data-invalidation bus (objectui#10887) (#10898)
Part of #10887 Clause-②: no — members 1 and 2 add a re-read on the canonical bus to two existing readers and change no declared contract. Member 3 stops at `needs_decision` before any scope widening: this PR touches neither the react-page scope nor its guide. `Part of`, not a closing keyword: member 3 (the `kind: 'react'` page scope) measured shipped readers and stops at `needs_decision`, as the dispatch order rules for that branch. Members 1 and 2 land here, and the card stays open for member 3. ## What changed - **Member 1, `@object-ui/plugin-view`.** `ObjectView`'s non-grid fetch effect (the one commented "Fetch data for non-grid view types") names the `useDataInvalidation` nonce for `schema.objectName`. The hook sits directly above that effect, and the nonce is the last entry of the effect's dependency list. No second channel, no remount, no key change. - **Member 2, `@object-ui/plugin-dashboard`.** `SelectFilter` in `DashboardFilterBar` names the nonce for `optionsFrom.object` in the options effect, on both reads (the dataset GROUP BY and the client-side `find` fallback). The effect never resets `dynamicOptions` and never writes the dashboard variable, so the options stay on screen until the re-read answers and the selected value is kept. - One `patch` changeset per package: `10887-object-view-non-grid-bus-reader` and `10887-dashboard-filter-options-bus-reader`. - Not touched: `renderContent` (PR #10884's region), `PageView` (PR #10777), the react-page scope and `react-pages` guide. ## H1 — reproduced on base `35d68c4ce5`, through the real `SchemaRenderer` Each block is rendered in the page-region shape (`{ type, properties }` for `object-view`, a `dashboard` node with `globalFilters`) under `SchemaRendererProvider`, over a data source that counts reads and implements no `onMutation` (a page action over raw HTTP announces none). | block | mount | after `'*'` | after an unrelated object | after its own object | | --- | --- | --- | --- | --- | | `object-view` kanban, calendar, gallery, timeline, map, gantt — base | 1 `find` | 1 | 1 | 1 | | same — head | 1 | 2 | 1 | 2 | | `dashboard` `optionsFrom` filter, dataset read — base | 1 `queryDataset` | 1 | 1 | 1 | | same, `find` fallback — base | 1 `find` | 1 | 1 | 1 | | both reads — head | 1 | 2 | 1 | 2 | Base failure text, quoted: `the rows never re-read after the bus reported a change: expected "vi.fn()" to be called 2 times, but got 1 times` (every `object-view` type) and `the options never re-read after the bus reported a change: expected "vi.fn()" to be called 2 times, but got 1 times` (both option reads). Lit controls, green on base and head in the same tree: a bare `useDataInvalidation` reader moves to 1 on every event (asserted before the count), a grid `object-view` re-reads once through `ObjectGrid`'s own reader, and an `object-metric` block beside the dashboard re-reads once through its own. ## H2 — seam - **Member 1.** Hook: `useDataInvalidation(fetchDrawsView && dataSource ? schema.objectName || undefined : undefined)`, where `fetchDrawsView` is "no host `renderListView`, and the view type is none of `grid`, `tree`, `chart`". Effect: the non-grid fetch, dependency list extended by `invalidationNonce`. - `dataChangeMatches(change, schema.objectName)` with no record id: true for `'*'`, and for any change whose `objectName` equals the view's, record-scoped or not. False for another object. - No object or no adapter: the hook does not subscribe, the nonce stays 0, and the effect's own early return reads nothing (pinned). - `renderListView` and `grid` are excluded because `ListView` and `ObjectGrid` read the bus themselves, and the effect body returns early for both anyway. - `tree` and `chart` (host-only, reachable only through a `views` prop) are excluded because their renderers query for themselves and read the bus themselves: `ObjectTree` runs its own query ahead of the rows handed to it (objectui#10778) and re-queries whenever that array changes, and `ObjectChart` never reads the rows. Subscribing there would add a read per event beside theirs (ablation 3 shows `find` 1 → 2 for each). This narrows the card's parenthetical list, which names `tree` among the fetch-driven views: the fetch does not drive the tree's rows once a data source is present. - **Member 2.** Hook: `useDataInvalidation(from && dataSource && (queryDataset or find is a function) ? from.object || undefined : undefined)`. Effect: the `optionsFrom` options effect, dependency list extended by `invalidationNonce`. - `dataChangeMatches(change, optionsFrom.object)`: true for `'*'` and for any change to that object. False for another object. - A filter with authored `options` and no `optionsFrom` does not subscribe and reads nothing (pinned). ## H3 — member 3 census (the `kind: 'react'` page scope) Enumerated from the committed trees, not a working tree: objectui `origin/main` `35d68c4ce5` and objectstack `origin/main` `862b6ce869`, over examples, docs, skills and templates. - **Population:** `kind: 'react'` pages that ship: 3, all in objectstack's `examples/app-showcase` (`showcase_crm_workbench`, `showcase_renewals_pipeline`, `showcase_task_desk`). objectui ships none in `examples/`; its react pages live in the `react-pages` guide and the console's SDUI preview harnesses. - **Shipped pages that read data through `useAdapter`: 2 of 3.** - `showcase_crm_workbench`: the KPI strip reads `adapter.find('showcase_project', { $top: 200 })` in an effect keyed on the page's own `reloadKey`, bumped only by its own `ObjectForm` `onSuccess`. - `showcase_renewals_pipeline`: two `adapter.find` reads (`showcase_project`, `showcase_invoice`) in an effect keyed on the selected account. - `showcase_task_desk`: no `useAdapter` read. - **Taught:** the objectui `react-pages` guide ("Live data" teaches `adapter.find(...)` in an effect keyed on `[adapter]`, and the record-family table teaches `useAdapter().findOne`); objectstack `content/docs/ui/react-pages.mdx`; objectstack `skills/objectstack-ui/rules/pages.md`. - **Lit control:** the same `useAdapter` pattern hits the scope's injection site in `@object-ui/components`' react-page renderer 7 times. Verdict: a shipped and taught react page reads data through the injected `useAdapter`, so injecting `useDataInvalidation` into the author scope widens a public surface. Member 3 stops at `needs_decision`, with the four-axis analysis in the dev report on the card. Nothing is injected and the guide is not edited here. ## Pins, red on base and green on head `ObjectView.busReread-10887.test.tsx` (plugin-view) and `DashboardFilterBar.busReread-10887.test.tsx` (plugin-dashboard), both through the real `SchemaRenderer` and each package's own registration. The inner non-grid views of `object-view` are stand-ins registered in the real registry (plugin-view does not depend on the plugins that register them, and the rows are `ObjectView`'s read); each carries an instance id, so a remount would show. - Per member, per view type or read: one read on mount, a second after `'*'`, a second after its own object, none after an unrelated object; for `object-view`, the re-read rows reach the view and the view keeps its instance. - Member 2: the value chosen through the real select survives the re-read on the same trigger node, and the re-read options reach the list. - Controls: an `object-view` with no object and a filter with no `optionsFrom` read nothing on mount or on `'*'`; host-composed `tree` and `chart` views are not re-read by this fetch; the two lit controls above. Final head `56354a1c38`: `Tests 23 passed (23)`. The same committed pins over base's two source files (restored from `HEAD` afterwards, blob equal and `git diff HEAD` empty): `Tests 17 failed | 6 passed (23)`, the 6 being the controls. ## Ablations (each through `ablation-replace`: anchor hit once, blob changed, restored to the `HEAD` blob, `git diff HEAD` empty) | ablation | anchor | result | | --- | --- | --- | | 1: drop the nonce from `ObjectView`'s fetch dependency list | `invalidationNonce,` | `Tests 12 failed \| 11 passed (23)`: only member 1's rows (6 types x 2) | | 2: drop the nonce from `SelectFilter`'s dependency list | `, invalidationNonce]);` | `Tests 5 failed \| 18 passed (23)`: only member 2's rows | | 3: drop the `tree` / `chart` exclusion | the two `currentViewType` comparisons | `Tests 2 failed \| 21 passed (23)`: only the two host-only controls, `find` 1 → 2 each | The pins import their subjects by relative path and `@object-ui/react` through the root vitest alias, so no `dist/` sits between the mutation and the run. ## Consumer sweep - `plugin-view` suite (under the verify lock): `Test Files 53 passed (53)`, `Tests 527 passed (527)`. - `plugin-dashboard` suite (under the verify lock): `Test Files 147 passed (147)`, `Tests 1339 passed | 6 skipped (1345)`. - Render-level consumers outside the two packages that mount `object-view` or a `dashboard` with `globalFilters`, 9 files in one run: `Test Files 9 passed (9)`, `Tests 657 passed (657)`: `timelineAxisRefusalReach-7070` (console), `catalog-gallery-render` and `plugin-dashboard-gallery-render` (schema-catalog), `rowClickModifierPayloadForward-9462`, `ObjectView.densityModeThroughFold-7924`, `ViewPreview.optionsFold-7928`, `StudioDesignSurface.gridRefresh`, `DashboardWidgetInspector` (app-shell), `DashboardDesignPage.presetAlias` (plugin-designer). - objectui#10519 census test on `main`: none (0 test files under `apps/` or `packages/app-shell` name 10519, at base and at `origin/main` `6a7f24e92c`; the same grep over `packages/` test files at base finds the 11 earlier reader pins, so the pattern is live). - The suites ran at `a70c7607c4`. Every later commit touches only the two pin files, which were re-run at `56354a1c38`. ## Gates (head `56354a1c38`) - `type-check` for both packages (echoed as `tsc --noEmit && tsc -p tsconfig.test.json`, the second program includes the pins): Done, Done. - `check:control-bytes`, `check:new-line-citations` (`0 new citation(s)`), `check:phantom-deps`, `check:unused-deps`, `check:test-path-roots`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:shell-escape-residue`, `changeset:check`, `check-changeset-presence` (`4 source file(s) of 2 released package(s) changed, and this change declares 2 changeset(s)`): all exit 0. - `check-governed-queue-guard --test` over the 6 changed paths: `NOT GOVERNED`. - ESLint on the 4 changed files, `--no-inline-config --format json`: 4 files, 0 errors; the pins carry 0 warnings. The two source files carry the same warnings per rule on base and head (`ObjectView.tsx` 60, `DashboardFilterBar.tsx` 18, read by linting each file's base blob through `--stdin-filename`). A narrowed run, not the repo lint: the root config enables no type-aware linting, so this diff cannot move a verdict on an untouched file. `pnpm lint` is CI's. - Dependency closure built first (`turbo run build` over the `^...` closures of both packages, 16 tasks), because `type-check` reads the built `.d.ts`. ## Serial - PR #10884 is merged (squash `35d68c4ce5`, which is this branch's base). `git merge-tree --write-tree` of this head against that PR's head `5dc80e3998` and against `origin/main` `6a7f24e92c`: both clean. - This change's three `ObjectView` hunks are the `@object-ui/react` import, the hook above the non-grid fetch effect, and that effect's dependency list, all above `renderContent`. - No open PR named `DashboardFilterBar` at claim time (claim record). ## Acceptance notes - **What this unblocks for objectui#10519 (round 4 of PR #10777):** census rows for `object-view` (kanban, plus calendar, gallery, timeline, map, gantt, all measured here) and for a dashboard with an `optionsFrom` filter can now be green without the remount. The react-page row waits on the member 3 decision. - The `renderContent` comment on `identityKey` still says the data-fed views are re-read "when `refreshKey` moves". That is now incomplete (they also re-read on the bus) but not false, and it sits in the region this PR was told not to touch. Carrier: none. - Observation, not filed: for the host-only `tree` and `chart`, `ObjectView` still runs its non-grid fetch on mount and on `refreshKey` although neither renderer draws those rows. That is a read nobody uses; it predates this change and this change adds no read to it. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 244d516 commit 9f0c84a

6 files changed

Lines changed: 507 additions & 2 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@object-ui/plugin-dashboard': patch
3+
---
4+
5+
fix(plugin-dashboard): a dashboard filter's `optionsFrom` options re-read on the data-invalidation bus
6+
7+
A `select` or `lookup` entry in a dashboard's `globalFilters` that reads its
8+
options through `optionsFrom` now re-reads them when the bus reports a change to
9+
`optionsFrom.object` (`notifyDataChanged`, as a page action over raw HTTP
10+
does), or an unscoped `'*'`, on either read: the server-side dataset GROUP BY
11+
and the client-side `find` fallback. A change to another object does not
12+
re-read. The options on screen stay until the re-read answers, and the value the
13+
user selected is kept: it is the dashboard variable's, and the read never writes
14+
it. Before, the options refreshed after such a write only when the host
15+
remounted the dashboard, and `PageView` is about to stop doing that
16+
(objectui#10519).
17+
18+
A filter with authored `options` and no `optionsFrom` reads nothing and does not
19+
subscribe.
20+
21+
**Clause-②: no** — no exported symbol, prop or authored key is added, removed,
22+
renamed or retyped, and no accept set moves. What changes is when an existing
23+
read runs.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@object-ui/plugin-view': patch
3+
---
4+
5+
fix(plugin-view): an `object-view` in a non-grid view re-reads its rows on the data-invalidation bus
6+
7+
For the non-grid views (kanban, calendar, gallery, timeline, map, gantt),
8+
`ObjectView` fetches the rows itself and hands them to the inner view as
9+
`data`, which switches off that view's own bus reader. That fetch
10+
now names the `useDataInvalidation` nonce for `schema.objectName`, so a write
11+
declared on the bus (`notifyDataChanged`, as a page action over raw HTTP does),
12+
or an unscoped `'*'`, re-reads the rows in place: the inner view is not
13+
remounted. A change to another object does not re-read. Before, such a write
14+
reached these views only when their host remounted them, and `PageView` is
15+
about to stop doing that (objectui#10519).
16+
17+
The subscription follows the rows the view draws: a host `renderListView` (its
18+
`ListView` reads the bus itself) and the grid (`ObjectGrid` does too) do not
19+
subscribe here, and neither does a view with no object or no data source. Nor
20+
do the host-only `tree` and `chart` views: their renderers query for themselves
21+
and read the bus themselves, so a re-read here would only add reads beside
22+
theirs.
23+
24+
**Clause-②: no** — no exported symbol, prop or authored key is added, removed,
25+
renamed or retyped, and no accept set moves. What changes is when an existing
26+
read runs.

‎packages/plugin-dashboard/src/DashboardFilterBar.tsx‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
} from '@object-ui/components';
3434
import { CalendarIcon, RotateCcw } from 'lucide-react';
3535
import { useSafeTranslate, useObjectTranslation, useSafeFieldLabel, pickLocalized } from '@object-ui/i18n';
36+
import { useDataInvalidation } from '@object-ui/react';
3637
import {
3738
DATE_RANGE_PRESETS,
3839
toDisplayDate,
@@ -397,6 +398,22 @@ function SelectFilter({ def, value, onChange, dataSource }: { def: DashboardFilt
397398
// it, by CONTENT: an equal filter in a fresh object is not a change
398399
// (AGENTS.md #10).
399400
const optionsFilterKey = JSON.stringify(from?.filter ?? null);
401+
// objectui#10887 — the data-invalidation bus (`notifyDataChanged` from
402+
// `@object-ui/react`), read the objectui#10853 way (the record picker's
403+
// options): the nonce moves when the bus reports a change to the object the
404+
// options are read from (or `'*'`), and the effect below names it, so the
405+
// options are re-read. Before, a page action over raw HTTP left them stale
406+
// unless `PageView` remounted the page, and objectui#10519 removes that
407+
// remount. The re-read is in place: the options on screen stay until the
408+
// answer swaps them (nothing here resets `dynamicOptions`), and the selected
409+
// value is the dashboard variable's, which this effect never writes.
410+
// Subscribed exactly when the effect below can read (an `optionsFrom` and an
411+
// adapter that serves either read).
412+
const invalidationNonce = useDataInvalidation(
413+
from && dataSource && (typeof dataSource.queryDataset === 'function' || typeof dataSource.find === 'function')
414+
? from.object || undefined
415+
: undefined,
416+
);
400417
useEffect(() => {
401418
if (!from || !dataSource) return;
402419
let cancelled = false;
@@ -473,7 +490,7 @@ function SelectFilter({ def, value, onChange, dataSource }: { def: DashboardFilt
473490
}
474491
return () => { cancelled = true; };
475492
// eslint-disable-next-line react-hooks/exhaustive-deps
476-
}, [from?.object, from?.valueField, from?.labelField, optionsFilterKey, dataSource]);
493+
}, [from?.object, from?.valueField, from?.labelField, optionsFilterKey, dataSource, invalidationNonce]);
477494

478495
const localizedOptions = useMemo(() => {
479496
// `def.options` is already normalized to `{ value, label }` PAIRS by
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#10887 member 2 — a dashboard filter's `optionsFrom` options are
11+
* re-read when the data-invalidation bus (`notifyDataChanged` from
12+
* `@object-ui/react`) reports a change to `optionsFrom.object`, and the value
13+
* the user selected survives the re-read.
14+
*
15+
* Before this card the options effect keyed on the object, the fields, the
16+
* options filter and the data source, and on no nonce. After a page action over
17+
* raw HTTP the filter kept offering the pre-action values until `PageView`
18+
* remounted the page, and objectui#10519 removes that remount. The effect now
19+
* names the `useDataInvalidation` nonce for `optionsFrom.object`, the
20+
* objectui#10853 shape (the record picker's options).
21+
*
22+
* Rendered through the real `SchemaRenderer` and this package's own
23+
* registration of `dashboard`, with the adapter injected by
24+
* `SchemaRendererProvider`, on both option reads: the server-side dataset
25+
* GROUP BY and the client-side `find` fallback. The selected value lives in the
26+
* dashboard's own variables provider, so it is chosen through the real select.
27+
* The bare `useDataInvalidation` reader beside the dashboard and an
28+
* `object-metric` block (it reads the bus itself) are the positive controls.
29+
*/
30+
import * as React from 'react';
31+
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
32+
import { render, act, cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
33+
import { SchemaRenderer, SchemaRendererProvider, notifyDataChanged, useDataInvalidation } from '@object-ui/react';
34+
// Module scope, not a hook: this import IS the registration of `dashboard`.
35+
import '../index';
36+
37+
// Radix Select opens on pointer events the DOM environment does not implement;
38+
// the same shim `DashboardFilterBar.options.test.tsx` uses.
39+
beforeAll(() => {
40+
class MockPointerEvent extends Event {
41+
button: number;
42+
ctrlKey: boolean;
43+
pointerType: string;
44+
constructor(type: string, props: PointerEventInit = {}) {
45+
super(type, props);
46+
this.button = props.button ?? 0;
47+
this.ctrlKey = props.ctrlKey ?? false;
48+
this.pointerType = props.pointerType ?? 'mouse';
49+
}
50+
}
51+
Object.assign(window, { PointerEvent: MockPointerEvent });
52+
Object.assign(HTMLElement.prototype, {
53+
hasPointerCapture: vi.fn(),
54+
releasePointerCapture: vi.fn(),
55+
scrollIntoView: vi.fn(),
56+
});
57+
});
58+
59+
afterEach(cleanup);
60+
61+
type Path = 'dataset' | 'fallback';
62+
63+
/**
64+
* The options on the first read are `finance` and `retail`; every later read
65+
* adds `energy`, so a re-read that landed is visible in the list.
66+
*/
67+
function makeDataSource(path: Path) {
68+
let answered = 0;
69+
const values = () => {
70+
answered += 1;
71+
return answered === 1 ? ['finance', 'retail'] : ['energy', 'finance', 'retail'];
72+
};
73+
const find = vi.fn(async (_object: string, _query?: unknown) => ({ data: values().map((industry) => ({ industry })) }));
74+
const queryDataset = vi.fn(async (_draft: unknown, _selection?: unknown) => ({
75+
rows: values().map((industry) => ({ industry, option_count: 1 })),
76+
}));
77+
return path === 'dataset' ? { find, queryDataset } : { find };
78+
}
79+
80+
/** The option reads, whichever of the two paths served them. */
81+
const reads = (ds: ReturnType<typeof makeDataSource>, path: Path) =>
82+
path === 'dataset' ? (ds as { queryDataset: ReturnType<typeof vi.fn> }).queryDataset : ds.find;
83+
84+
/** The positive control: a bare reader of the filter's source object. */
85+
function BusControl() {
86+
return <span data-testid="bus-control">{useDataInvalidation('accounts')}</span>;
87+
}
88+
89+
const selectFilter = (extra: Record<string, unknown> = {}) => ({
90+
name: 'industry',
91+
field: 'industry',
92+
label: 'Industry',
93+
type: 'select',
94+
...extra,
95+
});
96+
97+
const dashboardWith = (filter: Record<string, unknown>) => ({ type: 'dashboard', globalFilters: [filter], widgets: [] });
98+
99+
const OPTIONS_FROM = { optionsFrom: { object: 'accounts', valueField: 'industry', labelField: 'industry' } };
100+
101+
function mount(path: Path, node: Record<string, unknown> = dashboardWith(selectFilter(OPTIONS_FROM))) {
102+
const ds = makeDataSource(path);
103+
render(
104+
<SchemaRendererProvider dataSource={ds as never}>
105+
<BusControl />
106+
<SchemaRenderer schema={node as never} />
107+
</SchemaRendererProvider>,
108+
);
109+
return ds;
110+
}
111+
112+
const rest = () => act(() => new Promise<void>((resolve) => setTimeout(resolve, 60)));
113+
const emit = (change: { objectName: string; recordId?: string }) =>
114+
act(async () => {
115+
notifyDataChanged(change);
116+
});
117+
const trigger = () => screen.getByTestId('dashboard-filter-industry');
118+
119+
async function mountAtRest(path: Path) {
120+
const ds = mount(path);
121+
const read = reads(ds, path);
122+
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
123+
await rest();
124+
expect(read, 'one read on mount').toHaveBeenCalledTimes(1);
125+
return { ds, read };
126+
}
127+
128+
describe('a dashboard filter re-reads its optionsFrom options on the data-invalidation bus (objectui#10887)', () => {
129+
for (const path of ['dataset', 'fallback'] as const) {
130+
it(`an unscoped change ("*") re-reads the options once (${path} read)`, async () => {
131+
const { read } = await mountAtRest(path);
132+
133+
await emit({ objectName: '*' });
134+
await rest();
135+
136+
expect(screen.getByTestId('bus-control').textContent, 'control: the event never reached a subscriber').toBe('1');
137+
expect(read, 'the options never re-read after the bus reported a change').toHaveBeenCalledTimes(2);
138+
});
139+
140+
it(`a change to its own object re-reads once; an unrelated object does not (${path} read)`, async () => {
141+
const { read } = await mountAtRest(path);
142+
143+
await emit({ objectName: 'unrelated_object' });
144+
await rest();
145+
expect(read, 'a change to another object re-read the options').toHaveBeenCalledTimes(1);
146+
147+
await emit({ objectName: 'accounts', recordId: 'a1' });
148+
await rest();
149+
expect(read, 'a change to its own object did not re-read the options exactly once').toHaveBeenCalledTimes(2);
150+
});
151+
}
152+
153+
it('the selected value survives the re-read, and the re-read options reach the list', async () => {
154+
const { read } = await mountAtRest('dataset');
155+
156+
fireEvent.pointerDown(trigger(), { button: 0 });
157+
fireEvent.click(await screen.findByRole('option', { name: 'retail' }));
158+
await waitFor(() => expect(trigger().textContent).toBe('retail'));
159+
await rest();
160+
const triggerNode = trigger();
161+
162+
await emit({ objectName: '*' });
163+
await rest();
164+
expect(read).toHaveBeenCalledTimes(2);
165+
166+
expect(trigger(), 'the re-read remounted the filter').toBe(triggerNode);
167+
expect(trigger().textContent, 'the re-read dropped the selected value').toBe('retail');
168+
fireEvent.pointerDown(trigger(), { button: 0 });
169+
expect(await screen.findByRole('option', { name: 'energy' }), 'the re-read options never reached the list').toBeTruthy();
170+
expect(trigger().textContent).toBe('retail');
171+
});
172+
173+
it('lit control: an object-metric block beside the dashboard re-reads on the same event through its own reader', async () => {
174+
const ds = { ...makeDataSource('dataset'), aggregate: vi.fn(async () => [{ industry: 3 }]) };
175+
render(
176+
<SchemaRendererProvider dataSource={ds as never}>
177+
<SchemaRenderer schema={dashboardWith(selectFilter(OPTIONS_FROM)) as never} />
178+
<SchemaRenderer
179+
schema={{ type: 'object-metric', objectName: 'accounts', aggregate: { field: 'industry', function: 'count' }, label: 'Accounts' } as never}
180+
/>
181+
</SchemaRendererProvider>,
182+
);
183+
await waitFor(() => expect(ds.aggregate).toHaveBeenCalledTimes(1));
184+
await rest();
185+
186+
await emit({ objectName: '*' });
187+
await rest();
188+
189+
expect(ds.aggregate, 'the metric never re-read: the event did not reach a block that reads the bus').toHaveBeenCalledTimes(2);
190+
});
191+
192+
it('control: a select filter with no optionsFrom reads nothing, on mount or on an invalidation', async () => {
193+
const ds = mount('dataset', dashboardWith(selectFilter({ options: [{ value: 'finance', label: 'Finance' }] })));
194+
await rest();
195+
196+
await emit({ objectName: '*' });
197+
await rest();
198+
199+
expect(screen.getByTestId('bus-control').textContent).toBe('1');
200+
expect(reads(ds, 'dataset')).not.toHaveBeenCalled();
201+
expect(ds.find).not.toHaveBeenCalled();
202+
});
203+
});

‎packages/plugin-view/src/ObjectView.tsx‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ import {
8181
resolveFilterPlaceholders,
8282
type FilterTokenScope,
8383
} from '@object-ui/core';
84-
import { SchemaRenderer as ImportedSchemaRenderer, useSettledSchema, notifyDataChanged, useFilterScope } from '@object-ui/react';
84+
import { SchemaRenderer as ImportedSchemaRenderer, useSettledSchema, notifyDataChanged, useDataInvalidation, useFilterScope } from '@object-ui/react';
8585
import type { HandleClickModifiers } from '@object-ui/react';
8686
import { usePermissions } from '@object-ui/permissions';
8787
import { ViewSwitcher } from './ViewSwitcher';
@@ -1100,6 +1100,30 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
11001100
// an equal sort in a fresh array is not a change (AGENTS.md #10).
11011101
const tableSortKey = JSON.stringify(schema.table?.sort ?? null);
11021102

1103+
// objectui#10887 — the data-invalidation bus (`notifyDataChanged` from
1104+
// `@object-ui/react`), read the objectui#10623 / objectui#10778 /
1105+
// objectui#10853 way: the nonce moves when the bus reports a change to the
1106+
// object this fetch QUERIES (or `'*'`), and the effect below names it, so
1107+
// the rows are re-read in place. The inner view receives them as `data`,
1108+
// which switches off its own bus reader, and `refreshKey` moves only on this
1109+
// view's own write and `onMutation`; a page action over raw HTTP fires
1110+
// neither, so before this the rows were re-read only when `PageView`
1111+
// remounted the page (objectui#10519 removes that remount).
1112+
//
1113+
// Subscribed exactly when these rows are what the view draws. A host
1114+
// `renderListView` (its `ListView` reads the bus itself) and the grid
1115+
// (`ObjectGrid` does too) are not this effect's query, and neither is a view
1116+
// with no object or no adapter. The two host-only types query for
1117+
// themselves and read the bus themselves, so a re-read here would only add
1118+
// reads: `ObjectTree` runs its own query ahead of the rows handed to it
1119+
// (objectui#10778) and re-queries whenever that array changes, and
1120+
// `ObjectChart` never reads them (objectui#10035).
1121+
const fetchDrawsView =
1122+
!renderListView && currentViewType !== 'grid' && currentViewType !== 'tree' && currentViewType !== 'chart';
1123+
const invalidationNonce = useDataInvalidation(
1124+
fetchDrawsView && dataSource ? schema.objectName || undefined : undefined,
1125+
);
1126+
11031127
// Fetch data for non-grid view types (grid handles its own data via ObjectGrid)
11041128
useEffect(() => {
11051129
let isMounted = true;
@@ -1309,6 +1333,7 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
13091333
schema.objectName, dataSource, currentViewType, refreshKey,
13101334
currentNamedViewConfig, activeViewQueryInputs, renderListView,
13111335
objectSchemaReady, objectSchema, perms, authoredFilters, tableSortKey,
1336+
invalidationNonce,
13121337
]);
13131338

13141339
// Determine layout mode. #2578: default the record surface from how heavy the

0 commit comments

Comments
 (0)