Skip to content

Commit ee2556e

Browse files
committed
feat(client-react): bulk-write hooks, and useAutoRefresh refreshes on predicate writes (#4678)
#4639 gave predicate writes (`multi: true`) their own event contract and `@objectstack/client` exposes it as `subscribeBulkData`, but all three React realtime data hooks still delegated to `subscribeData` — so React consumers could not see bulk writes at all. The sharpest edge was `useAutoRefresh`: its whole job is "refetch when the data changes", and a predicate write is what dirties a list hardest — one statement can change or delete every row on screen. It sat still for those while refetching dutifully for a single-row edit. - Adds `useBulkDataSubscription` and `useBulkDataSubscriptionCallback`. - `useAutoRefresh` watches both streams. Safe here in a way it is not for `useDataSubscription`, because this hook's output is a refetch signal rather than an event body, so the shape difference between the two contracts never reaches the caller. With `options.recordId` set it still refetches on a bulk event: a count cannot say whether that record was in the match set, and a redundant query beats showing a row a predicate write already changed. - `useDataSubscription` / `useDataSubscriptionCallback` stay per-record only — their callbacks are typed `(event: DataEvent) => void`. Stacked on the #4639 branch because it consumes `subscribeBulkData`, which ships there; merge that first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LYnZrTwXbrctB8E8HpJAPT
1 parent 3b6b5e7 commit ee2556e

3 files changed

Lines changed: 146 additions & 1 deletion

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/client-react": minor
3+
---
4+
5+
feat(client-react): bulk-write hooks, and `useAutoRefresh` now refreshes on predicate writes (#4678)
6+
7+
#4639 gave predicate writes (`multi: true` update/delete) their own event
8+
contract — `data.records.updated` / `data.records.deleted`, carrying a
9+
`matched` count and no record — and `@objectstack/client` exposes them via
10+
`subscribeBulkData`. The React hooks never caught up: all three realtime data
11+
hooks delegated to `subscribeData`, so React consumers could not see bulk
12+
writes at all.
13+
14+
The sharpest edge was **`useAutoRefresh`**. Its whole job is "refetch when the
15+
data changes", and a predicate write is the case that dirties a list hardest —
16+
one statement can change or delete every row on screen. It sat still for those
17+
while refetching dutifully for a single-row edit.
18+
19+
- **New `useBulkDataSubscription(object)`** returning the latest
20+
`BulkDataEvent`, and **`useBulkDataSubscriptionCallback(object, cb)`** for
21+
the refetch/side-effect case.
22+
- **`useAutoRefresh` now watches both streams.** Safe here in a way it is not
23+
for `useDataSubscription`: this hook's output is a refetch signal, not an
24+
event body, so the shape difference that keeps the two contracts apart never
25+
reaches the caller. When `options.recordId` narrows it to one record it still
26+
refetches on a bulk event — a count cannot say whether that record was in the
27+
match set, and a redundant query beats showing a row a predicate write
28+
already changed.
29+
- **`useDataSubscription` / `useDataSubscriptionCallback` are unchanged** and
30+
still per-record only. Their callbacks are typed `(event: DataEvent) => void`;
31+
letting a `BulkDataEvent` through would hand them an object whose `recordId`
32+
and record body are `undefined` — the defect #4626 removed.

packages/client-react/src/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export {
5353
useDataSubscription,
5454
useMetadataSubscriptionCallback,
5555
useDataSubscriptionCallback,
56+
useBulkDataSubscription,
57+
useBulkDataSubscriptionCallback,
5658
useRealtimeConnection,
5759
useAutoRefresh
5860
} from './realtime-hooks';

packages/client-react/src/realtime-hooks.tsx

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import { useEffect, useState, useCallback } from 'react';
11-
import type { MetadataEvent, DataEvent } from '@objectstack/spec/api';
11+
import type { MetadataEvent, DataEvent, BulkDataEvent } from '@objectstack/spec/api';
1212
import { useClient } from './context';
1313

1414
/**
@@ -192,6 +192,96 @@ export function useDataSubscriptionCallback(
192192
}, [client, object, callback, options?.recordId]);
193193
}
194194

195+
/**
196+
* Hook to subscribe to bulk (predicate-write) data events
197+
*
198+
* A `multi: true` update/delete reaches the driver's `updateMany`/`deleteMany`,
199+
* which report an affected COUNT and name no rows — so it publishes
200+
* `data.records.updated` / `data.records.deleted` rather than the per-record
201+
* events {@link useDataSubscription} delivers (#4639).
202+
*
203+
* The event carries `object` and `matched` — there is no `recordId` and no
204+
* record body, which is why this is a separate hook rather than more types
205+
* flowing through `useDataSubscription`: a `DataEvent` callback receiving one
206+
* of these would read `undefined` for every field it expects.
207+
*
208+
* Use it to invalidate a list, show "40 records changed", or trigger a
209+
* refetch — not to patch a per-record cache, which a count cannot drive.
210+
*
211+
* @param object - Object name to subscribe to
212+
* @returns Latest bulk data event or null
213+
*
214+
* @example
215+
* ```tsx
216+
* function TaskList() {
217+
* const bulk = useBulkDataSubscription('project_task');
218+
*
219+
* useEffect(() => {
220+
* if (bulk) {
221+
* console.log(`${bulk.matched} tasks changed in one write`);
222+
* }
223+
* }, [bulk]);
224+
*
225+
* return <div>...</div>;
226+
* }
227+
* ```
228+
*/
229+
export function useBulkDataSubscription(object: string): BulkDataEvent | null {
230+
const client = useClient();
231+
const [event, setEvent] = useState<BulkDataEvent | null>(null);
232+
233+
useEffect(() => {
234+
if (!client) return;
235+
236+
const unsubscribe = client.events.subscribeBulkData(object, (e) => setEvent(e));
237+
238+
return () => {
239+
unsubscribe();
240+
};
241+
}, [client, object]);
242+
243+
return event;
244+
}
245+
246+
/**
247+
* Hook to subscribe to bulk data events with a callback
248+
*
249+
* The callback variant of {@link useBulkDataSubscription} — no state, no
250+
* re-render, for triggering refetches and side effects.
251+
*
252+
* @param object - Object name to subscribe to
253+
* @param callback - Callback to invoke on events
254+
*
255+
* @example
256+
* ```tsx
257+
* function TaskList() {
258+
* const { refetch } = useQuery(...);
259+
*
260+
* useBulkDataSubscriptionCallback('project_task', () => {
261+
* refetch(); // a predicate write touched an unknown set of rows
262+
* });
263+
*
264+
* return <div>...</div>;
265+
* }
266+
* ```
267+
*/
268+
export function useBulkDataSubscriptionCallback(
269+
object: string,
270+
callback: (event: BulkDataEvent) => void
271+
): void {
272+
const client = useClient();
273+
274+
useEffect(() => {
275+
if (!client) return;
276+
277+
const unsubscribe = client.events.subscribeBulkData(object, callback);
278+
279+
return () => {
280+
unsubscribe();
281+
};
282+
}, [client, object, callback]);
283+
}
284+
195285
/**
196286
* Hook to get connection status of realtime events
197287
*
@@ -233,6 +323,18 @@ export function useRealtimeConnection(): boolean {
233323
*
234324
* Combines data subscription with query refetch.
235325
*
326+
* Watches BOTH event streams (#4678): per-record `data.record.*` writes and
327+
* the aggregate `data.records.*` a predicate (`multi: true`) write publishes.
328+
* A bulk write is the case that dirties a list hardest — one statement can
329+
* change or delete every row on screen — so a refresh hook that ignored it
330+
* would sit still exactly when it matters most, while still refreshing for a
331+
* single-row edit.
332+
*
333+
* Mixing the two streams is safe here in a way it is not for
334+
* {@link useDataSubscription}: this hook's output is a refetch signal, not an
335+
* event body, so the shape difference that keeps the two contracts apart
336+
* (no `recordId`, no record) never reaches the caller.
337+
*
236338
* @param object - Object name to watch
237339
* @param refetch - Refetch function from useQuery
238340
* @param options - Optional filters
@@ -258,5 +360,14 @@ export function useAutoRefresh(
258360
refetch();
259361
}, [refetch]);
260362

363+
// A bulk event carries only a count, so when `options.recordId` narrows this
364+
// hook to one record there is no way to tell whether that record was in the
365+
// match set. Refetch anyway: a redundant query is cheap, and the alternative
366+
// is showing a record that a predicate write already changed.
367+
const handleBulkEvent = useCallback((_event: BulkDataEvent) => {
368+
refetch();
369+
}, [refetch]);
370+
261371
useDataSubscriptionCallback(object, handleEvent, options);
372+
useBulkDataSubscriptionCallback(object, handleBulkEvent);
262373
}

0 commit comments

Comments
 (0)