From a94af72fe19080d273859ad31c6bb3c97f42f5ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:35:32 +0000 Subject: [PATCH 1/8] fix(plugin-timeline): object-timeline re-reads on the data-invalidation bus (objectui#10623) The fetch effect now names the useDataInvalidation nonce for the object it queries, so a write declared with notifyDataChanged re-reads the rows in place. Authored items, host data and bound rows are not subscribed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...imeline.invalidationRefetch-10623.test.tsx | 134 ++++++++++++++++++ .../plugin-timeline/src/ObjectTimeline.tsx | 17 ++- 2 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 packages/plugin-timeline/src/ObjectTimeline.invalidationRefetch-10623.test.tsx diff --git a/packages/plugin-timeline/src/ObjectTimeline.invalidationRefetch-10623.test.tsx b/packages/plugin-timeline/src/ObjectTimeline.invalidationRefetch-10623.test.tsx new file mode 100644 index 0000000000..cd043b018a --- /dev/null +++ b/packages/plugin-timeline/src/ObjectTimeline.invalidationRefetch-10623.test.tsx @@ -0,0 +1,134 @@ +/** + * 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#10623 — an `object-timeline` block re-reads its rows when the + * data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a + * write to the object it queries, and it does so in place. + * + * Before this card the fetch effect named no nonce, so a write declared on the + * bus (a page action over raw HTTP, a flow, a server action) left the rail + * stale until something remounted it. The effect now names the + * `useDataInvalidation` nonce for the object it queries (the objectui#10494 + * shape). + * + * Rendered through the real `SchemaRenderer` and this package's own + * registration, over a fake data source that counts reads. The bare + * `useDataInvalidation` reader mounted beside the block is the positive + * control: it proves the event reached subscribers in this harness, so a block + * that did not re-read failed to listen rather than missed an event that never + * came. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup, screen, waitFor } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, notifyDataChanged, useDataInvalidation } from '@object-ui/react'; +// Registers `object-timeline` through this package's own entry. +import './index'; + +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(); +}); + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 150))); + +function makeDataSource() { + let title = 'Kickoff'; + return { + rename(next: string) { + title = next; + }, + find: vi.fn(async () => ({ data: [{ id: '1', name: title, due: '2026-01-05' }], total: 1 })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: 'task', fields: { name: { type: 'text' }, due: { type: 'date' } } })), + }; +} + +/** The positive control: a bare reader of the same object, beside the block. */ +function BusControl() { + const nonce = useDataInvalidation('task'); + return {nonce}; +} + +const renderBlock = (schema: Record, ds: ReturnType) => + render( + + + + , + ); + +const BLOCK = { type: 'object-timeline', objectName: 'task', timeline: { titleField: 'name', startDateField: 'due' } }; + +describe('object-timeline re-reads on the data-invalidation bus (objectui#10623)', () => { + it('an unscoped change (objectName "*") re-runs its query once, in place', async () => { + const ds = makeDataSource(); + renderBlock(BLOCK, ds); + await waitFor(() => expect(screen.getByText('Kickoff')).toBeTruthy()); + await settle(); + expect(ds.find).toHaveBeenCalledTimes(1); + const canvas = screen.getByTestId('timeline-canvas'); + + ds.rename('Kickoff (moved)'); + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + + expect(screen.getByTestId('bus-control').textContent, 'control: the event never reached a subscriber').toBe('1'); + expect(ds.find, 'the block never re-read after the bus reported a change').toHaveBeenCalledTimes(2); + expect(screen.getByText('Kickoff (moved)')).toBeTruthy(); + // The re-read is in place: the same canvas node, so its scroll survives. + expect(screen.getByTestId('timeline-canvas'), 'the canvas was remounted by the re-read').toBe(canvas); + }); + + it('a change to its own object re-runs its query once; another object does not', async () => { + const ds = makeDataSource(); + renderBlock(BLOCK, ds); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(1)); + await settle(); + + await act(async () => { + notifyDataChanged({ objectName: 'some_other_object' }); + }); + await settle(); + expect(ds.find, 'a change to another object re-read this block').toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: 'task', recordId: '1' }); + }); + await settle(); + expect(screen.getByTestId('bus-control').textContent).toBe('1'); + expect(ds.find).toHaveBeenCalledTimes(2); + }); + + it('a block drawing authored items queries nothing on an invalidation', async () => { + const ds = makeDataSource(); + renderBlock( + { ...BLOCK, items: [{ title: 'Authored', time: '2026-01-05' }] }, + ds, + ); + await settle(); + + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + + expect(screen.getByTestId('bus-control').textContent).toBe('1'); + expect(ds.find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx index 936c83c343..9309e2e844 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.tsx @@ -8,7 +8,7 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import type { DataSource, TimelineSchema, ListViewTimelineConfig } from '@object-ui/types'; -import { useDataScope, useNavigationOverlay, useSafeFieldLabel, useSettledSchema } from '@object-ui/react'; +import { useDataScope, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation } from '@object-ui/react'; import { NavigationOverlay } from '@object-ui/components'; import { extractRecords, buildExpandFields, convertSortToQueryParams, createFieldColorResolver, recordDisplayValueAt } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; @@ -341,6 +341,17 @@ export const ObjectTimeline: React.FC = ({ const filterKey = JSON.stringify(schema.filter ?? null); const sortKey = JSON.stringify(schema.sort ?? null); + // objectui#10623 — the data-invalidation bus (`notifyDataChanged` from + // `@object-ui/react`), read the objectui#10494 way: the nonce moves when a + // write to the object this timeline QUERIES is declared, and the fetch + // effect below names it, so the rows are re-read in place. The canvas stays + // mounted through the re-read (the skeleton below is drawn only while there + // are no items yet), so its scroll position survives. Subscribed only when + // the timeline fetches for itself: authored `items`, host `data` and bound + // rows are not this effect's query. + const fetchesForItself = !!schema.objectName && !boundData && !schema.items && !(props as any).data; + const invalidationNonce = useDataInvalidation(fetchesForItself ? schema.objectName : undefined); + useEffect(() => { const fetchData = async () => { if (!dataSource || typeof dataSource.find !== 'function' || !schema.objectName) { @@ -417,7 +428,7 @@ export const ObjectTimeline: React.FC = ({ } }; - if (schema.objectName && !boundData && !schema.items && !(props as any).data) { + if (fetchesForItself) { // ⭐ objectui#7895 — the object definition GATES this query; it does not // refine it afterwards. `objectDef` stays in the dependency list below // and the two are ONE mechanism, not two: the dependency is what makes @@ -439,7 +450,7 @@ export const ObjectTimeline: React.FC = ({ setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps -- `schema.filter`/`schema.sort` are tracked by CONTENT (filterKey/sortKey) on purpose; see above - }, [schema.objectName, dataSource, boundData, schema.items, (props as any).data, refreshKey, objectDefReady, objectDef, filterKey, sortKey, schema.limit, perms]); + }, [schema.objectName, dataSource, boundData, schema.items, (props as any).data, refreshKey, objectDefReady, objectDef, filterKey, sortKey, schema.limit, perms, invalidationNonce]); const rawData = (props as any).data || boundData || fetchedData; const { t } = useTimelineTranslation(); From 458fd00323f79a1bc5bd06bd6b68c8491b92055d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:35:33 +0000 Subject: [PATCH 2/8] fix(plugin-list): object-gallery re-reads on the data-invalidation bus (objectui#10623) The fetch effect now names the useDataInvalidation nonce for the object it queries, so a write declared with notifyDataChanged re-reads the cards in place; collapsed groups survive. Host data, authored data and bound rows are not subscribed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- packages/plugin-list/src/ObjectGallery.tsx | 17 ++- ...Gallery.invalidationRefetch-10623.test.tsx | 137 ++++++++++++++++++ 2 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 packages/plugin-list/src/__tests__/ObjectGallery.invalidationRefetch-10623.test.tsx diff --git a/packages/plugin-list/src/ObjectGallery.tsx b/packages/plugin-list/src/ObjectGallery.tsx index c8bf639286..a3dcfe369c 100644 --- a/packages/plugin-list/src/ObjectGallery.tsx +++ b/packages/plugin-list/src/ObjectGallery.tsx @@ -7,7 +7,7 @@ */ import React, { useState, useEffect, useCallback, useMemo, useContext } from 'react'; -import { useDataScope, SchemaRendererContext, useNavigationOverlay, useSafeFieldLabel, useSettledSchema } from '@object-ui/react'; +import { useDataScope, SchemaRendererContext, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation } from '@object-ui/react'; import { ComponentRegistry, buildExpandFields, getRecordDisplayName, isEmptyValue } from '@object-ui/core'; import { cn, Card, CardContent, NavigationOverlay } from '@object-ui/components'; import { usePermissions } from '@object-ui/permissions'; @@ -401,6 +401,17 @@ export const ObjectGallery: React.FC = (props) => { return enriched; }, [objectDef, schema.objectName, fieldLabel, fieldOptionLabel]); + // objectui#10623 — the data-invalidation bus (`notifyDataChanged` from + // `@object-ui/react`), read the objectui#10494 way: the nonce moves when a + // write to the object this gallery QUERIES is declared, and the fetch + // effect below names it, so the cards are re-read in place. The grid stays + // mounted through the re-read (the placeholder below is drawn only while + // there are no items yet), so collapsed groups and scroll survive. + // Subscribed only when the gallery fetches for itself: host `data`, + // authored `schema.data` and bound rows are not this effect's query. + const fetchesForItself = !!schema.objectName && !boundData && !schema.data && !props.data; + const invalidationNonce = useDataInvalidation(fetchesForItself ? schema.objectName : undefined); + useEffect(() => { let isMounted = true; @@ -507,7 +518,7 @@ export const ObjectGallery: React.FC = (props) => { } }; - if (schema.objectName && !boundData && !schema.data && !props.data) { + if (fetchesForItself) { // ⭐ objectui#7903 — the object definition GATES this query; it does // not refine it afterwards. `objectDef` stays in the dependency list // below and the two are ONE mechanism, not two: the dependency is @@ -538,7 +549,7 @@ export const ObjectGallery: React.FC = (props) => { fetchData(); } return () => { isMounted = false; }; - }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, props.data, objectDefReady, objectDef, perms]); + }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, props.data, objectDefReady, objectDef, perms, fetchesForItself, invalidationNonce]); const items: Record[] = props.data || boundData || schema.data || fetchedData || []; diff --git a/packages/plugin-list/src/__tests__/ObjectGallery.invalidationRefetch-10623.test.tsx b/packages/plugin-list/src/__tests__/ObjectGallery.invalidationRefetch-10623.test.tsx new file mode 100644 index 0000000000..e518243010 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ObjectGallery.invalidationRefetch-10623.test.tsx @@ -0,0 +1,137 @@ +/** + * 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#10623 — an `object-gallery` block re-reads its cards when the + * data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a + * write to the object it queries, and it does so in place. + * + * Before this card the fetch effect named no nonce, so a write declared on the + * bus (a page action over raw HTTP, a flow, a server action) left the cards + * stale until something remounted the gallery. The effect now names the + * `useDataInvalidation` nonce for the object it queries (the objectui#10494 + * shape). + * + * Rendered through the real `SchemaRenderer` and this package's own + * registration, over a fake data source that counts reads. The bare + * `useDataInvalidation` reader mounted beside the block is the positive + * control: it proves the event reached subscribers in this harness. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup, screen, waitFor, fireEvent } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, notifyDataChanged, useDataInvalidation } from '@object-ui/react'; +// Registers `object-gallery` (the registration sits in the component module). +import '../ObjectGallery'; + +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(); +}); + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 150))); + +function makeDataSource() { + let wonName = 'Globex'; + return { + rename(next: string) { + wonName = next; + }, + find: vi.fn(async () => ({ + data: [ + { id: '1', name: 'Acme', status: 'open' }, + { id: '2', name: wonName, status: 'won' }, + ], + total: 2, + })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: 'deal', fields: { name: { type: 'text' }, status: { type: 'text' } } })), + }; +} + +/** The positive control: a bare reader of the same object, beside the block. */ +function BusControl() { + const nonce = useDataInvalidation('deal'); + return {nonce}; +} + +const renderBlock = (schema: Record, ds: ReturnType) => + render( + + + + , + ); + +const BLOCK = { type: 'object-gallery', objectName: 'deal', gallery: { titleField: 'name' } }; + +describe('object-gallery re-reads on the data-invalidation bus (objectui#10623)', () => { + it('an unscoped change (objectName "*") re-runs its query once, and a collapsed group stays collapsed', async () => { + const ds = makeDataSource(); + renderBlock({ ...BLOCK, grouping: { fields: [{ field: 'status' }] } }, ds); + await waitFor(() => expect(screen.getByText('Acme')).toBeTruthy()); + await settle(); + expect(ds.find).toHaveBeenCalledTimes(1); + + // Local UI state the re-read must not reset: collapse the `open` group. + fireEvent.click(screen.getByRole('button', { name: /open/ })); + expect(screen.queryByText('Acme')).toBeNull(); + + ds.rename('Globex (renamed)'); + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + + expect(screen.getByTestId('bus-control').textContent, 'control: the event never reached a subscriber').toBe('1'); + expect(ds.find, 'the block never re-read after the bus reported a change').toHaveBeenCalledTimes(2); + expect(screen.getByText('Globex (renamed)')).toBeTruthy(); + expect(screen.queryByText('Acme'), 'the re-read reset the collapsed group').toBeNull(); + }); + + it('a change to its own object re-runs its query once; another object does not', async () => { + const ds = makeDataSource(); + renderBlock(BLOCK, ds); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(1)); + await settle(); + + await act(async () => { + notifyDataChanged({ objectName: 'some_other_object' }); + }); + await settle(); + expect(ds.find, 'a change to another object re-read this block').toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: 'deal', recordId: '2' }); + }); + await settle(); + expect(screen.getByTestId('bus-control').textContent).toBe('1'); + expect(ds.find).toHaveBeenCalledTimes(2); + }); + + it('a block drawing inline rows queries nothing on an invalidation', async () => { + const ds = makeDataSource(); + renderBlock({ ...BLOCK, data: [{ id: '9', name: 'Inline', status: 'open' }] }, ds); + await settle(); + + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + + expect(screen.getByTestId('bus-control').textContent).toBe('1'); + expect(ds.find).not.toHaveBeenCalled(); + }); +}); From b50c1ec9c8a75ac30a0dd2b2cf30856c2e185538 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:35:33 +0000 Subject: [PATCH 3/8] fix(plugin-map): object-map re-reads on the data-invalidation bus, silently (objectui#10623) The fetch effect now names the useDataInvalidation nonce for the object it queries. A run the bus caused does not flip loading, so MapGL stays mounted and the user's camera survives the re-read. Only the object provider without host rows is subscribed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...jectMap.invalidationRefetch-10623.test.tsx | 157 ++++++++++++++++++ packages/plugin-map/src/ObjectMap.tsx | 39 ++++- 2 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 packages/plugin-map/src/ObjectMap.invalidationRefetch-10623.test.tsx diff --git a/packages/plugin-map/src/ObjectMap.invalidationRefetch-10623.test.tsx b/packages/plugin-map/src/ObjectMap.invalidationRefetch-10623.test.tsx new file mode 100644 index 0000000000..00fc7347a3 --- /dev/null +++ b/packages/plugin-map/src/ObjectMap.invalidationRefetch-10623.test.tsx @@ -0,0 +1,157 @@ +/** + * 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#10623 — an `object-map` block re-reads its markers when the + * data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a + * write to the object it queries, and it does so without unmounting the map. + * + * Before this card the fetch effect named no nonce, so a write declared on the + * bus (a page action over raw HTTP, a flow, a server action) left the markers + * stale until something remounted the map. The effect now names the + * `useDataInvalidation` nonce for the object it queries (the objectui#10494 + * shape), and the re-read it causes is SILENT: the `loading` gate unmounts + * `MapGL` for a fetch, and a bus re-read that went through it would reset the + * camera the user panned — see the comment on the nonce in `ObjectMap.tsx`. + * + * ⚠️ Counted as a DELTA, not an absolute. This map issues two `find` calls on + * mount (the fetch effect keys on the object definition, which lands after the + * first query); that is recorded on objectui#10623 as a separate finding and is + * not this file's subject. What is pinned here is that one bus event adds + * exactly one query. + * + * Rendered through the real `SchemaRenderer` and this package's own + * registration, over a fake data source that counts reads. The bare + * `useDataInvalidation` reader mounted beside the block is the positive + * control: it proves the event reached subscribers in this harness. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup, screen, waitFor } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, notifyDataChanged, useDataInvalidation } from '@object-ui/react'; + +// No WebGL in the test env — same stub the sibling ObjectMap tests use. The +// stub's node stands in for the map canvas: if the re-read unmounted `MapGL`, +// the node after it is a different one. +vi.mock('react-map-gl/maplibre', () => ({ + default: ({ children }: any) =>
{children}
, + Map: ({ children }: any) =>
{children}
, + NavigationControl: () =>
, + Marker: ({ children }: any) =>
{children}
, + Popup: ({ children }: any) =>
{children}
, +})); + +// Registers `object-map` through this package's own entry. +import './index'; + +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(); +}); + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 150))); + +function makeDataSource() { + let rows = [{ id: '1', name: 'Depot', lat: 10, lng: 20 }]; + return { + addRow(row: { id: string; name: string; lat: number; lng: number }) { + rows = [...rows, row]; + }, + find: vi.fn(async () => ({ data: rows, total: rows.length })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ + name: 'store', + fields: { name: { type: 'text' }, lat: { type: 'number' }, lng: { type: 'number' } }, + })), + }; +} + +/** The positive control: a bare reader of the same object, beside the block. */ +function BusControl() { + const nonce = useDataInvalidation('store'); + return {nonce}; +} + +const renderBlock = (schema: Record, ds: ReturnType) => + render( + + + + , + ); + +const MAP = { latitudeField: 'lat', longitudeField: 'lng', titleField: 'name' }; +const BLOCK = { type: 'object-map', objectName: 'store', map: MAP }; + +describe('object-map re-reads on the data-invalidation bus (objectui#10623)', () => { + it('an unscoped change (objectName "*") adds one query, and the map stays mounted', async () => { + const ds = makeDataSource(); + renderBlock(BLOCK, ds); + await waitFor(() => expect(screen.getAllByTestId('map-marker')).toHaveLength(1)); + await settle(); + const onMount = ds.find.mock.calls.length; + const canvas = screen.getByLabelText('Map'); + + ds.addRow({ id: '2', name: 'Harbour', lat: 11, lng: 21 }); + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + + expect(screen.getByTestId('bus-control').textContent, 'control: the event never reached a subscriber').toBe('1'); + expect(ds.find.mock.calls.length - onMount, 'the block did not re-read exactly once after the bus reported a change').toBe(1); + expect(screen.getAllByTestId('map-marker')).toHaveLength(2); + // Silent: the map node is the same one, so the user's camera survives. + expect(screen.getByLabelText('Map'), 'the re-read unmounted the map').toBe(canvas); + }); + + it('a change to its own object adds one query; another object adds none', async () => { + const ds = makeDataSource(); + renderBlock(BLOCK, ds); + await waitFor(() => expect(screen.getAllByTestId('map-marker')).toHaveLength(1)); + await settle(); + const onMount = ds.find.mock.calls.length; + + await act(async () => { + notifyDataChanged({ objectName: 'some_other_object' }); + }); + await settle(); + expect(ds.find.mock.calls.length - onMount, 'a change to another object re-read this block').toBe(0); + + await act(async () => { + notifyDataChanged({ objectName: 'store', recordId: '1' }); + }); + await settle(); + expect(screen.getByTestId('bus-control').textContent).toBe('1'); + expect(ds.find.mock.calls.length - onMount).toBe(1); + }); + + it('a block drawing an inline value set queries nothing on an invalidation', async () => { + const ds = makeDataSource(); + renderBlock( + { type: 'object-map', map: MAP, data: { provider: 'value', items: [{ id: '9', name: 'Inline', lat: 1, lng: 2 }] } }, + ds, + ); + await waitFor(() => expect(screen.getAllByTestId('map-marker')).toHaveLength(1)); + + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + + expect(screen.getByTestId('bus-control').textContent).toBe('1'); + expect(ds.find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-map/src/ObjectMap.tsx b/packages/plugin-map/src/ObjectMap.tsx index f0a7492503..0d45ab22aa 100644 --- a/packages/plugin-map/src/ObjectMap.tsx +++ b/packages/plugin-map/src/ObjectMap.tsx @@ -26,6 +26,7 @@ import { ObjectMapConfigSchema } from '@object-ui/types/zod'; import { useNavigationOverlay, NonGridRowCeilingNote, + useDataInvalidation, } from '@object-ui/react'; import { NavigationOverlay, cn, useIsMobile } from '@object-ui/components'; import { usePermissions } from '@object-ui/permissions'; @@ -775,11 +776,35 @@ export const ObjectMap: React.FC = ({ // PR #7428 recorded for `ListView`'s memo and `ObjectCalendar`'s effect). const perms = usePermissions(); + /** + * objectui#10623 — the data-invalidation bus (`notifyDataChanged` from + * `@object-ui/react`), read the objectui#10494 way: the nonce moves when a + * write to the object this map QUERIES is declared, and the fetch effect + * below names it, so the markers are re-read. Subscribed only on the + * `object` provider without host rows: a host `data` array is the host's to + * refresh, and an inline `value` set queries its own array, not an object. + * + * ⭐ The re-read the bus causes is SILENT: it does not flip `loading`. The + * `loading` gate further down unmounts `MapGL` for the duration of a fetch, + * which is the right answer when the QUERY changed (the one-shot camera then + * re-fits the new record set) and the wrong one for a write to the same + * query: it would throw away the camera the user has panned and zoomed, the + * loss a remount causes, one level down (AGENTS.md #8's corollary: refresh + * data, don't rebuild UI). `ObjectGantt` answers the bus with a silent + * reload for the same reason. A run is the bus's when the nonce differs + * from the last one the effect answered. + */ + const fetchesForItself = !Array.isArray(dataProp) && dataProvider === 'object'; + const invalidationNonce = useDataInvalidation(fetchesForItself ? dataObjectName : undefined); + const answeredInvalidationRef = useRef(invalidationNonce); + // Fetch data based on provider useEffect(() => { + const silent = invalidationNonce !== answeredInvalidationRef.current; + answeredInvalidationRef.current = invalidationNonce; const fetchData = async () => { try { - setLoading(true); + if (!silent) setLoading(true); // Prioritize data passed via props (from ListView). `dataProp` is a // declared prop (not the `rest` spread), so it can sit in this @@ -939,7 +964,7 @@ export const ObjectMap: React.FC = ({ }; fetchData(); - }, [dataProp, dataProvider, dataObjectName, dataItems, dataSource, hasInlineData, schema.filter, schema.sort, objectSchema, perms]); + }, [dataProp, dataProvider, dataObjectName, dataItems, dataSource, hasInlineData, schema.filter, schema.sort, objectSchema, perms, invalidationNonce]); // Fetch object schema for field metadata useEffect(() => { @@ -1125,10 +1150,12 @@ export const ObjectMap: React.FC = ({ /** * Initial camera. Read once, when `MapGL` mounts — which is also every time - * the record set changes, because the `loading` gate below unmounts the map - * for the duration of each fetch. So the one-shot camera always reflects the - * records currently in hand, and nothing here ever yanks a camera the user - * has since panned. + * the query changes, because the `loading` gate below unmounts the map for + * the duration of each fetch the query's inputs cause. So the one-shot + * camera always reflects the records that query returned, and nothing here + * ever yanks a camera the user has since panned. A re-read the + * data-invalidation bus causes (objectui#10623) is silent and keeps the map + * mounted: the markers move and the camera stays where the user left it. */ const initialViewState = useMemo(() => { // Records, no declared camera: hand MapLibre the box and let it fit at the From 4d43ca97b4ffcf11a1a904fad38245e897e23264 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:35:33 +0000 Subject: [PATCH 4/8] fix(components): element:number and element:repeater re-read on the data-invalidation bus (objectui#10623) Each effect now names the useDataInvalidation nonce for the object in its properties.object, so a write declared with notifyDataChanged re-reads the value or the rows. A block with no object or no adapter is not subscribed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...Readers.invalidationRefetch-10623.test.tsx | 144 ++++++++++++++++++ .../src/renderers/basic/data-list.tsx | 13 +- .../src/renderers/basic/elements.tsx | 10 +- 3 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 packages/components/src/renderers/basic/__tests__/elementReaders.invalidationRefetch-10623.test.tsx diff --git a/packages/components/src/renderers/basic/__tests__/elementReaders.invalidationRefetch-10623.test.tsx b/packages/components/src/renderers/basic/__tests__/elementReaders.invalidationRefetch-10623.test.tsx new file mode 100644 index 0000000000..92d85eb375 --- /dev/null +++ b/packages/components/src/renderers/basic/__tests__/elementReaders.invalidationRefetch-10623.test.tsx @@ -0,0 +1,144 @@ +/** + * 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#10623 — `element:number` and `element:repeater` re-read when the + * data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a + * write to the object they read. + * + * Before this card neither effect named a nonce, so a write declared on the bus + * (a page action over raw HTTP, a flow, a server action) left the number and + * the list stale until something remounted the page. Each effect now names the + * `useDataInvalidation` nonce for the object in its `properties.object` (the + * objectui#10494 shape). + * + * Rendered through the real `SchemaRenderer` and this package's own + * registrations, under the `AdapterCtx` provider these two read their adapter + * from (without it they issue no read at all). The bare `useDataInvalidation` + * reader mounted beside each block is the positive control: it proves the + * event reached subscribers in this harness. + */ +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, act, cleanup, screen, waitFor } from '@testing-library/react'; +import { AdapterCtx, SchemaRenderer, notifyDataChanged, useDataInvalidation } from '@object-ui/react'; +// Registers every `element:*` renderer at module scope, not in a hook +// (object-ui/no-dynamic-import-in-test-hook, objectui#3010). +import '../../../renderers'; + +afterEach(cleanup); + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 100))); + +function makeAdapter() { + let rows = [{ id: 'r1', name: 'Ada' }]; + return { + addRow(row: { id: string; name: string }) { + rows = [...rows, row]; + }, + find: vi.fn(async () => ({ data: rows, total: rows.length })), + aggregate: vi.fn(async () => [{ count: rows.length }]), + }; +} + +/** The positive control: a bare reader of the same object, beside the block. */ +function BusControl() { + const nonce = useDataInvalidation('contact'); + return {nonce}; +} + +function mount(schema: Record, adapter: ReturnType) { + render( + + + + , + ); +} + +const NUMBER = { type: 'element:number', id: 'n', properties: { object: 'contact', aggregate: 'count' } }; +const REPEATER = { type: 'element:repeater', id: 'rep', properties: { object: 'contact', fields: ['name'] } }; + +const cases = [ + { block: 'element:number', schema: NUMBER, read: (a: ReturnType) => a.aggregate }, + { block: 'element:repeater', schema: REPEATER, read: (a: ReturnType) => a.find }, +] as const; + +describe.each(cases)('$block re-reads on the data-invalidation bus (objectui#10623)', ({ schema, read }) => { + it('an unscoped change (objectName "*") re-runs its read once', async () => { + const adapter = makeAdapter(); + mount(schema, adapter); + await waitFor(() => expect(read(adapter)).toHaveBeenCalledTimes(1)); + await settle(); + + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + + expect(screen.getByTestId('bus-control').textContent, 'control: the event never reached a subscriber').toBe('1'); + expect(read(adapter), 'the block never re-read after the bus reported a change').toHaveBeenCalledTimes(2); + }); + + it('a change to its own object re-runs its read once; another object does not', async () => { + const adapter = makeAdapter(); + mount(schema, adapter); + await waitFor(() => expect(read(adapter)).toHaveBeenCalledTimes(1)); + await settle(); + + await act(async () => { + notifyDataChanged({ objectName: 'some_other_object' }); + }); + await settle(); + expect(read(adapter), 'a change to another object re-read this block').toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: 'contact', recordId: 'r1' }); + }); + await settle(); + expect(screen.getByTestId('bus-control').textContent).toBe('1'); + expect(read(adapter)).toHaveBeenCalledTimes(2); + }); + + it('a block with no object binding reads nothing on an invalidation', async () => { + const adapter = makeAdapter(); + const { object: _object, ...unbound } = schema.properties; + mount({ ...schema, properties: unbound }, adapter); + await settle(); + + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + + expect(screen.getByTestId('bus-control').textContent).toBe('1'); + expect(adapter.find).not.toHaveBeenCalled(); + expect(adapter.aggregate).not.toHaveBeenCalled(); + }); +}); + +describe('the re-read shows the new data (objectui#10623)', () => { + it('element:number shows the new count and element:repeater the new row', async () => { + const adapter = makeAdapter(); + render( + + + + , + ); + await waitFor(() => expect(screen.getByText('Ada')).toBeTruthy()); + + adapter.addRow({ id: 'r2', name: 'Grace' }); + await act(async () => { + notifyDataChanged({ objectName: 'contact' }); + }); + + await waitFor(() => expect(screen.getByText('Grace')).toBeTruthy()); + expect(screen.getByText('2')).toBeTruthy(); + }); +}); diff --git a/packages/components/src/renderers/basic/data-list.tsx b/packages/components/src/renderers/basic/data-list.tsx index ccb6cd2cd6..e37dc5e792 100644 --- a/packages/components/src/renderers/basic/data-list.tsx +++ b/packages/components/src/renderers/basic/data-list.tsx @@ -19,7 +19,7 @@ import * as React from 'react'; import { ComponentRegistry } from '@object-ui/core'; -import { useAdapter } from '@object-ui/react'; +import { useAdapter, useDataInvalidation } from '@object-ui/react'; import { cn } from '../../lib/utils'; import { readProps } from './readProps'; @@ -117,6 +117,15 @@ function RepeaterRenderer({ schema }: { schema: any }) { [props.fields], ); + // objectui#10623 — the data-invalidation bus (`notifyDataChanged` from + // `@object-ui/react`), read the objectui#10494 way: the nonce moves when a + // write to the object this list REPEATS over is declared, and the effect + // below names it, so the rows are re-read. Subscribed only when the effect + // can query: without an adapter `find` there is no read to repeat. + const invalidationNonce = useDataInvalidation( + adapter && typeof adapter.find === 'function' ? props.object : undefined, + ); + React.useEffect(() => { let cancelled = false; if (!adapter || !props.object || typeof adapter.find !== 'function') { @@ -152,7 +161,7 @@ function RepeaterRenderer({ schema }: { schema: any }) { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [adapter, props.object, filterKey, props.limit]); + }, [adapter, props.object, filterKey, props.limit, invalidationNonce]); if (loading) return

Loading…

; if (error) return

{error}

; diff --git a/packages/components/src/renderers/basic/elements.tsx b/packages/components/src/renderers/basic/elements.tsx index 0a4cfa7bec..a0611f7c51 100644 --- a/packages/components/src/renderers/basic/elements.tsx +++ b/packages/components/src/renderers/basic/elements.tsx @@ -27,7 +27,7 @@ import * as React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import type { ActionDef } from '@object-ui/core'; -import { useAdapter, useAction } from '@object-ui/react'; +import { useAdapter, useAction, useDataInvalidation } from '@object-ui/react'; import { useObjectTranslation, pickLocalized, @@ -390,6 +390,12 @@ function ElementNumberRenderer({ schema }: { schema: any }) { const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const filterKey = React.useMemo(() => (props.filter ? JSON.stringify(props.filter) : ''), [props.filter]); + // objectui#10623 — the data-invalidation bus (`notifyDataChanged` from + // `@object-ui/react`), read the objectui#10494 way: the nonce moves when a + // write to the object this number AGGREGATES is declared, and the effect + // below names it, so the value is re-read. Subscribed only when the effect + // can query: no adapter or no aggregate means no read to repeat. + const invalidationNonce = useDataInvalidation(adapter && props.aggregate ? props.object : undefined); React.useEffect(() => { let cancelled = false; @@ -452,7 +458,7 @@ function ElementNumberRenderer({ schema }: { schema: any }) { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [adapter, props.object, props.field, props.aggregate, filterKey]); + }, [adapter, props.object, props.field, props.aggregate, filterKey, invalidationNonce]); return (
From 008a5df9d3d66ab7275b4b4f68f53762fc752bce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:45:50 +0000 Subject: [PATCH 5/8] chore(changeset): patch notes for the five readers on the invalidation bus (objectui#10623) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../10623-readers-follow-invalidation-bus.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/10623-readers-follow-invalidation-bus.md diff --git a/.changeset/10623-readers-follow-invalidation-bus.md b/.changeset/10623-readers-follow-invalidation-bus.md new file mode 100644 index 0000000000..f2321e874c --- /dev/null +++ b/.changeset/10623-readers-follow-invalidation-bus.md @@ -0,0 +1,15 @@ +--- +'@object-ui/plugin-timeline': patch +'@object-ui/plugin-list': patch +'@object-ui/plugin-map': patch +'@object-ui/components': patch +--- + +Five more page blocks re-read when the data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a write to the object they read (objectui#10623). A write that bypasses the data source — a page action over raw HTTP, a flow, a server action — fires no `onMutation`, so these blocks used to show it only when something remounted them. Each fetch effect now names the `useDataInvalidation` nonce for its object; an unscoped change (`'*'`) matches too, and a change to another object does not. + +- `@object-ui/plugin-timeline`: `object-timeline` re-reads its rows; a canvas that already shows rows stays mounted. +- `@object-ui/plugin-list`: `object-gallery` re-reads its cards; collapsed groups stay collapsed. +- `@object-ui/plugin-map`: `object-map` re-reads its markers without showing its loading placeholder, so the map stays mounted and the camera stays where the user left it. +- `@object-ui/components`: `element:number` re-reads its aggregate and `element:repeater` its rows. + +Rows a host hands these blocks (`data`, `bind`, authored `items`, an inline `value` set) are still the host's to refresh; only the blocks' own queries follow the bus. From 5a5f4f1bdf81f9865e6703f51cb02f243febcaa6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:54:20 +0000 Subject: [PATCH 6/8] fix(plugin-map): a bus re-read is silent only for the query on screen; newest run commits; errors clear on success (objectui#10623) Mirrors ObjectGantt: a run is silent only when the invalidation nonce moved and its query inputs equal the ones whose rows were last committed, so a query change arriving with a bus event still goes through the loading gate and re-fits. A sequence ref lets only the newest run commit rows, an error or the loading flag, and the effect cleanup retires a run on re-run or unmount. A failed silent run keeps the last good rows and the mounted map and logs, as ObjectGantt's silent reload does. Committed rows clear an earlier error (objectui#10578's rule). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- packages/plugin-map/src/ObjectMap.tsx | 98 ++++++++++++++++++++------- 1 file changed, 72 insertions(+), 26 deletions(-) diff --git a/packages/plugin-map/src/ObjectMap.tsx b/packages/plugin-map/src/ObjectMap.tsx index 0d45ab22aa..9f71dd7149 100644 --- a/packages/plugin-map/src/ObjectMap.tsx +++ b/packages/plugin-map/src/ObjectMap.tsx @@ -784,24 +784,61 @@ export const ObjectMap: React.FC = ({ * `object` provider without host rows: a host `data` array is the host's to * refresh, and an inline `value` set queries its own array, not an object. * - * ⭐ The re-read the bus causes is SILENT: it does not flip `loading`. The - * `loading` gate further down unmounts `MapGL` for the duration of a fetch, - * which is the right answer when the QUERY changed (the one-shot camera then - * re-fits the new record set) and the wrong one for a write to the same - * query: it would throw away the camera the user has panned and zoomed, the - * loss a remount causes, one level down (AGENTS.md #8's corollary: refresh - * data, don't rebuild UI). `ObjectGantt` answers the bus with a silent - * reload for the same reason. A run is the bus's when the nonce differs - * from the last one the effect answered. + * ⭐ A re-read of the SAME query is SILENT, in `ObjectGantt`'s sense + * (objectui#7237 / objectui#10035): it does not flip `loading`, and a failure + * keeps the last good rows instead of reporting. The `loading` gate further + * down unmounts `MapGL` for the duration of a fetch. That is the right answer + * when the QUERY changed (the one-shot camera then re-fits the new record + * set) and the wrong one for a write to the same query, because it would throw + * away the camera the user has panned and zoomed: the loss a remount causes, + * one level down (AGENTS.md #8's corollary: refresh data, don't rebuild UI). + * The same goes for a failure: the error screen is also an early return + * above `MapGL`. + * + * A run is silent only when BOTH hold: + * - the nonce moved since the effect's last run (the bus asked), and + * - its query inputs are, value for value, the ones whose rows were last + * COMMITTED, so the rows on screen already answer this query. + * A query change that lands in the same commit as a bus event therefore + * still goes through the gate and re-fits. So does a bus event that lands + * before the first rows, or after a changed query failed: nothing on screen + * answers that query yet, so its failure must be reported. */ const fetchesForItself = !Array.isArray(dataProp) && dataProvider === 'object'; const invalidationNonce = useDataInvalidation(fetchesForItself ? dataObjectName : undefined); const answeredInvalidationRef = useRef(invalidationNonce); + /** The query inputs of the run whose rows were last committed. */ + const committedQueryRef = useRef(null); + /** + * Only the NEWEST run may commit rows, an error or the loading flag + * (`ObjectGantt`'s `reloadSeqRef`). The cleanup below advances it too, so a + * run superseded by a re-run, or outliving an unmount, commits nothing. + */ + const fetchSeqRef = useRef(0); // Fetch data based on provider useEffect(() => { - const silent = invalidationNonce !== answeredInvalidationRef.current; + // Every input of the query below: this effect's dependency list, less the + // nonce. Compared value for value (`Object.is`), the way React compares + // the list itself. + const query = [dataProp, dataProvider, dataObjectName, dataItems, dataSource, hasInlineData, schema.filter, schema.sort, objectSchema, perms] as const; + const committed = committedQueryRef.current; + const answersShownQuery = committed !== null && committed.length === query.length && query.every((v, i) => Object.is(v, committed[i])); + const nonceMoved = invalidationNonce !== answeredInvalidationRef.current; answeredInvalidationRef.current = invalidationNonce; + const silent = nonceMoved && answersShownQuery; + const seq = ++fetchSeqRef.current; + const isCurrent = () => fetchSeqRef.current === seq; + // The one place a run commits rows. Committed rows answer this run's query, + // so no earlier failure describes the screen any more: the error is cleared + // HERE (objectui#10578's rule on `ObjectGantt`), never when a run starts. + const commit = (rows: unknown[], ceiling: NonGridCeilingResult | null) => { + if (!isCurrent()) return; + setData(rows); + setRowCeiling(ceiling); + setError(null); + committedQueryRef.current = query; + }; const fetchData = async () => { try { if (!silent) setLoading(true); @@ -811,9 +848,7 @@ export const ObjectMap: React.FC = ({ // effect's dependency array below without turning into a // refetch-every-render trap (objectui#5003). if (Array.isArray(dataProp)) { - setData(dataProp); - setRowCeiling(null); - setLoading(false); + commit(dataProp, null); return; } @@ -871,9 +906,7 @@ export const ObjectMap: React.FC = ({ // below the ceiling therefore plots every matching row and stays // quiet. const capped = applyNonGridRowCeiling(result); - setData(capped.rows); - setRowCeiling(capped); - setLoading(false); + commit(capped.rows, capped); return; } @@ -949,21 +982,33 @@ export const ObjectMap: React.FC = ({ }); const capped = applyNonGridRowCeiling(result); - setData(capped.rows); - setRowCeiling(capped); + commit(capped.rows, capped); } else if (dataProvider === 'api') { console.warn('API provider not yet implemented for ObjectMap'); - setData([]); + commit([], null); } - - setLoading(false); } catch (err) { - setError(err as Error); - setLoading(false); + if (silent) { + // A failed re-read of the query on screen keeps the last good rows + // and the mounted map, exactly as `ObjectGantt`'s silent reload does: + // it logs, it does not report. Those rows still answer the query. + console.error('[ObjectMap] Failed to refresh data:', err); + } else if (isCurrent()) { + // A failed CHANGED query is reported: the rows in state answer the + // previous query, so drawing them would misstate the screen. + setError(err as Error); + } + } finally { + // Only the newest run owns the flag. It clears it whatever its own + // mode, because a silent run can overtake a loud one that set it. + if (isCurrent()) setLoading(false); } }; fetchData(); + return () => { + fetchSeqRef.current += 1; + }; }, [dataProp, dataProvider, dataObjectName, dataItems, dataSource, hasInlineData, schema.filter, schema.sort, objectSchema, perms, invalidationNonce]); // Fetch object schema for field metadata @@ -1153,9 +1198,10 @@ export const ObjectMap: React.FC = ({ * the query changes, because the `loading` gate below unmounts the map for * the duration of each fetch the query's inputs cause. So the one-shot * camera always reflects the records that query returned, and nothing here - * ever yanks a camera the user has since panned. A re-read the - * data-invalidation bus causes (objectui#10623) is silent and keeps the map - * mounted: the markers move and the camera stays where the user left it. + * ever yanks a camera the user has since panned. A re-read of the query + * already on screen, caused by the data-invalidation bus (objectui#10623), is + * silent and keeps the map mounted, whether it succeeds or fails: the markers + * move and the camera stays where the user left it. */ const initialViewState = useMemo(() => { // Records, no declared camera: hand MapLibre the box and let it fit at the From 3b808bebd3b38fe3e8a7f7ee6cf03963868159a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:54:20 +0000 Subject: [PATCH 7/8] test(plugin-map): pin the silent re-read, the stale guard, the silent failure and the error clear; changeset wording (objectui#10623) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../10623-readers-follow-invalidation-bus.md | 6 +- .../src/ObjectMap.busReread-10623.test.tsx | 284 ++++++++++++++++++ 2 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 packages/plugin-map/src/ObjectMap.busReread-10623.test.tsx diff --git a/.changeset/10623-readers-follow-invalidation-bus.md b/.changeset/10623-readers-follow-invalidation-bus.md index f2321e874c..a7f1df497a 100644 --- a/.changeset/10623-readers-follow-invalidation-bus.md +++ b/.changeset/10623-readers-follow-invalidation-bus.md @@ -5,11 +5,11 @@ '@object-ui/components': patch --- -Five more page blocks re-read when the data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a write to the object they read (objectui#10623). A write that bypasses the data source — a page action over raw HTTP, a flow, a server action — fires no `onMutation`, so these blocks used to show it only when something remounted them. Each fetch effect now names the `useDataInvalidation` nonce for its object; an unscoped change (`'*'`) matches too, and a change to another object does not. +Five more page blocks re-read when the data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a write to the object they read (objectui#10623). These blocks read neither `onMutation` nor the bus, so any write — through the data source or past it (a page action over raw HTTP, a flow, a server action) — showed only when something remounted them. Each fetch effect now names the `useDataInvalidation` nonce for its object; an unscoped change (`'*'`) matches too, and a change to another object does not. -- `@object-ui/plugin-timeline`: `object-timeline` re-reads its rows; a canvas that already shows rows stays mounted. +- `@object-ui/plugin-timeline`: `object-timeline` re-reads its rows; a canvas that already shows rows stays mounted while the re-read runs. - `@object-ui/plugin-list`: `object-gallery` re-reads its cards; collapsed groups stay collapsed. -- `@object-ui/plugin-map`: `object-map` re-reads its markers without showing its loading placeholder, so the map stays mounted and the camera stays where the user left it. +- `@object-ui/plugin-map`: `object-map` re-reads the markers it is showing without its loading placeholder, so the map stays mounted and the camera stays where the user left it. If that re-read fails, the last markers stay on the map and the failure is logged to the console, as `ObjectGantt`'s background refresh does. A changed query still shows the placeholder and re-fits the camera, even when a bus event arrives with it. Two older faults in the same fetch are fixed as well: a slow earlier answer can no longer overwrite a newer one, and a load that succeeds now takes the map off an earlier error screen. - `@object-ui/components`: `element:number` re-reads its aggregate and `element:repeater` its rows. Rows a host hands these blocks (`data`, `bind`, authored `items`, an inline `value` set) are still the host's to refresh; only the blocks' own queries follow the bus. diff --git a/packages/plugin-map/src/ObjectMap.busReread-10623.test.tsx b/packages/plugin-map/src/ObjectMap.busReread-10623.test.tsx new file mode 100644 index 0000000000..e05bb3bbc9 --- /dev/null +++ b/packages/plugin-map/src/ObjectMap.busReread-10623.test.tsx @@ -0,0 +1,284 @@ +/** + * 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#10623, round 2 — how `ObjectMap`'s fetch effect answers a + * data-invalidation re-read, measured against the `ObjectGantt` precedent + * (`reloadSeqRef` / `isCurrent()`, the objectui#7237 silent mode, the + * objectui#10578 error clear). + * + * A. A run is silent only when the bus moved AND the query is the one whose + * rows are on screen. A query change that lands in the same commit as a + * bus event still goes through the loading gate, so the camera re-fits. + * B. Only the newest run may commit rows, an error or the loading flag. A + * slow older answer can't overwrite a newer one. + * C. A silent run that fails keeps the last good rows and the mounted map, + * and logs the failure — what `ObjectGantt`'s silent reload does. + * D. A run that commits rows clears an earlier error. + * + * Plus the control: a plain query change, with no bus event, re-fits. + * + * "Re-fits" is read the only way this component can do it. `initialViewState` + * is read by `MapGL` once, at mount, and the loading gate unmounts `MapGL`, so a + * re-fit is a NEW map node mounted with the new records' bounds. The stub below + * records the camera each map mounts with. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup, screen, waitFor } from '@testing-library/react'; +import { notifyDataChanged } from '@object-ui/react'; +import { ObjectMap } from './ObjectMap'; + +/** The camera each `MapGL` mount was handed, in mount order. */ +let mountedCameras: any[] = []; + +// No WebGL in the test env. The stub records `initialViewState` once per MOUNT +// (a ref, so a re-render of the same map adds nothing). +vi.mock('react-map-gl/maplibre', () => ({ + default: (props: any) => { + const seen = React.useRef(false); + if (!seen.current) { + seen.current = true; + mountedCameras.push(props.initialViewState); + } + return
{props.children}
; + }, + Map: ({ children }: any) =>
{children}
, + NavigationControl: () =>
, + Marker: ({ children, longitude, latitude }: any) => ( +
+ {children} +
+ ), + Popup: ({ children }: any) =>
{children}
, +})); + +type Row = { id: string; name: string; lat: number; lng: number }; + +const NORTH = [['zone', '=', 'north']]; +const SOUTH = [['zone', '=', 'south']]; +const EAST = [['zone', '=', 'east']]; + +const ROWS: Record = { + north: [{ id: 'n1', name: 'Oslo', lat: 59.9, lng: 10.7 }], + south: [{ id: 's1', name: 'Cape Town', lat: -33.9, lng: 18.4 }], + east: [{ id: 'e1', name: 'Tokyo', lat: 35.7, lng: 139.7 }], +}; + +const zoneOf = (filter: unknown) => (JSON.stringify(filter).match(/north|south|east/) ?? ['north'])[0]; + +/** + * A data source whose answer per zone can be HELD. `hold(zone)` makes the next + * `find` for that zone wait until `release(zone)` or `fail(zone)`. + */ +function makeDataSource() { + type Gate = { promise: Promise; resolve: () => void; reject: (e: Error) => void }; + /** Gates the next `find` per zone will wait on (consumed by that call). */ + const holds = new Map(); + /** Every gate ever set, so a test can release one after `find` consumed it. */ + const controls = new Map(); + let failNextFor: string | null = null; + return { + hold(zone: string) { + let resolve!: () => void; + let reject!: (e: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + holds.set(zone, { promise, resolve, reject }); + controls.set(zone, { promise, resolve, reject }); + }, + release(zone: string) { controls.get(zone)!.resolve(); }, + fail(zone: string) { controls.get(zone)!.reject(new Error(`the ${zone} query failed`)); }, + failNext(zone: string) { failNextFor = zone; }, + find: vi.fn(async (_object: string, q: any) => { + const zone = zoneOf(q?.$filter); + const held = holds.get(zone); + if (held) { + holds.delete(zone); + await held.promise; + } + if (failNextFor === zone) { + failNextFor = null; + throw new Error(`the ${zone} query failed`); + } + return { data: ROWS[zone], total: ROWS[zone].length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ + name: 'store', + fields: { name: { type: 'text' }, lat: { type: 'number' }, lng: { type: 'number' } }, + })), + }; +} + +const MAP = { latitudeField: 'lat', longitudeField: 'lng', titleField: 'name' }; + +let setFilter: (f: unknown) => void = () => {}; + +function Host({ ds }: { ds: ReturnType }) { + const [filter, set] = React.useState(NORTH); + setFilter = set; + const schema = React.useMemo(() => ({ type: 'object-map', objectName: 'store', map: MAP, filter }), [filter]); + return ; +} + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 100))); + +const markerLats = () => screen.queryAllByTestId('map-marker').map((m) => Number(m.getAttribute('data-lat'))); + +/** The latitude the camera of the NEWEST map mount was fitted to (single-record bounds). */ +const lastFittedLat = () => mountedCameras[mountedCameras.length - 1]?.bounds?.[0]?.[1]; + +async function mountSettled(ds: ReturnType) { + render(); + await waitFor(() => expect(markerLats()).toEqual([59.9])); + await settle(); + return screen.getByLabelText('Map'); +} + +beforeEach(() => { + mountedCameras = []; + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + cleanup(); +}); + +describe('object-map: a query change re-fits the camera (control, objectui#10623)', () => { + it('a plain query change goes through the loading gate and mounts a map fitted to the new rows', async () => { + const ds = makeDataSource(); + const before = await mountSettled(ds); + + await act(async () => { setFilter(SOUTH); }); + await waitFor(() => expect(markerLats()).toEqual([-33.9])); + + expect(screen.getByLabelText('Map'), 'a changed query kept the old map, so it did not re-fit').not.toBe(before); + expect(lastFittedLat()).toBe(-33.9); + }); +}); + +describe('object-map: A, a run is silent only for a bus-only re-read (objectui#10623)', () => { + it('a query change landing in the same commit as a bus event still re-fits', async () => { + const ds = makeDataSource(); + const before = await mountSettled(ds); + + await act(async () => { + setFilter(SOUTH); + notifyDataChanged({ objectName: 'store' }); + }); + await waitFor(() => expect(markerLats()).toEqual([-33.9])); + + expect(screen.getByLabelText('Map'), 'the bus event made a changed query silent, so the map kept the old camera').not.toBe(before); + expect(lastFittedLat()).toBe(-33.9); + }); +}); + +describe('object-map: B, only the newest run commits (objectui#10623)', () => { + it('a slow older answer does not overwrite a newer one', async () => { + const ds = makeDataSource(); + await mountSettled(ds); + + ds.hold('south'); + await act(async () => { setFilter(SOUTH); }); + await act(async () => { setFilter(EAST); }); + await waitFor(() => expect(markerLats()).toEqual([35.7])); + + await act(async () => { ds.release('south'); }); + await settle(); + + expect(markerLats(), 'the superseded south answer overwrote the east rows').toEqual([35.7]); + }); + + it('a slow older failure does not put the map on its error screen', async () => { + const ds = makeDataSource(); + await mountSettled(ds); + + ds.hold('south'); + await act(async () => { setFilter(SOUTH); }); + await act(async () => { setFilter(EAST); }); + await waitFor(() => expect(markerLats()).toEqual([35.7])); + + await act(async () => { ds.fail('south'); }); + await settle(); + + expect(screen.queryByText(/Error:/), 'a superseded failure replaced the current rows').toBeNull(); + expect(markerLats()).toEqual([35.7]); + }); + + it('a superseded answer does not release the loading gate while the newest query is in flight', async () => { + const ds = makeDataSource(); + await mountSettled(ds); + + ds.hold('south'); + ds.hold('east'); + await act(async () => { setFilter(SOUTH); }); + await act(async () => { setFilter(EAST); }); + + await act(async () => { ds.release('south'); }); + await settle(); + expect(screen.queryByText('Loading map...'), 'the superseded run cleared the loading gate').not.toBeNull(); + expect(markerLats()).toEqual([]); + + await act(async () => { ds.release('east'); }); + await waitFor(() => expect(markerLats()).toEqual([35.7])); + }); +}); + +describe('object-map: C, a failed silent re-read keeps the map (objectui#10623)', () => { + it('keeps the last good rows and the same map node, and logs the failure', async () => { + const ds = makeDataSource(); + const before = await mountSettled(ds); + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + + ds.failNext('north'); + await act(async () => { notifyDataChanged({ objectName: 'store' }); }); + await settle(); + + expect(screen.queryByText(/Error:/), 'a failed bus re-read put the map on its error screen').toBeNull(); + expect(screen.getByLabelText('Map'), 'a failed bus re-read unmounted the map').toBe(before); + expect(markerLats()).toEqual([59.9]); + expect(logged.mock.calls.some((c) => String(c[0]).includes('[ObjectMap] Failed to refresh data'))).toBe(true); + }); +}); + +describe('object-map: D, committed rows clear an earlier error (objectui#10623)', () => { + it('a later query that succeeds takes the map off its error screen', async () => { + const ds = makeDataSource(); + await mountSettled(ds); + + ds.failNext('south'); + await act(async () => { setFilter(SOUTH); }); + await waitFor(() => expect(screen.queryByText(/Error:/)).not.toBeNull()); + + await act(async () => { setFilter(EAST); }); + await settle(); + + expect(screen.queryByText(/Error:/), 'the map stayed on the error screen after a query succeeded').toBeNull(); + expect(markerLats()).toEqual([35.7]); + }); + + it('a bus re-read after a reported failure retries through the gate and clears the error', async () => { + const ds = makeDataSource(); + await mountSettled(ds); + + ds.failNext('south'); + await act(async () => { setFilter(SOUTH); }); + await waitFor(() => expect(screen.queryByText(/Error:/)).not.toBeNull()); + + await act(async () => { notifyDataChanged({ objectName: 'store' }); }); + await settle(); + + expect(screen.queryByText(/Error:/), 'the map stayed on the error screen after the re-read succeeded').toBeNull(); + expect(markerLats()).toEqual([-33.9]); + expect(lastFittedLat()).toBe(-33.9); + }); +}); From cef514df8840b98e1195bd932a850915013d3f74 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:07:07 +0000 Subject: [PATCH 8/8] test(plugin-map): lint-clean pin harness (named map stub, forwardRef host) (objectui#10623) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../src/ObjectMap.busReread-10623.test.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/plugin-map/src/ObjectMap.busReread-10623.test.tsx b/packages/plugin-map/src/ObjectMap.busReread-10623.test.tsx index e05bb3bbc9..a2f4fa6afa 100644 --- a/packages/plugin-map/src/ObjectMap.busReread-10623.test.tsx +++ b/packages/plugin-map/src/ObjectMap.busReread-10623.test.tsx @@ -40,7 +40,7 @@ let mountedCameras: any[] = []; // No WebGL in the test env. The stub records `initialViewState` once per MOUNT // (a ref, so a re-render of the same map adds nothing). vi.mock('react-map-gl/maplibre', () => ({ - default: (props: any) => { + default: function MapStub(props: any) { const seen = React.useRef(false); if (!seen.current) { seen.current = true; @@ -120,14 +120,18 @@ function makeDataSource() { const MAP = { latitudeField: 'lat', longitudeField: 'lng', titleField: 'name' }; -let setFilter: (f: unknown) => void = () => {}; +type HostHandle = { setFilter: (f: unknown) => void }; -function Host({ ds }: { ds: ReturnType }) { +/** Holds the map's `filter` as state, so a test can change the query in one commit. */ +const Host = React.forwardRef }>(function Host({ ds }, ref) { const [filter, set] = React.useState(NORTH); - setFilter = set; + React.useImperativeHandle(ref, () => ({ setFilter: set }), []); const schema = React.useMemo(() => ({ type: 'object-map', objectName: 'store', map: MAP, filter }), [filter]); return ; -} +}); + +const host = React.createRef(); +const setFilter = (f: unknown) => host.current!.setFilter(f); const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 100))); @@ -137,7 +141,7 @@ const markerLats = () => screen.queryAllByTestId('map-marker').map((m) => Number const lastFittedLat = () => mountedCameras[mountedCameras.length - 1]?.bounds?.[0]?.[1]; async function mountSettled(ds: ReturnType) { - render(); + render(); await waitFor(() => expect(markerLats()).toEqual([59.9])); await settle(); return screen.getByLabelText('Map');