Skip to content
Merged
15 changes: 15 additions & 0 deletions .changeset/10623-readers-follow-invalidation-bus.md
Original file line number Diff line number Diff line change
@@ -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). 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 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 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.
Original file line number Diff line number Diff line change
@@ -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<void>((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 <span data-testid="bus-control">{nonce}</span>;
}

function mount(schema: Record<string, unknown>, adapter: ReturnType<typeof makeAdapter>) {
render(
<AdapterCtx.Provider value={adapter as never}>
<BusControl />
<SchemaRenderer schema={schema as never} />
</AdapterCtx.Provider>,
);
}

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<typeof makeAdapter>) => a.aggregate },
{ block: 'element:repeater', schema: REPEATER, read: (a: ReturnType<typeof makeAdapter>) => 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(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer schema={NUMBER as never} />
<SchemaRenderer schema={REPEATER as never} />
</AdapterCtx.Provider>,
);
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();
});
});
13 changes: 11 additions & 2 deletions packages/components/src/renderers/basic/data-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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 <p className="py-2 text-sm text-muted-foreground">Loading…</p>;
if (error) return <p className="py-2 text-sm text-destructive">{error}</p>;
Expand Down
10 changes: 8 additions & 2 deletions packages/components/src/renderers/basic/elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -390,6 +390,12 @@ function ElementNumberRenderer({ schema }: { schema: any }) {
const [loading, setLoading] = React.useState<boolean>(true);
const [error, setError] = React.useState<string | null>(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;
Expand Down Expand Up @@ -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 (
<div className={cn('flex flex-col gap-1', schema?.className)} {...ariaAttrs(props.aria)}>
Expand Down
17 changes: 14 additions & 3 deletions packages/plugin-list/src/ObjectGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -401,6 +401,17 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (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;

Expand Down Expand Up @@ -507,7 +518,7 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (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
Expand Down Expand Up @@ -538,7 +549,7 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (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<string, unknown>[] = props.data || boundData || schema.data || fetchedData || [];

Expand Down
Loading
Loading