From 0939f57b837ff4b359beb3cdc515fae40d88b96a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:31:30 +0000 Subject: [PATCH 1/5] fix(plugin-view,plugin-dashboard): object-view's non-grid fetch and a dashboard filter's optionsFrom options re-read on the data-invalidation bus (objectui#10887) ObjectView's non-grid fetch effect now names the useDataInvalidation nonce for schema.objectName, subscribed exactly when that effect queries (no host renderListView, not the grid, an object and an adapter). SelectFilter's optionsFrom read names the nonce for optionsFrom.object; the options stay on screen until the re-read answers, and the selected value (the dashboard variable) is never written by the read. Pins through the real SchemaRenderer: one read on mount, one more after '*', one more after the view's own object, none after an unrelated object, the same inner instance, the selected filter value kept, and a control with no object that never reads. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../src/DashboardFilterBar.tsx | 19 +- ...ashboardFilterBar.busReread-10887.test.tsx | 182 ++++++++++++++++++ packages/plugin-view/src/ObjectView.tsx | 20 +- .../ObjectView.busReread-10887.test.tsx | 160 +++++++++++++++ 4 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 packages/plugin-dashboard/src/__tests__/DashboardFilterBar.busReread-10887.test.tsx create mode 100644 packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx diff --git a/packages/plugin-dashboard/src/DashboardFilterBar.tsx b/packages/plugin-dashboard/src/DashboardFilterBar.tsx index 0d534faf48..010cd9583f 100644 --- a/packages/plugin-dashboard/src/DashboardFilterBar.tsx +++ b/packages/plugin-dashboard/src/DashboardFilterBar.tsx @@ -33,6 +33,7 @@ import { } from '@object-ui/components'; import { CalendarIcon, RotateCcw } from 'lucide-react'; import { useSafeTranslate, useObjectTranslation, useSafeFieldLabel, pickLocalized } from '@object-ui/i18n'; +import { useDataInvalidation } from '@object-ui/react'; import { DATE_RANGE_PRESETS, toDisplayDate, @@ -397,6 +398,22 @@ function SelectFilter({ def, value, onChange, dataSource }: { def: DashboardFilt // it, by CONTENT: an equal filter in a fresh object is not a change // (AGENTS.md #10). const optionsFilterKey = JSON.stringify(from?.filter ?? null); + // objectui#10887 — the data-invalidation bus (`notifyDataChanged` from + // `@object-ui/react`), read the objectui#10853 way (the record picker's + // options): the nonce moves when the bus reports a change to the object the + // options are read from (or `'*'`), and the effect below names it, so the + // options are re-read. Before, a page action over raw HTTP left them stale + // unless `PageView` remounted the page, and objectui#10519 removes that + // remount. The re-read is in place: the options on screen stay until the + // answer swaps them (nothing here resets `dynamicOptions`), and the selected + // value is the dashboard variable's, which this effect never writes. + // Subscribed exactly when the effect below can read (an `optionsFrom` and an + // adapter that serves either read). + const invalidationNonce = useDataInvalidation( + from && dataSource && (typeof dataSource.queryDataset === 'function' || typeof dataSource.find === 'function') + ? from.object || undefined + : undefined, + ); useEffect(() => { if (!from || !dataSource) return; let cancelled = false; @@ -473,7 +490,7 @@ function SelectFilter({ def, value, onChange, dataSource }: { def: DashboardFilt } return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [from?.object, from?.valueField, from?.labelField, optionsFilterKey, dataSource]); + }, [from?.object, from?.valueField, from?.labelField, optionsFilterKey, dataSource, invalidationNonce]); const localizedOptions = useMemo(() => { // `def.options` is already normalized to `{ value, label }` PAIRS by diff --git a/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.busReread-10887.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.busReread-10887.test.tsx new file mode 100644 index 0000000000..d4f0607706 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.busReread-10887.test.tsx @@ -0,0 +1,182 @@ +/** + * 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. + */ + +/** + * objectui#10887 member 2 — a dashboard filter's `optionsFrom` options are + * re-read when the data-invalidation bus (`notifyDataChanged` from + * `@object-ui/react`) reports a change to `optionsFrom.object`, and the value + * the user selected survives the re-read. + * + * Before this card the options effect keyed on the object, the fields, the + * options filter and the data source, and on no nonce. After a page action over + * raw HTTP the filter kept offering the pre-action values until `PageView` + * remounted the page, and objectui#10519 removes that remount. The effect now + * names the `useDataInvalidation` nonce for `optionsFrom.object`, the + * objectui#10853 shape (the record picker's options). + * + * Rendered through the real `SchemaRenderer` and this package's own + * registration of `dashboard`, with the adapter injected by + * `SchemaRendererProvider`, on both option reads: the server-side dataset + * GROUP BY and the client-side `find` fallback. The selected value lives in the + * dashboard's own variables provider, so it is chosen through the real select. + * The bare `useDataInvalidation` reader beside the dashboard is the positive + * control. + */ +import * as React from 'react'; +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; +import { render, act, cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, notifyDataChanged, useDataInvalidation } from '@object-ui/react'; +// Module scope, not a hook: this import IS the registration of `dashboard`. +import '../index'; + +// Radix Select opens on pointer events the DOM environment does not implement; +// the same shim `DashboardFilterBar.options.test.tsx` uses. +beforeAll(() => { + class MockPointerEvent extends Event { + button: number; + ctrlKey: boolean; + pointerType: string; + constructor(type: string, props: any = {}) { + super(type, props); + this.button = props.button ?? 0; + this.ctrlKey = props.ctrlKey ?? false; + this.pointerType = props.pointerType ?? 'mouse'; + } + } + (window as any).PointerEvent = MockPointerEvent; + (HTMLElement.prototype as any).hasPointerCapture = vi.fn(); + (HTMLElement.prototype as any).releasePointerCapture = vi.fn(); + (HTMLElement.prototype as any).scrollIntoView = vi.fn(); +}); + +afterEach(cleanup); + +type Path = 'dataset' | 'fallback'; + +/** + * The options on the first read are `finance` and `retail`; every later read + * adds `energy`, so a re-read that landed is visible in the list. + */ +function makeDataSource(path: Path) { + let answered = 0; + const values = () => { + answered += 1; + return answered === 1 ? ['finance', 'retail'] : ['energy', 'finance', 'retail']; + }; + const find = vi.fn(async (_object: string, _query?: unknown) => ({ data: values().map((industry) => ({ industry })) })); + const queryDataset = vi.fn(async (_draft: unknown, _selection?: unknown) => ({ + rows: values().map((industry) => ({ industry, option_count: 1 })), + })); + return path === 'dataset' ? { find, queryDataset } : { find }; +} + +/** The option reads, whichever of the two paths served them. */ +const reads = (ds: ReturnType, path: Path) => + path === 'dataset' ? (ds as { queryDataset: ReturnType }).queryDataset : ds.find; + +/** The positive control: a bare reader of the filter's source object. */ +function BusControl() { + return {useDataInvalidation('accounts')}; +} + +const selectFilter = (extra: Record = {}) => ({ + name: 'industry', + field: 'industry', + label: 'Industry', + type: 'select', + ...extra, +}); + +const dashboardWith = (filter: Record) => ({ type: 'dashboard', globalFilters: [filter], widgets: [] }); + +const OPTIONS_FROM = { optionsFrom: { object: 'accounts', valueField: 'industry', labelField: 'industry' } }; + +function mount(path: Path, node: Record = dashboardWith(selectFilter(OPTIONS_FROM))) { + const ds = makeDataSource(path); + render( + + + + , + ); + return ds; +} + +const rest = () => act(() => new Promise((resolve) => setTimeout(resolve, 60))); +const emit = (change: { objectName: string; recordId?: string }) => + act(async () => { + notifyDataChanged(change); + }); +const trigger = () => screen.getByTestId('dashboard-filter-industry'); + +async function mountAtRest(path: Path) { + const ds = mount(path); + const read = reads(ds, path); + await waitFor(() => expect(read).toHaveBeenCalledTimes(1)); + await rest(); + expect(read, 'one read on mount').toHaveBeenCalledTimes(1); + return { ds, read }; +} + +describe('a dashboard filter re-reads its optionsFrom options on the data-invalidation bus (objectui#10887)', () => { + for (const path of ['dataset', 'fallback'] as const) { + it(`an unscoped change ("*") re-reads the options once (${path} read)`, async () => { + const { read } = await mountAtRest(path); + + await emit({ objectName: '*' }); + await rest(); + + expect(screen.getByTestId('bus-control').textContent, 'control: the event never reached a subscriber').toBe('1'); + expect(read, 'the options never re-read after the bus reported a change').toHaveBeenCalledTimes(2); + }); + + it(`a change to its own object re-reads once; an unrelated object does not (${path} read)`, async () => { + const { read } = await mountAtRest(path); + + await emit({ objectName: 'unrelated_object' }); + await rest(); + expect(read, 'a change to another object re-read the options').toHaveBeenCalledTimes(1); + + await emit({ objectName: 'accounts', recordId: 'a1' }); + await rest(); + expect(read, 'a change to its own object did not re-read the options exactly once').toHaveBeenCalledTimes(2); + }); + } + + it('the selected value survives the re-read, and the re-read options reach the list', async () => { + const { read } = await mountAtRest('dataset'); + + fireEvent.pointerDown(trigger(), { button: 0 }); + fireEvent.click(await screen.findByRole('option', { name: 'retail' })); + await waitFor(() => expect(trigger().textContent).toBe('retail')); + await rest(); + const triggerNode = trigger(); + + await emit({ objectName: '*' }); + await rest(); + expect(read).toHaveBeenCalledTimes(2); + + expect(trigger(), 'the re-read remounted the filter').toBe(triggerNode); + expect(trigger().textContent, 'the re-read dropped the selected value').toBe('retail'); + fireEvent.pointerDown(trigger(), { button: 0 }); + expect(await screen.findByRole('option', { name: 'energy' }), 'the re-read options never reached the list').toBeTruthy(); + expect(trigger().textContent).toBe('retail'); + }); + + it('control: a select filter with no optionsFrom reads nothing, on mount or on an invalidation', async () => { + const ds = mount('dataset', dashboardWith(selectFilter({ options: [{ value: 'finance', label: 'Finance' }] }))); + await rest(); + + await emit({ objectName: '*' }); + await rest(); + + expect(screen.getByTestId('bus-control').textContent).toBe('1'); + expect(reads(ds, 'dataset')).not.toHaveBeenCalled(); + expect(ds.find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 55cf6088c9..de842df1af 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -81,7 +81,7 @@ import { resolveFilterPlaceholders, type FilterTokenScope, } from '@object-ui/core'; -import { SchemaRenderer as ImportedSchemaRenderer, useSettledSchema, notifyDataChanged, useFilterScope } from '@object-ui/react'; +import { SchemaRenderer as ImportedSchemaRenderer, useSettledSchema, notifyDataChanged, useDataInvalidation, useFilterScope } from '@object-ui/react'; import type { HandleClickModifiers } from '@object-ui/react'; import { usePermissions } from '@object-ui/permissions'; import { ViewSwitcher } from './ViewSwitcher'; @@ -1100,6 +1100,23 @@ export const ObjectView: React.FC = ({ // an equal sort in a fresh array is not a change (AGENTS.md #10). const tableSortKey = JSON.stringify(schema.table?.sort ?? null); + // objectui#10887 — the data-invalidation bus (`notifyDataChanged` from + // `@object-ui/react`), read the objectui#10623 / objectui#10778 / + // 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). Subscribed + // exactly when the effect below queries: a host `renderListView` (whose + // `ListView` reads the bus itself) and the grid (`ObjectGrid` does too) are + // not this effect's query, and neither is a view with no object or no + // adapter. + const invalidationNonce = useDataInvalidation( + !renderListView && currentViewType !== 'grid' && dataSource ? schema.objectName || undefined : undefined, + ); + // Fetch data for non-grid view types (grid handles its own data via ObjectGrid) useEffect(() => { let isMounted = true; @@ -1309,6 +1326,7 @@ export const ObjectView: React.FC = ({ schema.objectName, dataSource, currentViewType, refreshKey, currentNamedViewConfig, activeViewQueryInputs, renderListView, objectSchemaReady, objectSchema, perms, authoredFilters, tableSortKey, + invalidationNonce, ]); // Determine layout mode. #2578: default the record surface from how heavy the diff --git a/packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx new file mode 100644 index 0000000000..10a55f503f --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx @@ -0,0 +1,160 @@ +/** + * 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. + */ + +/** + * objectui#10887 member 1 — an `object-view` in a non-grid view re-reads its + * rows when the data-invalidation bus (`notifyDataChanged` from + * `@object-ui/react`) reports a change to the object it queries. + * + * For every non-grid view `ObjectView` fetches the rows itself and hands them + * to the inner view as `data`, which switches off that view's own bus reader. + * Before this card the fetch effect keyed on `refreshKey`, which moves only on + * the view's own write and `dataSource.onMutation`. A page action over raw HTTP + * fires neither, so the rows were re-read only because `PageView` remounted + * the page, and objectui#10519 removes that remount. The effect now names the + * `useDataInvalidation` nonce for `schema.objectName`, the objectui#10623 / + * #10778 / #10853 shape. + * + * Rendered through the real `SchemaRenderer` and this package's own + * registration of `object-view`, in the page-region shape (`properties`). The + * fake data source counts reads and implements no `onMutation`, as a page + * action's raw HTTP write announces none. The inner views are stand-ins + * registered in the real registry: this package does not depend on the + * plugins that register them, and the rows are this component's read. Each + * stand-in carries an instance id from a `useState` initializer, so a changed + * id is a remount. + */ +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup, screen, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { + SchemaRenderer, + SchemaRendererProvider, + notifyDataChanged, + useDataInvalidation, +} from '@object-ui/react'; +// Module scope, not a hook: this import IS the registration of `object-view`. +import '../index'; + +const NON_GRID_TYPES = ['kanban', 'calendar', 'gallery', 'timeline', 'map'] as const; + +let instanceSeq = 0; +function InnerViewStandIn({ schema, data }: { schema?: { type?: string }; data?: unknown }) { + const [id] = React.useState(() => ++instanceSeq); + return ( +
+ ); +} +for (const t of NON_GRID_TYPES) ComponentRegistry.register(`object-${t}`, InnerViewStandIn as never); + +/** The positive control: a bare reader of the view's object. */ +function BusControl() { + return {useDataInvalidation('deal')}; +} + +beforeEach(() => { + // Best-effort metadata probes are not what these cases are about. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); +}); +afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); +}); + +/** Answers one row more on every read, so a later delivery is distinguishable. */ +function makeDataSource() { + let answered = 0; + return { + find: vi.fn(async () => { + answered += 1; + const rows = Array.from({ length: answered }, (_, i) => ({ id: String(i + 1), name: `Deal ${i + 1}` })); + return { data: rows, total: rows.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: 'deal', fields: { name: { type: 'text' } } })), + }; +} + +function mount(properties: Record) { + const ds = makeDataSource(); + render( + + + + , + ); + return ds; +} + +const rest = () => act(() => new Promise((resolve) => setTimeout(resolve, 60))); +const emit = (change: { objectName: string; recordId?: string }) => + act(async () => { + notifyDataChanged(change); + }); +const inner = () => screen.getByTestId('inner-view'); + +async function mountAtRest(viewType: string) { + const ds = mount({ objectName: 'deal', defaultViewType: viewType }); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(inner().getAttribute('data-rows')).toBe('1')); + await rest(); + expect(ds.find, 'one read on mount').toHaveBeenCalledTimes(1); + return ds; +} + +describe('object-view non-grid views re-read on the data-invalidation bus (objectui#10887)', () => { + for (const viewType of NON_GRID_TYPES) { + it(`${viewType}: an unscoped change ("*") re-reads once, in place`, async () => { + const ds = await mountAtRest(viewType); + const instance = inner().getAttribute('data-instance'); + + await emit({ objectName: '*' }); + await rest(); + + expect(screen.getByTestId('bus-control').textContent, 'control: the event never reached a subscriber').toBe('1'); + expect(ds.find, 'the rows never re-read after the bus reported a change').toHaveBeenCalledTimes(2); + expect(ds.find.mock.calls[1][0]).toBe('deal'); + await waitFor(() => expect(inner().getAttribute('data-rows'), 'the re-read rows never reached the view').toBe('2')); + expect(inner().getAttribute('data-type')).toBe(`object-${viewType}`); + expect(inner().getAttribute('data-instance'), 'the re-read remounted the view').toBe(instance); + }); + + it(`${viewType}: a change to its own object re-reads once; an unrelated object does not`, async () => { + const ds = await mountAtRest(viewType); + + await emit({ objectName: 'unrelated_object' }); + await rest(); + expect(ds.find, 'a change to another object re-read the rows').toHaveBeenCalledTimes(1); + + await emit({ objectName: 'deal', recordId: '1' }); + await rest(); + expect(ds.find, 'a change to its own object did not re-read the rows exactly once').toHaveBeenCalledTimes(2); + await waitFor(() => expect(inner().getAttribute('data-rows')).toBe('2')); + }); + } + + it('control: an object-view with no object reads nothing, on mount or on an invalidation', async () => { + const ds = mount({ defaultViewType: 'kanban' }); + await rest(); + + await emit({ objectName: '*' }); + await rest(); + + expect(screen.getByTestId('bus-control').textContent).toBe('1'); + expect(ds.find).not.toHaveBeenCalled(); + }); +}); From 720c13b06c84758bb2c8e9d44cbc91a38a60462a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:37:08 +0000 Subject: [PATCH 2/5] fix(plugin-view): the non-grid fetch subscribes only where its rows are drawn; changesets (objectui#10887) The host-only tree and chart renderers query for themselves and read the bus themselves (ObjectTree runs its own query ahead of the rows handed to it and re-queries when that array changes; ObjectChart never reads them), so the non-grid fetch no longer subscribes for them: a re-read there would only add reads. Gantt joins the pinned view types, and two controls pin the host-composed tree and chart. One patch changeset per touched package. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...887-dashboard-filter-options-bus-reader.md | 23 +++++++++++ .../10887-object-view-non-grid-bus-reader.md | 26 ++++++++++++ packages/plugin-view/src/ObjectView.tsx | 19 ++++++--- .../ObjectView.busReread-10887.test.tsx | 40 ++++++++++++++++++- 4 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 .changeset/10887-dashboard-filter-options-bus-reader.md create mode 100644 .changeset/10887-object-view-non-grid-bus-reader.md diff --git a/.changeset/10887-dashboard-filter-options-bus-reader.md b/.changeset/10887-dashboard-filter-options-bus-reader.md new file mode 100644 index 0000000000..eeeb3fd42e --- /dev/null +++ b/.changeset/10887-dashboard-filter-options-bus-reader.md @@ -0,0 +1,23 @@ +--- +'@object-ui/plugin-dashboard': patch +--- + +fix(plugin-dashboard): a dashboard filter's `optionsFrom` options re-read on the data-invalidation bus + +A `select` or `lookup` entry in a dashboard's `globalFilters` that reads its +options through `optionsFrom` now re-reads them when the bus reports a change to +`optionsFrom.object` (`notifyDataChanged`, as a page action over raw HTTP +does), or an unscoped `'*'`, on either read: the server-side dataset GROUP BY +and the client-side `find` fallback. A change to another object does not +re-read. The options on screen stay until the re-read answers, and the value the +user selected is kept: it is the dashboard variable's, and the read never writes +it. Before, the options refreshed after such a write only when the host +remounted the dashboard, and `PageView` is about to stop doing that +(objectui#10519). + +A filter with authored `options` and no `optionsFrom` reads nothing and does not +subscribe. + +**Clause-②: no** — no exported symbol, prop or authored key is added, removed, +renamed or retyped, and no accept set moves. What changes is when an existing +read runs. diff --git a/.changeset/10887-object-view-non-grid-bus-reader.md b/.changeset/10887-object-view-non-grid-bus-reader.md new file mode 100644 index 0000000000..abfea1447b --- /dev/null +++ b/.changeset/10887-object-view-non-grid-bus-reader.md @@ -0,0 +1,26 @@ +--- +'@object-ui/plugin-view': patch +--- + +fix(plugin-view): an `object-view` in a non-grid view re-reads its rows on the data-invalidation bus + +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). + +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 +subscribe here, and neither does a view with no object or no data source. Nor +do the host-only `tree` and `chart` views: their renderers query for themselves +and read the bus themselves, so a re-read here would only add reads beside +theirs. + +**Clause-②: no** — no exported symbol, prop or authored key is added, removed, +renamed or retyped, and no accept set moves. What changes is when an existing +read runs. diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index de842df1af..b3891b799c 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -1108,13 +1108,20 @@ export const ObjectView: React.FC = ({ // 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). Subscribed - // exactly when the effect below queries: a host `renderListView` (whose - // `ListView` reads the bus itself) and the grid (`ObjectGrid` does too) are - // not this effect's query, and neither is a view with no object or no - // adapter. + // 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 + // (`ObjectGrid` does too) are not this effect's query, and neither is a view + // with no object or no adapter. The two host-only types query for + // themselves and read the bus themselves, so a re-read here would only add + // reads: `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 them (objectui#10035). + const fetchDrawsView = + !renderListView && currentViewType !== 'grid' && currentViewType !== 'tree' && currentViewType !== 'chart'; const invalidationNonce = useDataInvalidation( - !renderListView && currentViewType !== 'grid' && dataSource ? schema.objectName || undefined : undefined, + fetchDrawsView && dataSource ? schema.objectName || undefined : undefined, ); // Fetch data for non-grid view types (grid handles its own data via ObjectGrid) diff --git a/packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx index 10a55f503f..8693ad79d4 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx @@ -28,6 +28,10 @@ * plugins that register them, and the rows are this component's read. Each * stand-in carries an instance id from a `useState` initializer, so a changed * id is a remount. + * + * Controls: a view with no object never reads, and the two host-only types + * whose renderers query for themselves (`tree`, `chart`) are not re-read by + * this fetch. */ import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -41,8 +45,9 @@ import { } from '@object-ui/react'; // Module scope, not a hook: this import IS the registration of `object-view`. import '../index'; +import { ObjectView } from '../ObjectView'; -const NON_GRID_TYPES = ['kanban', 'calendar', 'gallery', 'timeline', 'map'] as const; +const NON_GRID_TYPES = ['kanban', 'calendar', 'gallery', 'timeline', 'map', 'gantt'] as const; let instanceSeq = 0; function InnerViewStandIn({ schema, data }: { schema?: { type?: string }; data?: unknown }) { @@ -56,7 +61,7 @@ function InnerViewStandIn({ schema, data }: { schema?: { type?: string }; data?: /> ); } -for (const t of NON_GRID_TYPES) ComponentRegistry.register(`object-${t}`, InnerViewStandIn as never); +for (const t of [...NON_GRID_TYPES, 'tree', 'chart']) ComponentRegistry.register(`object-${t}`, InnerViewStandIn as never); /** The positive control: a bare reader of the view's object. */ function BusControl() { @@ -157,4 +162,35 @@ describe('object-view non-grid views re-read on the data-invalidation bus (objec expect(screen.getByTestId('bus-control').textContent).toBe('1'); expect(ds.find).not.toHaveBeenCalled(); }); + + // `tree` and `chart` are reachable only from a host `views` prop, not through + // the registered renderer, so these compose the component the way a host + // does. Their renderers query for themselves and read the bus themselves: + // `ObjectTree` runs its own query ahead of the rows handed to it and + // re-queries when that array changes, and `ObjectChart` never reads them. A + // re-read of this component's rows would only add reads beside theirs. + for (const hostType of ['tree', 'chart'] as const) { + it(`control: a host-composed ${hostType} view is not re-read by this fetch`, async () => { + const ds = makeDataSource(); + render( + + + + , + ); + await waitFor(() => expect(inner().getAttribute('data-type')).toBe(`object-${hostType}`)); + await rest(); + const before = ds.find.mock.calls.length; + + await emit({ objectName: '*' }); + await rest(); + + expect(screen.getByTestId('bus-control').textContent).toBe('1'); + expect(ds.find, `the ${hostType} view's rows were re-read beside its own reader`).toHaveBeenCalledTimes(before); + }); + } }); From a70c7607c4299ac1cc57d8c2b80598a9ee016ac0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:44:03 +0000 Subject: [PATCH 3/5] test(plugin-view): declare the counting find's parameters so the pin's call reads type-check (objectui#10887) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../src/__tests__/ObjectView.busReread-10887.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx index 8693ad79d4..59727e6c5f 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx @@ -81,7 +81,8 @@ afterEach(() => { function makeDataSource() { let answered = 0; return { - find: vi.fn(async () => { + // The parameters are declared so `mock.calls[i][0]` below is typed. + find: vi.fn(async (_objectName: string, _query?: unknown) => { answered += 1; const rows = Array.from({ length: answered }, (_, i) => ({ id: String(i + 1), name: `Deal ${i + 1}` })); return { data: rows, total: rows.length }; From e6ed8458a0bd16c70244144d771b12d6b66ac018 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:59:30 +0000 Subject: [PATCH 4/5] test(plugin-dashboard): type the pointer-event shim in the bus re-read pin instead of casting to any (objectui#10887) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../DashboardFilterBar.busReread-10887.test.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.busReread-10887.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.busReread-10887.test.tsx index d4f0607706..8180d45d14 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.busReread-10887.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.busReread-10887.test.tsx @@ -41,17 +41,19 @@ beforeAll(() => { button: number; ctrlKey: boolean; pointerType: string; - constructor(type: string, props: any = {}) { + constructor(type: string, props: PointerEventInit = {}) { super(type, props); this.button = props.button ?? 0; this.ctrlKey = props.ctrlKey ?? false; this.pointerType = props.pointerType ?? 'mouse'; } } - (window as any).PointerEvent = MockPointerEvent; - (HTMLElement.prototype as any).hasPointerCapture = vi.fn(); - (HTMLElement.prototype as any).releasePointerCapture = vi.fn(); - (HTMLElement.prototype as any).scrollIntoView = vi.fn(); + Object.assign(window, { PointerEvent: MockPointerEvent }); + Object.assign(HTMLElement.prototype, { + hasPointerCapture: vi.fn(), + releasePointerCapture: vi.fn(), + scrollIntoView: vi.fn(), + }); }); afterEach(cleanup); From 56354a1c38c67076e9e4c2faa0fe2cc713ea4de5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:05:29 +0000 Subject: [PATCH 5/5] test(plugin-view,plugin-dashboard): a real bus-reading block beside each pin as its lit control (objectui#10887) A grid object-view (ObjectGrid reads the bus itself) and an object-metric block move on the same event the members are pinned on, green on the base and on the head. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...ashboardFilterBar.busReread-10887.test.tsx | 23 +++++++++++++++++-- .../ObjectView.busReread-10887.test.tsx | 20 +++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.busReread-10887.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.busReread-10887.test.tsx index 8180d45d14..6f67c7c8a7 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.busReread-10887.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.busReread-10887.test.tsx @@ -24,8 +24,8 @@ * `SchemaRendererProvider`, on both option reads: the server-side dataset * GROUP BY and the client-side `find` fallback. The selected value lives in the * dashboard's own variables provider, so it is chosen through the real select. - * The bare `useDataInvalidation` reader beside the dashboard is the positive - * control. + * The bare `useDataInvalidation` reader beside the dashboard and an + * `object-metric` block (it reads the bus itself) are the positive controls. */ import * as React from 'react'; import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; @@ -170,6 +170,25 @@ describe('a dashboard filter re-reads its optionsFrom options on the data-invali expect(trigger().textContent).toBe('retail'); }); + it('lit control: an object-metric block beside the dashboard re-reads on the same event through its own reader', async () => { + const ds = { ...makeDataSource('dataset'), aggregate: vi.fn(async () => [{ industry: 3 }]) }; + render( + + + + , + ); + await waitFor(() => expect(ds.aggregate).toHaveBeenCalledTimes(1)); + await rest(); + + await emit({ objectName: '*' }); + await rest(); + + expect(ds.aggregate, 'the metric never re-read: the event did not reach a block that reads the bus').toHaveBeenCalledTimes(2); + }); + it('control: a select filter with no optionsFrom reads nothing, on mount or on an invalidation', async () => { const ds = mount('dataset', dashboardWith(selectFilter({ options: [{ value: 'finance', label: 'Finance' }] }))); await rest(); diff --git a/packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx index 59727e6c5f..dc8ac5e6e4 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.busReread-10887.test.tsx @@ -29,9 +29,11 @@ * stand-in carries an instance id from a `useState` initializer, so a changed * id is a remount. * - * Controls: a view with no object never reads, and the two host-only types - * whose renderers query for themselves (`tree`, `chart`) are not re-read by - * this fetch. + * Controls: the bare `useDataInvalidation` reader beside the view and a grid + * `object-view` (`ObjectGrid` reads the bus itself) move on every event, a + * view with no object never reads, and the two host-only types whose + * renderers query for themselves (`tree`, `chart`) are not re-read by this + * fetch. */ import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -153,6 +155,18 @@ describe('object-view non-grid views re-read on the data-invalidation bus (objec }); } + it('lit control: a grid object-view re-reads on the same event through its own reader (ObjectGrid)', async () => { + const ds = mount({ objectName: 'deal', defaultViewType: 'grid' }); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + await rest(); + const before = ds.find.mock.calls.length; + + await emit({ objectName: '*' }); + await rest(); + + expect(ds.find, 'the grid never re-read: the event did not reach a block that reads the bus').toHaveBeenCalledTimes(before + 1); + }); + it('control: an object-view with no object reads nothing, on mount or on an invalidation', async () => { const ds = mount({ defaultViewType: 'kanban' }); await rest();