@objectstack/client-react 17.0.0-rc.1 → 17.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,228 @@
1
1
  # @objectstack/client-react
2
2
 
3
+ ## 17.0.0-rc.2
4
+
5
+ ### Minor Changes
6
+
7
+ - 0884452: feat(client-react): bulk-write hooks, and `useAutoRefresh` now refreshes on predicate writes (#4678)
8
+
9
+ #4639 gave predicate writes (`multi: true` update/delete) their own event
10
+ contract — `data.records.updated` / `data.records.deleted`, carrying a
11
+ `matched` count and no record — and `@objectstack/client` exposes them via
12
+ `subscribeBulkData`. The React hooks never caught up: all three realtime data
13
+ hooks delegated to `subscribeData`, so React consumers could not see bulk
14
+ writes at all.
15
+
16
+ The sharpest edge was **`useAutoRefresh`**. Its whole job is "refetch when the
17
+ data changes", and a predicate write is the case that dirties a list hardest —
18
+ one statement can change or delete every row on screen. It sat still for those
19
+ while refetching dutifully for a single-row edit.
20
+
21
+ - **New `useBulkDataSubscription(object)`** returning the latest
22
+ `BulkDataEvent`, and **`useBulkDataSubscriptionCallback(object, cb)`** for
23
+ the refetch/side-effect case.
24
+ - **`useAutoRefresh` now watches both streams.** Safe here in a way it is not
25
+ for `useDataSubscription`: this hook's output is a refetch signal, not an
26
+ event body, so the shape difference that keeps the two contracts apart never
27
+ reaches the caller. When `options.recordId` narrows it to one record it still
28
+ refetches on a bulk event — a count cannot say whether that record was in the
29
+ match set, and a redundant query beats showing a row a predicate write
30
+ already changed.
31
+ - **`useDataSubscription` / `useDataSubscriptionCallback` are unchanged** and
32
+ still per-record only. Their callbacks are typed `(event: DataEvent) => void`;
33
+ letting a `BulkDataEvent` through would hand them an object whose `recordId`
34
+ and record body are `undefined` — the defect #4626 removed.
35
+
36
+ ### Patch Changes
37
+
38
+ - 21855f8: fix(client-react): stop five hooks from looping on dependency identity (#4693, #4694)
39
+
40
+ Five hooks keyed a `useCallback`/`useEffect` on values the caller supplies
41
+ inline — `where` / `fields` / `orderBy` objects, `onSuccess` / `onError`
42
+ handlers, and the `fetcher` `useMetadata` takes as a required positional
43
+ argument. Inline means a fresh identity on every render, so the effect re-ran on
44
+ every render; because the fetch hooks call `setState`, that render caused
45
+ another. The result was an unbounded request loop under the hooks' own
46
+ documented usage.
47
+
48
+ Requests issued in 250ms by a single mounted component, measured before and
49
+ after:
50
+
51
+ | hook | before | after |
52
+ | ----------------------------------- | -----: | ----: |
53
+ | `useQuery` (inline `where`) | 4691 | 1 |
54
+ | `useInfiniteQuery` (inline `where`) | 6611 | 1 |
55
+ | `useObject` (no options at all) | 4306 | 1 |
56
+ | `useView` (inline `onSuccess`) | 8197 | 1 |
57
+ | `useMetadata` (inline `fetcher`) | 7654 | 1 |
58
+
59
+ `useObject` and `useMetadata` needed no particular usage to loop: the former
60
+ depended on its own `data` and `etag` state while writing both, and the latter
61
+ takes its fetcher positionally, so there is no non-inline way to call it.
62
+ `useMutation` was never affected — no effect drives it.
63
+
64
+ The same root cause churned the realtime subscriptions (#4694):
65
+ `useAutoRefresh` with an unmemoized `refetch` — which is what `useQuery`
66
+ returned on every render — resubscribed on both streams every render, losing any
67
+ event delivered in the unsubscribe/resubscribe gap.
68
+
69
+ Two internal primitives fix both halves: `stableKey` derives a dependency from a
70
+ structural value (sorted keys, array order preserved) so a rebuilt-but-equal
71
+ object is a no-op, and `useEventCallback` gives a handler a fixed identity while
72
+ always invoking its latest version. Neither is exported.
73
+
74
+ A changed _value_ still refetches, and every stabilized handler is asserted to
75
+ run its newest version rather than the one captured when the effect first ran —
76
+ the ref indirection would otherwise trade a loop for a stale closure. 13 tests
77
+ cover this, each verified by reverting the fix it guards.
78
+
79
+ - bbb1192: test(client-react): give the package a test harness and pin the realtime hooks' behavior (#4682)
80
+
81
+ `packages/client-react` shipped 8 public hooks with `build` and `typecheck` as
82
+ its only scripts and not a single test file. `tsc --noEmit` cannot see any of
83
+ what actually breaks in a hook: a dependency array is a value, not a type, so a
84
+ missing entry, a missing cleanup, and a callback that never fires all typecheck
85
+ perfectly. #4678 was precisely that shape — `useAutoRefresh` ignored predicate
86
+ writes, the one case that dirties a list hardest, and no type noticed.
87
+
88
+ Adds the workspace's first DOM test environment (`jsdom` + `@testing-library/
89
+ react`, `environment: 'jsdom'` — every other package runs `node`) and 17 tests
90
+ over the realtime hooks, covering the three things the type checker is blind to:
91
+
92
+ - **Re-subscription on dependency change** — changing `object` opens a
93
+ subscription on the new name and releases the old one; a re-render that
94
+ changes nothing must not churn. Also pins that the hooks key on the primitive
95
+ `options?.recordId` / `options?.packageId` rather than on the options object's
96
+ identity, so an equal-but-new object stays a no-op.
97
+ - **Release on unmount** — every subscription hook unsubscribes, including
98
+ `useAutoRefresh`, which holds two.
99
+ - **Delivery** — events reach state and callbacks, and `useAutoRefresh`
100
+ refetches on the per-record _and_ the bulk stream (the #4678 regression pin).
101
+
102
+ Each assertion was verified by sabotage: dropping the `object` dep, deleting a
103
+ cleanup, and reverting `useAutoRefresh` to the single-stream version each turn
104
+ the suite red, and only reverting turns it green again.
105
+
106
+ The package is picked up by CI's `Test Core` shards automatically — they
107
+ partition by package off `turbo ls`, so a `test` script is all that was needed.
108
+
109
+ No runtime code changed.
110
+
111
+ - Updated dependencies [430dcc2]
112
+ - Updated dependencies [e6ac4bd]
113
+ - Updated dependencies [80334c7]
114
+ - Updated dependencies [ce5242c]
115
+ - Updated dependencies [a7163ea]
116
+ - Updated dependencies [e6e9379]
117
+ - Updated dependencies [98877c9]
118
+ - Updated dependencies [98877c9]
119
+ - Updated dependencies [e6b1b69]
120
+ - Updated dependencies [ad047d2]
121
+ - Updated dependencies [2826d1e]
122
+ - Updated dependencies [5a84d41]
123
+ - Updated dependencies [20b1a9e]
124
+ - Updated dependencies [203a449]
125
+ - Updated dependencies [ac37fc6]
126
+ - Updated dependencies [4820f55]
127
+ - Updated dependencies [462d9c4]
128
+ - Updated dependencies [7d21581]
129
+ - Updated dependencies [f2445c9]
130
+ - Updated dependencies [23338c3]
131
+ - Updated dependencies [84b4a3a]
132
+ - Updated dependencies [5b843fb]
133
+ - Updated dependencies [b4487aa]
134
+ - Updated dependencies [65ca83a]
135
+ - Updated dependencies [67bf2e2]
136
+ - Updated dependencies [c6d1cb4]
137
+ - Updated dependencies [462b713]
138
+ - Updated dependencies [36030ff]
139
+ - Updated dependencies [6117f7b]
140
+ - Updated dependencies [e533b0b]
141
+ - Updated dependencies [cdf4d9a]
142
+ - Updated dependencies [aee1806]
143
+ - Updated dependencies [c13350b]
144
+ - Updated dependencies [c13350b]
145
+ - Updated dependencies [9ca2d85]
146
+ - Updated dependencies [c13350b]
147
+ - Updated dependencies [891d345]
148
+ - Updated dependencies [a52e2ef]
149
+ - Updated dependencies [5293114]
150
+ - Updated dependencies [20bc357]
151
+ - Updated dependencies [5966c2a]
152
+ - Updated dependencies [2382580]
153
+ - Updated dependencies [d9fa683]
154
+ - Updated dependencies [3c7bcc0]
155
+ - Updated dependencies [4b6cac7]
156
+ - Updated dependencies [7631964]
157
+ - Updated dependencies [ac471a0]
158
+ - Updated dependencies [60ae58e]
159
+ - Updated dependencies [ce92674]
160
+ - Updated dependencies [9f601e8]
161
+ - Updated dependencies [51c5227]
162
+ - Updated dependencies [a4a85c8]
163
+ - Updated dependencies [07a4e26]
164
+ - Updated dependencies [ec975f1]
165
+ - Updated dependencies [eb4204b]
166
+ - Updated dependencies [4f13be2]
167
+ - Updated dependencies [61cc079]
168
+ - Updated dependencies [0e96e46]
169
+ - Updated dependencies [d52d4fe]
170
+ - Updated dependencies [742cebb]
171
+ - Updated dependencies [ce92674]
172
+ - Updated dependencies [cf2c9b7]
173
+ - Updated dependencies [833b512]
174
+ - Updated dependencies [0f9faa2]
175
+ - Updated dependencies [7cf42fe]
176
+ - Updated dependencies [5966c2a]
177
+ - Updated dependencies [8aacf94]
178
+ - Updated dependencies [f78dd83]
179
+ - Updated dependencies [a2cd18a]
180
+ - Updated dependencies [4638aaa]
181
+ - Updated dependencies [0222d3c]
182
+ - Updated dependencies [071d0dc]
183
+ - Updated dependencies [0a936ea]
184
+ - Updated dependencies [023c00b]
185
+ - Updated dependencies [155507e]
186
+ - Updated dependencies [7bba90b]
187
+ - Updated dependencies [7e05d8e]
188
+ - Updated dependencies [061406d]
189
+ - Updated dependencies [c1f344b]
190
+ - Updated dependencies [9c93465]
191
+ - Updated dependencies [ebb209c]
192
+ - Updated dependencies [63b33e6]
193
+ - Updated dependencies [2a44c1d]
194
+ - Updated dependencies [695cfbd]
195
+ - Updated dependencies [7445149]
196
+ - Updated dependencies [071d0dc]
197
+ - Updated dependencies [0848bea]
198
+ - Updated dependencies [d51bed2]
199
+ - Updated dependencies [b8b3c64]
200
+ - Updated dependencies [0c0fbd9]
201
+ - Updated dependencies [f3141d8]
202
+ - Updated dependencies [5a84d41]
203
+ - Updated dependencies [fd3013a]
204
+ - Updated dependencies [21676eb]
205
+ - Updated dependencies [e336549]
206
+ - Updated dependencies [d40f43a]
207
+ - Updated dependencies [e5e7ee0]
208
+ - Updated dependencies [a2ebea2]
209
+ - Updated dependencies [800bdb0]
210
+ - Updated dependencies [04f1182]
211
+ - Updated dependencies [5647006]
212
+ - Updated dependencies [38f7e4f]
213
+ - Updated dependencies [c57f3cf]
214
+ - Updated dependencies [97faca3]
215
+ - Updated dependencies [ad5fe25]
216
+ - Updated dependencies [ea90179]
217
+ - Updated dependencies [ce92674]
218
+ - Updated dependencies [5ef0b5b]
219
+ - Updated dependencies [48fbacb]
220
+ - Updated dependencies [355e951]
221
+ - Updated dependencies [dadb43f]
222
+ - @objectstack/spec@17.0.0-rc.2
223
+ - @objectstack/core@17.0.0-rc.2
224
+ - @objectstack/client@17.0.0-rc.2
225
+
3
226
  ## 17.0.0-rc.1
4
227
 
5
228
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -3,7 +3,7 @@ import { ReactNode } from 'react';
3
3
  import { ObjectStackClient, PaginatedResult } from '@objectstack/client';
4
4
  export { ClientConfig, ObjectStackClient } from '@objectstack/client';
5
5
  import { QueryAST, FilterCondition } from '@objectstack/spec/data';
6
- import { DataEvent, MetadataEvent } from '@objectstack/spec/api';
6
+ import { BulkDataEvent, DataEvent, MetadataEvent } from '@objectstack/spec/api';
7
7
 
8
8
  /**
9
9
  * ObjectStack React Context
@@ -527,6 +527,64 @@ declare function useMetadataSubscriptionCallback(type: string, callback: (event:
527
527
  declare function useDataSubscriptionCallback(object: string, callback: (event: DataEvent) => void, options?: {
528
528
  recordId?: string;
529
529
  }): void;
530
+ /**
531
+ * Hook to subscribe to bulk (predicate-write) data events
532
+ *
533
+ * A `multi: true` update/delete reaches the driver's `updateMany`/`deleteMany`,
534
+ * which report an affected COUNT and name no rows — so it publishes
535
+ * `data.records.updated` / `data.records.deleted` rather than the per-record
536
+ * events {@link useDataSubscription} delivers (#4639).
537
+ *
538
+ * The event carries `object` and `matched` — there is no `recordId` and no
539
+ * record body, which is why this is a separate hook rather than more types
540
+ * flowing through `useDataSubscription`: a `DataEvent` callback receiving one
541
+ * of these would read `undefined` for every field it expects.
542
+ *
543
+ * Use it to invalidate a list, show "40 records changed", or trigger a
544
+ * refetch — not to patch a per-record cache, which a count cannot drive.
545
+ *
546
+ * @param object - Object name to subscribe to
547
+ * @returns Latest bulk data event or null
548
+ *
549
+ * @example
550
+ * ```tsx
551
+ * function TaskList() {
552
+ * const bulk = useBulkDataSubscription('project_task');
553
+ *
554
+ * useEffect(() => {
555
+ * if (bulk) {
556
+ * console.log(`${bulk.matched} tasks changed in one write`);
557
+ * }
558
+ * }, [bulk]);
559
+ *
560
+ * return <div>...</div>;
561
+ * }
562
+ * ```
563
+ */
564
+ declare function useBulkDataSubscription(object: string): BulkDataEvent | null;
565
+ /**
566
+ * Hook to subscribe to bulk data events with a callback
567
+ *
568
+ * The callback variant of {@link useBulkDataSubscription} — no state, no
569
+ * re-render, for triggering refetches and side effects.
570
+ *
571
+ * @param object - Object name to subscribe to
572
+ * @param callback - Callback to invoke on events
573
+ *
574
+ * @example
575
+ * ```tsx
576
+ * function TaskList() {
577
+ * const { refetch } = useQuery(...);
578
+ *
579
+ * useBulkDataSubscriptionCallback('project_task', () => {
580
+ * refetch(); // a predicate write touched an unknown set of rows
581
+ * });
582
+ *
583
+ * return <div>...</div>;
584
+ * }
585
+ * ```
586
+ */
587
+ declare function useBulkDataSubscriptionCallback(object: string, callback: (event: BulkDataEvent) => void): void;
530
588
  /**
531
589
  * Hook to get connection status of realtime events
532
590
  *
@@ -551,6 +609,18 @@ declare function useRealtimeConnection(): boolean;
551
609
  *
552
610
  * Combines data subscription with query refetch.
553
611
  *
612
+ * Watches BOTH event streams (#4678): per-record `data.record.*` writes and
613
+ * the aggregate `data.records.*` a predicate (`multi: true`) write publishes.
614
+ * A bulk write is the case that dirties a list hardest — one statement can
615
+ * change or delete every row on screen — so a refresh hook that ignored it
616
+ * would sit still exactly when it matters most, while still refreshing for a
617
+ * single-row edit.
618
+ *
619
+ * Mixing the two streams is safe here in a way it is not for
620
+ * {@link useDataSubscription}: this hook's output is a refetch signal, not an
621
+ * event body, so the shape difference that keeps the two contracts apart
622
+ * (no `recordId`, no record) never reaches the caller.
623
+ *
554
624
  * @param object - Object name to watch
555
625
  * @param refetch - Refetch function from useQuery
556
626
  * @param options - Optional filters
@@ -570,4 +640,4 @@ declare function useAutoRefresh(object: string, refetch: () => void, options?: {
570
640
  recordId?: string;
571
641
  }): void;
572
642
 
573
- export { ObjectStackContext, ObjectStackLocaleContext, ObjectStackProvider, type ObjectStackProviderProps, type UseInfiniteQueryOptions, type UseInfiniteQueryResult, type UseMetadataOptions, type UseMetadataResult, type UseMutationOptions, type UseMutationResult, type UsePaginationOptions, type UsePaginationResult, type UseQueryOptions, type UseQueryResult, useAutoRefresh, useClient, useDataSubscription, useDataSubscriptionCallback, useFields, useInfiniteQuery, useMetadata, useMetadataSubscription, useMetadataSubscriptionCallback, useMutation, useObject, useObjectStackLocale, usePagination, useQuery, useRealtimeConnection, useView };
643
+ export { ObjectStackContext, ObjectStackLocaleContext, ObjectStackProvider, type ObjectStackProviderProps, type UseInfiniteQueryOptions, type UseInfiniteQueryResult, type UseMetadataOptions, type UseMetadataResult, type UseMutationOptions, type UseMutationResult, type UsePaginationOptions, type UsePaginationResult, type UseQueryOptions, type UseQueryResult, useAutoRefresh, useBulkDataSubscription, useBulkDataSubscriptionCallback, useClient, useDataSubscription, useDataSubscriptionCallback, useFields, useInfiniteQuery, useMetadata, useMetadataSubscription, useMetadataSubscriptionCallback, useMutation, useObject, useObjectStackLocale, usePagination, useQuery, useRealtimeConnection, useView };
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ import { ReactNode } from 'react';
3
3
  import { ObjectStackClient, PaginatedResult } from '@objectstack/client';
4
4
  export { ClientConfig, ObjectStackClient } from '@objectstack/client';
5
5
  import { QueryAST, FilterCondition } from '@objectstack/spec/data';
6
- import { DataEvent, MetadataEvent } from '@objectstack/spec/api';
6
+ import { BulkDataEvent, DataEvent, MetadataEvent } from '@objectstack/spec/api';
7
7
 
8
8
  /**
9
9
  * ObjectStack React Context
@@ -527,6 +527,64 @@ declare function useMetadataSubscriptionCallback(type: string, callback: (event:
527
527
  declare function useDataSubscriptionCallback(object: string, callback: (event: DataEvent) => void, options?: {
528
528
  recordId?: string;
529
529
  }): void;
530
+ /**
531
+ * Hook to subscribe to bulk (predicate-write) data events
532
+ *
533
+ * A `multi: true` update/delete reaches the driver's `updateMany`/`deleteMany`,
534
+ * which report an affected COUNT and name no rows — so it publishes
535
+ * `data.records.updated` / `data.records.deleted` rather than the per-record
536
+ * events {@link useDataSubscription} delivers (#4639).
537
+ *
538
+ * The event carries `object` and `matched` — there is no `recordId` and no
539
+ * record body, which is why this is a separate hook rather than more types
540
+ * flowing through `useDataSubscription`: a `DataEvent` callback receiving one
541
+ * of these would read `undefined` for every field it expects.
542
+ *
543
+ * Use it to invalidate a list, show "40 records changed", or trigger a
544
+ * refetch — not to patch a per-record cache, which a count cannot drive.
545
+ *
546
+ * @param object - Object name to subscribe to
547
+ * @returns Latest bulk data event or null
548
+ *
549
+ * @example
550
+ * ```tsx
551
+ * function TaskList() {
552
+ * const bulk = useBulkDataSubscription('project_task');
553
+ *
554
+ * useEffect(() => {
555
+ * if (bulk) {
556
+ * console.log(`${bulk.matched} tasks changed in one write`);
557
+ * }
558
+ * }, [bulk]);
559
+ *
560
+ * return <div>...</div>;
561
+ * }
562
+ * ```
563
+ */
564
+ declare function useBulkDataSubscription(object: string): BulkDataEvent | null;
565
+ /**
566
+ * Hook to subscribe to bulk data events with a callback
567
+ *
568
+ * The callback variant of {@link useBulkDataSubscription} — no state, no
569
+ * re-render, for triggering refetches and side effects.
570
+ *
571
+ * @param object - Object name to subscribe to
572
+ * @param callback - Callback to invoke on events
573
+ *
574
+ * @example
575
+ * ```tsx
576
+ * function TaskList() {
577
+ * const { refetch } = useQuery(...);
578
+ *
579
+ * useBulkDataSubscriptionCallback('project_task', () => {
580
+ * refetch(); // a predicate write touched an unknown set of rows
581
+ * });
582
+ *
583
+ * return <div>...</div>;
584
+ * }
585
+ * ```
586
+ */
587
+ declare function useBulkDataSubscriptionCallback(object: string, callback: (event: BulkDataEvent) => void): void;
530
588
  /**
531
589
  * Hook to get connection status of realtime events
532
590
  *
@@ -551,6 +609,18 @@ declare function useRealtimeConnection(): boolean;
551
609
  *
552
610
  * Combines data subscription with query refetch.
553
611
  *
612
+ * Watches BOTH event streams (#4678): per-record `data.record.*` writes and
613
+ * the aggregate `data.records.*` a predicate (`multi: true`) write publishes.
614
+ * A bulk write is the case that dirties a list hardest — one statement can
615
+ * change or delete every row on screen — so a refresh hook that ignored it
616
+ * would sit still exactly when it matters most, while still refreshing for a
617
+ * single-row edit.
618
+ *
619
+ * Mixing the two streams is safe here in a way it is not for
620
+ * {@link useDataSubscription}: this hook's output is a refetch signal, not an
621
+ * event body, so the shape difference that keeps the two contracts apart
622
+ * (no `recordId`, no record) never reaches the caller.
623
+ *
554
624
  * @param object - Object name to watch
555
625
  * @param refetch - Refetch function from useQuery
556
626
  * @param options - Optional filters
@@ -570,4 +640,4 @@ declare function useAutoRefresh(object: string, refetch: () => void, options?: {
570
640
  recordId?: string;
571
641
  }): void;
572
642
 
573
- export { ObjectStackContext, ObjectStackLocaleContext, ObjectStackProvider, type ObjectStackProviderProps, type UseInfiniteQueryOptions, type UseInfiniteQueryResult, type UseMetadataOptions, type UseMetadataResult, type UseMutationOptions, type UseMutationResult, type UsePaginationOptions, type UsePaginationResult, type UseQueryOptions, type UseQueryResult, useAutoRefresh, useClient, useDataSubscription, useDataSubscriptionCallback, useFields, useInfiniteQuery, useMetadata, useMetadataSubscription, useMetadataSubscriptionCallback, useMutation, useObject, useObjectStackLocale, usePagination, useQuery, useRealtimeConnection, useView };
643
+ export { ObjectStackContext, ObjectStackLocaleContext, ObjectStackProvider, type ObjectStackProviderProps, type UseInfiniteQueryOptions, type UseInfiniteQueryResult, type UseMetadataOptions, type UseMetadataResult, type UseMutationOptions, type UseMutationResult, type UsePaginationOptions, type UsePaginationResult, type UseQueryOptions, type UseQueryResult, useAutoRefresh, useBulkDataSubscription, useBulkDataSubscriptionCallback, useClient, useDataSubscription, useDataSubscriptionCallback, useFields, useInfiniteQuery, useMetadata, useMetadataSubscription, useMetadataSubscriptionCallback, useMutation, useObject, useObjectStackLocale, usePagination, useQuery, useRealtimeConnection, useView };