diff --git a/.changeset/client-react-bulk-data-hooks.md b/.changeset/client-react-bulk-data-hooks.md new file mode 100644 index 0000000000..f5c73af2f9 --- /dev/null +++ b/.changeset/client-react-bulk-data-hooks.md @@ -0,0 +1,32 @@ +--- +"@objectstack/client-react": minor +--- + +feat(client-react): bulk-write hooks, and `useAutoRefresh` now refreshes on predicate writes (#4678) + +#4639 gave predicate writes (`multi: true` update/delete) their own event +contract — `data.records.updated` / `data.records.deleted`, carrying a +`matched` count and no record — and `@objectstack/client` exposes them via +`subscribeBulkData`. The React hooks never caught up: all three realtime data +hooks 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 the case that 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. + +- **New `useBulkDataSubscription(object)`** returning the latest + `BulkDataEvent`, and **`useBulkDataSubscriptionCallback(object, cb)`** for + the refetch/side-effect case. +- **`useAutoRefresh` now watches both streams.** Safe here in a way it is not + for `useDataSubscription`: this hook's output is a refetch signal, not an + event body, so the shape difference that keeps the two contracts apart never + reaches the caller. When `options.recordId` narrows it to one record 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` are unchanged** and + still per-record only. Their callbacks are typed `(event: DataEvent) => void`; + letting a `BulkDataEvent` through would hand them an object whose `recordId` + and record body are `undefined` — the defect #4626 removed. diff --git a/packages/client-react/src/index.tsx b/packages/client-react/src/index.tsx index 5e46a95938..b5ff1f85c4 100644 --- a/packages/client-react/src/index.tsx +++ b/packages/client-react/src/index.tsx @@ -53,6 +53,8 @@ export { useDataSubscription, useMetadataSubscriptionCallback, useDataSubscriptionCallback, + useBulkDataSubscription, + useBulkDataSubscriptionCallback, useRealtimeConnection, useAutoRefresh } from './realtime-hooks'; diff --git a/packages/client-react/src/realtime-hooks.tsx b/packages/client-react/src/realtime-hooks.tsx index 470cb74775..e57ec36251 100644 --- a/packages/client-react/src/realtime-hooks.tsx +++ b/packages/client-react/src/realtime-hooks.tsx @@ -8,7 +8,7 @@ */ import { useEffect, useState, useCallback } from 'react'; -import type { MetadataEvent, DataEvent } from '@objectstack/spec/api'; +import type { MetadataEvent, DataEvent, BulkDataEvent } from '@objectstack/spec/api'; import { useClient } from './context'; /** @@ -192,6 +192,96 @@ export function useDataSubscriptionCallback( }, [client, object, callback, options?.recordId]); } +/** + * Hook to subscribe to bulk (predicate-write) data events + * + * A `multi: true` update/delete reaches the driver's `updateMany`/`deleteMany`, + * which report an affected COUNT and name no rows — so it publishes + * `data.records.updated` / `data.records.deleted` rather than the per-record + * events {@link useDataSubscription} delivers (#4639). + * + * The event carries `object` and `matched` — there is no `recordId` and no + * record body, which is why this is a separate hook rather than more types + * flowing through `useDataSubscription`: a `DataEvent` callback receiving one + * of these would read `undefined` for every field it expects. + * + * Use it to invalidate a list, show "40 records changed", or trigger a + * refetch — not to patch a per-record cache, which a count cannot drive. + * + * @param object - Object name to subscribe to + * @returns Latest bulk data event or null + * + * @example + * ```tsx + * function TaskList() { + * const bulk = useBulkDataSubscription('project_task'); + * + * useEffect(() => { + * if (bulk) { + * console.log(`${bulk.matched} tasks changed in one write`); + * } + * }, [bulk]); + * + * return
...
; + * } + * ``` + */ +export function useBulkDataSubscription(object: string): BulkDataEvent | null { + const client = useClient(); + const [event, setEvent] = useState(null); + + useEffect(() => { + if (!client) return; + + const unsubscribe = client.events.subscribeBulkData(object, (e) => setEvent(e)); + + return () => { + unsubscribe(); + }; + }, [client, object]); + + return event; +} + +/** + * Hook to subscribe to bulk data events with a callback + * + * The callback variant of {@link useBulkDataSubscription} — no state, no + * re-render, for triggering refetches and side effects. + * + * @param object - Object name to subscribe to + * @param callback - Callback to invoke on events + * + * @example + * ```tsx + * function TaskList() { + * const { refetch } = useQuery(...); + * + * useBulkDataSubscriptionCallback('project_task', () => { + * refetch(); // a predicate write touched an unknown set of rows + * }); + * + * return
...
; + * } + * ``` + */ +export function useBulkDataSubscriptionCallback( + object: string, + callback: (event: BulkDataEvent) => void +): void { + const client = useClient(); + + useEffect(() => { + if (!client) return; + + const unsubscribe = client.events.subscribeBulkData(object, callback); + + return () => { + unsubscribe(); + }; + }, [client, object, callback]); +} + /** * Hook to get connection status of realtime events * @@ -233,6 +323,18 @@ export function useRealtimeConnection(): boolean { * * Combines data subscription with query refetch. * + * Watches BOTH event streams (#4678): per-record `data.record.*` writes and + * the aggregate `data.records.*` a predicate (`multi: true`) write publishes. + * A bulk write is the case that dirties a list hardest — one statement can + * change or delete every row on screen — so a refresh hook that ignored it + * would sit still exactly when it matters most, while still refreshing for a + * single-row edit. + * + * Mixing the two streams is safe here in a way it is not for + * {@link useDataSubscription}: this hook's output is a refetch signal, not an + * event body, so the shape difference that keeps the two contracts apart + * (no `recordId`, no record) never reaches the caller. + * * @param object - Object name to watch * @param refetch - Refetch function from useQuery * @param options - Optional filters @@ -258,5 +360,14 @@ export function useAutoRefresh( refetch(); }, [refetch]); + // A bulk event carries only a count, so when `options.recordId` narrows this + // hook to one record there is no way to tell whether that record was in the + // match set. Refetch anyway: a redundant query is cheap, and the alternative + // is showing a record that a predicate write already changed. + const handleBulkEvent = useCallback((_event: BulkDataEvent) => { + refetch(); + }, [refetch]); + useDataSubscriptionCallback(object, handleEvent, options); + useBulkDataSubscriptionCallback(object, handleBulkEvent); }