@veams/status-quo-query 0.10.0 → 0.12.0

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.
Files changed (40) hide show
  1. package/README.md +212 -10
  2. package/dist/index.d.ts +0 -1
  3. package/dist/index.js +0 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/provider.d.ts +1 -0
  6. package/dist/provider.js +2 -0
  7. package/dist/provider.js.map +1 -1
  8. package/dist/query.d.ts +26 -15
  9. package/dist/query.js +40 -31
  10. package/dist/query.js.map +1 -1
  11. package/dist/react/hooks/index.d.ts +1 -0
  12. package/dist/react/hooks/index.js +2 -0
  13. package/dist/react/hooks/index.js.map +1 -0
  14. package/dist/react/hooks/use-query-handle.d.ts +2 -0
  15. package/dist/react/hooks/use-query-handle.js +71 -0
  16. package/dist/react/hooks/use-query-handle.js.map +1 -0
  17. package/dist/react/hooks/use-query-subscription.d.ts +1 -0
  18. package/dist/react/hooks/use-query-subscription.js +2 -0
  19. package/dist/react/hooks/use-query-subscription.js.map +1 -0
  20. package/dist/react/index.d.ts +1 -0
  21. package/dist/react/index.js +2 -0
  22. package/dist/react/index.js.map +1 -0
  23. package/eslint.config.mjs +13 -2
  24. package/jest.config.cjs +2 -2
  25. package/package.json +21 -10
  26. package/src/__tests__/provider.spec.ts +8 -0
  27. package/src/index.ts +0 -2
  28. package/src/provider.ts +6 -2
  29. package/src/query.ts +84 -68
  30. package/src/react/__tests__/use-query-handle.spec.tsx +104 -0
  31. package/src/react/hooks/index.ts +1 -0
  32. package/src/react/hooks/use-query-handle.ts +96 -0
  33. package/src/react/index.ts +1 -0
  34. package/tsconfig.eslint.json +2 -1
  35. package/tsconfig.json +5 -2
  36. package/dist/query-registry.d.ts +0 -9
  37. package/dist/query-registry.js +0 -28
  38. package/dist/query-registry.js.map +0 -1
  39. package/src/__tests__/query-registry.spec.ts +0 -101
  40. package/src/query-registry.ts +0 -52
package/src/query.ts CHANGED
@@ -36,9 +36,9 @@ export type QueryFetchStatus = FetchStatus;
36
36
  export type QueryStatus = TanstackQueryStatus;
37
37
 
38
38
  /**
39
- * Represents a stable snapshot of the query service's state.
39
+ * Represents a stable snapshot of one query handle's state.
40
40
  */
41
- export interface QueryServiceSnapshot<TData, TError> {
41
+ export interface QueryHandleSnapshot<TData, TError> {
42
42
  // The data retrieved from a successful query.
43
43
  data: TData | undefined;
44
44
  // The error object if the query failed.
@@ -57,6 +57,14 @@ export interface QueryServiceSnapshot<TData, TError> {
57
57
  isSuccess: boolean;
58
58
  }
59
59
 
60
+ /**
61
+ * Represents the lightweight data/error read model for one query handle.
62
+ */
63
+ export interface QueryHandleData<TData, TError> {
64
+ data: TData | undefined;
65
+ error: TError | null;
66
+ }
67
+
60
68
  /**
61
69
  * Defines a subset of query state containing only the status and fetch status.
62
70
  */
@@ -66,15 +74,15 @@ export interface QueryMetaState {
66
74
  }
67
75
 
68
76
  /**
69
- * Defines the public API for a query service.
77
+ * Defines the public API for a query handle.
70
78
  */
71
- export interface QueryService<TData, TError> {
79
+ export interface QueryHandle<TData, TError> {
72
80
  // Returns the current state snapshot of the query.
73
- getSnapshot: () => QueryServiceSnapshot<TData, TError>;
81
+ getSnapshot: () => QueryHandleSnapshot<TData, TError>;
74
82
  // Subscribes a listener to state changes; returns an unsubscribe function.
75
- subscribe: (listener: (snapshot: QueryServiceSnapshot<TData, TError>) => void) => () => void;
83
+ subscribe: (listener: (snapshot: QueryHandleSnapshot<TData, TError>) => void) => () => void;
76
84
  // Manually triggers a refetch of this query.
77
- refetch: (options?: RefetchOptions) => Promise<QueryServiceSnapshot<TData, TError>>;
85
+ refetch: (options?: RefetchOptions) => Promise<QueryHandleSnapshot<TData, TError>>;
78
86
  // Marks this specific query as invalid in the cache to trigger a refetch if active.
79
87
  invalidate: (options?: QueryInvalidateOptions) => Promise<void>;
80
88
  // Escape hatch: provides direct access to the underlying Tanstack Query observer result.
@@ -93,14 +101,14 @@ type QueryDependencyDerivedOptions<TQueryKey extends QueryKey = QueryKey> = {
93
101
  queryKey?: TQueryKey;
94
102
  };
95
103
 
96
- type QueryServiceRuntimeOptions<
104
+ type QueryHandleRuntimeOptions<
97
105
  TQueryFnData = unknown,
98
106
  TError = Error,
99
107
  TData = TQueryFnData,
100
108
  TQueryData = TQueryFnData,
101
109
  TQueryKey extends QueryKey = QueryKey,
102
110
  > = Omit<
103
- QueryServiceOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>,
111
+ QueryHandleOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>,
104
112
  'dependsOn'
105
113
  >;
106
114
 
@@ -108,9 +116,9 @@ export type QueryDependencyTuple<
108
116
  TSources extends readonly unknown[],
109
117
  TQueryKey extends QueryKey = QueryKey,
110
118
  > = readonly [
111
- sources: { readonly [K in keyof TSources]: QueryService<TSources[K], Error> },
119
+ sources: { readonly [K in keyof TSources]: QueryHandle<TSources[K], Error> },
112
120
  deriveOptions: (
113
- sourceSnapshots: { readonly [K in keyof TSources]: QueryServiceSnapshot<TSources[K], Error> }
121
+ sourceSnapshots: { readonly [K in keyof TSources]: QueryHandleSnapshot<TSources[K], Error> }
114
122
  ) => QueryDependencyDerivedOptions<TQueryKey>,
115
123
  ];
116
124
 
@@ -131,15 +139,15 @@ export interface CreateUntrackedQuery {
131
139
  // The asynchronous function that performs the data fetch.
132
140
  queryFn: QueryFunction<TQueryFnData, TQueryKey>,
133
141
  // Optional configuration for behavior like staleness, retry, and refetching.
134
- options?: QueryServiceOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey, TSources>
135
- ): QueryService<TData, TError>;
142
+ options?: QueryHandleOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey, TSources>
143
+ ): QueryHandle<TData, TError>;
136
144
  }
137
145
 
138
146
  /**
139
147
  * Function signature for the default query factory that derives dependencies from the final
140
148
  * query-key segment.
141
149
  *
142
- * The tracked query handle deliberately stays API-compatible with the normal query service.
150
+ * The tracked query handle deliberately stays API-compatible with the normal query handle.
143
151
  * The only extra behavior is invisible: dependency registration and on-demand re-registration.
144
152
  */
145
153
  export interface CreateQuery {
@@ -154,14 +162,14 @@ export interface CreateQuery {
154
162
  >(
155
163
  queryKey: TQueryKey,
156
164
  queryFn: QueryFunction<TQueryFnData, TQueryKey>,
157
- options?: QueryServiceOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey, TSources>
158
- ): QueryService<TData, TError>;
165
+ options?: QueryHandleOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey, TSources>
166
+ ): QueryHandle<TData, TError>;
159
167
  }
160
168
 
161
169
  /**
162
- * Configuration options for creating a query service, excluding function and key.
170
+ * Configuration options for creating a query handle, excluding function and key.
163
171
  */
164
- export type QueryServiceOptions<
172
+ export type QueryHandleOptions<
165
173
  TQueryFnData = unknown,
166
174
  TError = Error,
167
175
  TData = TQueryFnData,
@@ -179,7 +187,7 @@ export type QueryServiceOptions<
179
187
  * Extracts and maps status and fetchStatus to our QueryMetaState interface.
180
188
  */
181
189
  export function toQueryMetaState<TData, TError>(
182
- snapshot: Pick<QueryServiceSnapshot<TData, TError>, 'fetchStatus' | 'status'>
190
+ snapshot: Pick<QueryHandleSnapshot<TData, TError>, 'fetchStatus' | 'status'>
183
191
  ): QueryMetaState {
184
192
  // Return a simplified state object for UI or other services.
185
193
  return {
@@ -188,6 +196,18 @@ export function toQueryMetaState<TData, TError>(
188
196
  };
189
197
  }
190
198
 
199
+ /**
200
+ * Extracts only data and error from a query snapshot.
201
+ */
202
+ export function toQueryHandleData<TData, TError>(
203
+ snapshot: Pick<QueryHandleSnapshot<TData, TError>, 'data' | 'error'>
204
+ ): QueryHandleData<TData, TError> {
205
+ return {
206
+ data: snapshot.data,
207
+ error: snapshot.error,
208
+ };
209
+ }
210
+
191
211
  /**
192
212
  * Helper function to check if the query is in its initial loading state.
193
213
  */
@@ -200,7 +220,7 @@ export function isQueryLoading(query: QueryMetaState): boolean {
200
220
  * Prepares the query factory by binding it to a specific QueryClient instance.
201
221
  */
202
222
  export function setupQuery(queryClient: QueryClient): CreateUntrackedQuery {
203
- // Returns the actual factory function for creating individual query services.
223
+ // Returns the actual factory function for creating individual query handles.
204
224
  return function createQuery<
205
225
  TSources extends readonly unknown[] = [],
206
226
  TQueryFnData = unknown,
@@ -211,16 +231,16 @@ export function setupQuery(queryClient: QueryClient): CreateUntrackedQuery {
211
231
  >(
212
232
  queryKey: TQueryKey,
213
233
  queryFn: QueryFunction<TQueryFnData, TQueryKey>,
214
- options?: QueryServiceOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey, TSources>
215
- ): QueryService<TData, TError> {
216
- const { dependsOn, runtimeOptions } = splitQueryServiceOptions(options);
217
- const service = createQueryService(queryClient, queryKey, queryFn, runtimeOptions);
234
+ options?: QueryHandleOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey, TSources>
235
+ ): QueryHandle<TData, TError> {
236
+ const { dependsOn, runtimeOptions } = splitQueryHandleOptions(options);
237
+ const handle = createQueryHandle(queryClient, queryKey, queryFn, runtimeOptions);
218
238
 
219
239
  if (!dependsOn) {
220
- return service.service;
240
+ return handle.handle;
221
241
  }
222
242
 
223
- return bindQueryDependencies(service, queryKey, dependsOn);
243
+ return bindQueryDependencies(handle, queryKey, dependsOn);
224
244
  };
225
245
  }
226
246
 
@@ -249,33 +269,33 @@ export function setupTrackedQuery(
249
269
  >(
250
270
  queryKey: TQueryKey,
251
271
  queryFn: QueryFunction<TQueryFnData, TQueryKey>,
252
- options?: QueryServiceOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey, TSources>
253
- ): QueryService<TData, TError> {
254
- const { dependsOn, runtimeOptions } = splitQueryServiceOptions(options);
255
- // Reuse the same core query service implementation as the untracked API.
256
- const service = createQueryService(queryClient, queryKey, queryFn, runtimeOptions);
272
+ options?: QueryHandleOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey, TSources>
273
+ ): QueryHandle<TData, TError> {
274
+ const { dependsOn, runtimeOptions } = splitQueryHandleOptions(options);
275
+ // Reuse the same core query-handle implementation as the untracked API.
276
+ const handle = createQueryHandle(queryClient, queryKey, queryFn, runtimeOptions);
257
277
  // We only need re-registration on the transition from zero to one subscribers.
258
278
  let subscriberCount = 0;
259
279
 
260
280
  // Register the current query hash immediately so future tracked mutations can find it.
261
281
  trackingRegistry.register(
262
- service.observer.getCurrentQuery().queryHash,
263
- extractTrackedDependencies(service.getCurrentQueryKey())
282
+ handle.observer.getCurrentQuery().queryHash,
283
+ extractTrackedDependencies(handle.getCurrentQueryKey())
264
284
  );
265
285
 
266
286
  const applyTrackedDerivedState = (derivedOptions: QueryDependencyDerivedOptions<TQueryKey>) => {
267
- const previousQueryHash = service.observer.getCurrentQuery().queryHash;
287
+ const previousQueryHash = handle.observer.getCurrentQuery().queryHash;
268
288
 
269
- service.setDerivedState(derivedOptions);
289
+ handle.setDerivedState(derivedOptions);
270
290
 
271
- const nextQueryHash = service.observer.getCurrentQuery().queryHash;
291
+ const nextQueryHash = handle.observer.getCurrentQuery().queryHash;
272
292
 
273
293
  if (nextQueryHash === previousQueryHash) {
274
294
  return;
275
295
  }
276
296
 
277
297
  trackingRegistry.unregister(previousQueryHash);
278
- trackingRegistry.register(nextQueryHash, extractTrackedDependencies(service.getCurrentQueryKey()));
298
+ trackingRegistry.register(nextQueryHash, extractTrackedDependencies(handle.getCurrentQueryKey()));
279
299
  };
280
300
 
281
301
  const dependencyController = dependsOn
@@ -291,9 +311,9 @@ export function setupTrackedQuery(
291
311
  // the same mechanism TanStack uses internally when a query gets recreated after GC.
292
312
  const liveQuery = queryClient.getQueryCache().build(
293
313
  queryClient,
294
- service.getCurrentObserverOptions()
314
+ handle.getCurrentObserverOptions()
295
315
  );
296
- const liveDependencies = extractTrackedDependencies(service.getCurrentQueryKey());
316
+ const liveDependencies = extractTrackedDependencies(handle.getCurrentQueryKey());
297
317
 
298
318
  // Re-register only when TanStack has recreated the query and the registry has already
299
319
  // cleaned up the previous hash. This keeps the edge-case handling cheap in the common case.
@@ -303,12 +323,12 @@ export function setupTrackedQuery(
303
323
  };
304
324
 
305
325
  return {
306
- ...service.service,
326
+ ...handle.handle,
307
327
  refetch: async (refetchOptions) => {
308
328
  await dependencyController?.evaluateForRefetch();
309
329
  // Refetch is one of the two explicit reactivation paths agreed on in the design.
310
330
  ensureRegistered();
311
- return service.service.refetch(refetchOptions);
331
+ return handle.handle.refetch(refetchOptions);
312
332
  },
313
333
  subscribe: (listener) => {
314
334
  // The first active subscriber is the other reactivation path. Re-running registration
@@ -320,7 +340,7 @@ export function setupTrackedQuery(
320
340
 
321
341
  subscriberCount += 1;
322
342
 
323
- const unsubscribe = service.service.subscribe(listener);
343
+ const unsubscribe = handle.handle.subscribe(listener);
324
344
 
325
345
  return () => {
326
346
  // Keep the counter bounded so accidental double-unsubscribe cannot push it negative.
@@ -338,10 +358,10 @@ export function setupTrackedQuery(
338
358
  /**
339
359
  * Internal helper to transform a raw Tanstack query result into our public snapshot format.
340
360
  */
341
- function toQueryServiceSnapshot<TData, TError>(
361
+ function toQueryHandleSnapshot<TData, TError>(
342
362
  result: QueryObserverResult<TData, TError>
343
- ): QueryServiceSnapshot<TData, TError> {
344
- // Extract and return the relevant fields for the UI or other services.
363
+ ): QueryHandleSnapshot<TData, TError> {
364
+ // Extract and return the relevant fields for the UI or other handle consumers.
345
365
  return {
346
366
  data: result.data,
347
367
  error: result.error,
@@ -354,7 +374,7 @@ function toQueryServiceSnapshot<TData, TError>(
354
374
  };
355
375
  }
356
376
 
357
- function createQueryService<
377
+ function createQueryHandle<
358
378
  TQueryFnData = unknown,
359
379
  TError = Error,
360
380
  TData = TQueryFnData,
@@ -364,12 +384,12 @@ function createQueryService<
364
384
  queryClient: QueryClient,
365
385
  queryKey: TQueryKey,
366
386
  queryFn: QueryFunction<TQueryFnData, TQueryKey>,
367
- options?: QueryServiceRuntimeOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>
387
+ options?: QueryHandleRuntimeOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>
368
388
  ): {
369
389
  // Expose the observer internally so tracked queries can access the current query hash.
370
390
  observer: QueryObserver<TQueryFnData, TError, TData, TQueryData, TQueryKey>;
371
- // Preserve the public query-service shape for all callers.
372
- service: QueryService<TData, TError>;
391
+ // Preserve the public query-handle shape for all callers.
392
+ handle: QueryHandle<TData, TError>;
373
393
  getCurrentObserverOptions: () => QueryOptions<TQueryFnData, TError, TQueryData, TQueryKey> &
374
394
  QueryObserverOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>;
375
395
  getCurrentQueryKey: () => TQueryKey;
@@ -401,14 +421,14 @@ function createQueryService<
401
421
  getCurrentObserverOptions,
402
422
  getCurrentQueryKey: () => resolvedQueryKey,
403
423
  setDerivedState,
404
- service: {
405
- getSnapshot: () => toQueryServiceSnapshot(observer.getCurrentResult()),
424
+ handle: {
425
+ getSnapshot: () => toQueryHandleSnapshot(observer.getCurrentResult()),
406
426
  subscribe: (listener) =>
407
427
  observer.subscribe((result) => {
408
- listener(toQueryServiceSnapshot(result));
428
+ listener(toQueryHandleSnapshot(result));
409
429
  }),
410
430
  refetch: async (refetchOptions) =>
411
- toQueryServiceSnapshot(await observer.refetch(refetchOptions)),
431
+ toQueryHandleSnapshot(await observer.refetch(refetchOptions)),
412
432
  invalidate: (invalidateOptions) =>
413
433
  queryClient.invalidateQueries(
414
434
  {
@@ -433,24 +453,24 @@ function bindQueryDependencies<
433
453
  TQueryData = TQueryFnData,
434
454
  TQueryKey extends QueryKey = QueryKey,
435
455
  >(
436
- queryService: ReturnType<
437
- typeof createQueryService<TQueryFnData, TError, TData, TQueryData, TQueryKey>
456
+ queryHandle: ReturnType<
457
+ typeof createQueryHandle<TQueryFnData, TError, TData, TQueryData, TQueryKey>
438
458
  >,
439
459
  queryKey: TQueryKey,
440
460
  dependsOn: QueryDependencyTuple<TSources, TQueryKey>
441
- ): QueryService<TData, TError> {
461
+ ): QueryHandle<TData, TError> {
442
462
  const dependencyController = createDependencyController(
443
463
  queryKey,
444
- queryService.setDerivedState,
464
+ queryHandle.setDerivedState,
445
465
  dependsOn
446
466
  );
447
467
  let subscriberCount = 0;
448
468
 
449
469
  return {
450
- ...queryService.service,
470
+ ...queryHandle.handle,
451
471
  refetch: async (refetchOptions) => {
452
472
  await dependencyController.evaluateForRefetch();
453
- return queryService.service.refetch(refetchOptions);
473
+ return queryHandle.handle.refetch(refetchOptions);
454
474
  },
455
475
  subscribe: (listener) => {
456
476
  if (subscriberCount === 0) {
@@ -459,7 +479,7 @@ function bindQueryDependencies<
459
479
 
460
480
  subscriberCount += 1;
461
481
 
462
- const unsubscribe = queryService.service.subscribe(listener);
482
+ const unsubscribe = queryHandle.handle.subscribe(listener);
463
483
 
464
484
  return () => {
465
485
  subscriberCount = Math.max(0, subscriberCount - 1);
@@ -474,10 +494,6 @@ function bindQueryDependencies<
474
494
 
475
495
  function createDependencyController<
476
496
  TSources extends readonly unknown[] = [],
477
- TQueryFnData = unknown,
478
- TError = Error,
479
- TData = TQueryFnData,
480
- TQueryData = TQueryFnData,
481
497
  TQueryKey extends QueryKey = QueryKey,
482
498
  >(
483
499
  baseQueryKey: TQueryKey,
@@ -555,7 +571,7 @@ function createDependencyController<
555
571
  };
556
572
  }
557
573
 
558
- function splitQueryServiceOptions<
574
+ function splitQueryHandleOptions<
559
575
  TQueryFnData = unknown,
560
576
  TError = Error,
561
577
  TData = TQueryFnData,
@@ -563,10 +579,10 @@ function splitQueryServiceOptions<
563
579
  TQueryKey extends QueryKey = QueryKey,
564
580
  TSources extends readonly unknown[] = [],
565
581
  >(
566
- options?: QueryServiceOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey, TSources>
582
+ options?: QueryHandleOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey, TSources>
567
583
  ): {
568
584
  dependsOn?: QueryDependencyTuple<TSources, TQueryKey>;
569
- runtimeOptions: QueryServiceRuntimeOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey> | undefined;
585
+ runtimeOptions: QueryHandleRuntimeOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey> | undefined;
570
586
  } {
571
587
  if (options === undefined) {
572
588
  return {
@@ -592,7 +608,7 @@ function toQueryOptions<
592
608
  >(
593
609
  queryKey: TQueryKey,
594
610
  queryFn: QueryFunction<TQueryFnData, TQueryKey>,
595
- options?: QueryServiceRuntimeOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>
611
+ options?: QueryHandleRuntimeOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>
596
612
  ): QueryOptions<TQueryFnData, TError, TQueryData, TQueryKey> &
597
613
  QueryObserverOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey> {
598
614
  // Centralize option assembly so both normal queries and tracked queries build observers and
@@ -0,0 +1,104 @@
1
+ import React, { act } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { QueryClient } from '@tanstack/query-core';
4
+
5
+ import { setupQuery } from '../../query.js';
6
+ import { useQueryHandle } from '../hooks/use-query-handle.js';
7
+
8
+ import type { QueryHandle, QueryHandleSnapshot } from '../../query.js';
9
+
10
+ declare global {
11
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
12
+ }
13
+
14
+ describe('useQueryHandle', () => {
15
+ let container: HTMLDivElement;
16
+
17
+ beforeAll(() => {
18
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
19
+ });
20
+
21
+ beforeEach(() => {
22
+ container = document.createElement('div');
23
+ document.body.appendChild(container);
24
+ });
25
+
26
+ afterEach(() => {
27
+ container.remove();
28
+ });
29
+
30
+ it('renders the latest query snapshot', async () => {
31
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 0 } } });
32
+ const createQuery = setupQuery(queryClient);
33
+ const query = createQuery(['product', 1], jest.fn().mockResolvedValue({ name: 'Ada' }), {
34
+ enabled: false,
35
+ });
36
+ const renderStates: Array<string | undefined> = [];
37
+
38
+ const Consumer = () => {
39
+ const snapshot = useQueryHandle(query);
40
+ renderStates.push(snapshot.data?.name);
41
+
42
+ return <span>{snapshot.data?.name ?? 'pending'}</span>;
43
+ };
44
+
45
+ const root = createRoot(container);
46
+
47
+ await act(async () => {
48
+ root.render(<Consumer />);
49
+ });
50
+
51
+ expect(container.textContent).toBe('pending');
52
+
53
+ await act(async () => {
54
+ await query.refetch();
55
+ });
56
+
57
+ expect(container.textContent).toBe('Ada');
58
+ expect(renderStates).toContain(undefined);
59
+ expect(renderStates).toContain('Ada');
60
+
61
+ await act(async () => {
62
+ root.unmount();
63
+ });
64
+ });
65
+ it('cleans up the store subscription on unmount', async () => {
66
+ const snapshot: QueryHandleSnapshot<{ name: string }, Error> = {
67
+ data: { name: 'Ada' },
68
+ error: null,
69
+ fetchStatus: 'idle',
70
+ status: 'success',
71
+ isError: false,
72
+ isFetching: false,
73
+ isPending: false,
74
+ isSuccess: true,
75
+ };
76
+ const unsubscribe = jest.fn();
77
+ const query: QueryHandle<{ name: string }, Error> = {
78
+ getSnapshot: () => snapshot,
79
+ subscribe: jest.fn(() => unsubscribe),
80
+ refetch: jest.fn(async () => snapshot),
81
+ invalidate: jest.fn(async () => undefined),
82
+ unsafe_getResult: jest.fn(),
83
+ };
84
+
85
+ const root = createRoot(container);
86
+
87
+ const Consumer = () => {
88
+ useQueryHandle(query);
89
+ return <span>ready</span>;
90
+ };
91
+
92
+ await act(async () => {
93
+ root.render(<Consumer />);
94
+ });
95
+
96
+ expect(query.subscribe).toHaveBeenCalledTimes(1);
97
+
98
+ await act(async () => {
99
+ root.unmount();
100
+ });
101
+
102
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
103
+ });
104
+ });
@@ -0,0 +1 @@
1
+ export { useQueryHandle } from './use-query-handle.js';
@@ -0,0 +1,96 @@
1
+ // Import the React hooks needed to memoize callbacks, hold mutable cache state,
2
+ // and connect an external store to React rendering.
3
+ import { useCallback, useRef, useSyncExternalStore } from 'react';
4
+
5
+ // Import the query-handle contract and the stable snapshot shape the hook returns.
6
+ import type { QueryHandle, QueryHandleSnapshot } from '../../query.js';
7
+ // Describe the no-argument listener shape expected by useSyncExternalStore.
8
+ type Listener = () => void;
9
+
10
+ // Store one cached snapshot together with the store version it belongs to.
11
+ type SnapshotCacheEntry<TData, TError> = {
12
+ // Hold the snapshot returned by the query handle for this version.
13
+ snapshot: QueryHandleSnapshot<TData, TError>;
14
+ // Track which subscription version produced the cached snapshot.
15
+ version: number;
16
+ };
17
+
18
+ // Subscribe a React component to a QueryHandle and return its latest snapshot.
19
+ export function useQueryHandle<TData, TError>(
20
+ // Receive the external query handle instance to subscribe to.
21
+ queryHandle: QueryHandle<TData, TError>
22
+ ) {
23
+ // Count store notifications so we can tell when our cached snapshot is stale.
24
+ const snapshotVersionRef = useRef(0);
25
+ // Cache one client-side snapshot per observed version to keep getSnapshot stable.
26
+ const snapshotCacheRef = useRef<SnapshotCacheEntry<TData, TError> | null>(null);
27
+ // Cache the server snapshot separately for the useSyncExternalStore SSR fallback.
28
+ const serverSnapshotCacheRef = useRef<QueryHandleSnapshot<TData, TError> | null>(null);
29
+
30
+ // Create the subscribe function expected by useSyncExternalStore.
31
+ const subscribe = useCallback(
32
+ // React passes a listener that must run whenever the external store changes.
33
+ (listener: Listener) =>
34
+ // Forward the subscription to the query handle.
35
+ queryHandle.subscribe(() => {
36
+ // Bump the version so later reads know the previous cache is outdated.
37
+ snapshotVersionRef.current += 1;
38
+ // Drop the cached client snapshot because the store just changed.
39
+ snapshotCacheRef.current = null;
40
+ // Drop the cached server snapshot for the same reason.
41
+ serverSnapshotCacheRef.current = null;
42
+ // Notify React that it should read a fresh snapshot.
43
+ listener();
44
+ }),
45
+ // Recreate the subscription function only when the handle instance changes.
46
+ [queryHandle]
47
+ );
48
+
49
+ // Read the current client snapshot in a referentially stable way for React.
50
+ const getSnapshot = useCallback(() => {
51
+ // Read the latest store version number.
52
+ const version = snapshotVersionRef.current;
53
+ // Read the last cached client snapshot, if there is one.
54
+ const cachedSnapshot = snapshotCacheRef.current;
55
+
56
+ // Reuse the cached snapshot when it was produced for the current version.
57
+ if (cachedSnapshot && cachedSnapshot.version === version) {
58
+ // Return the cached snapshot so repeated reads in the same render stay stable.
59
+ return cachedSnapshot.snapshot;
60
+ }
61
+
62
+ // Ask the query handle for the latest snapshot because the cache is empty or stale.
63
+ const snapshot = queryHandle.getSnapshot();
64
+ // Store the new snapshot together with the version it belongs to.
65
+ snapshotCacheRef.current = {
66
+ snapshot,
67
+ version,
68
+ };
69
+
70
+ // Return the freshly read snapshot to React.
71
+ return snapshot;
72
+ }, [queryHandle]);
73
+
74
+ // Read the server snapshot used by React during SSR or hydration fallback paths.
75
+ const getServerSnapshot = useCallback(() => {
76
+ // Read the cached server snapshot, if one was stored earlier.
77
+ const cachedSnapshot = serverSnapshotCacheRef.current;
78
+
79
+ // Reuse the cached server snapshot to keep server reads stable.
80
+ if (cachedSnapshot) {
81
+ // Return the cached server snapshot directly.
82
+ return cachedSnapshot;
83
+ }
84
+
85
+ // Ask the query handle for a snapshot because no server cache exists yet.
86
+ const snapshot = queryHandle.getSnapshot();
87
+ // Cache that snapshot for the next server read.
88
+ serverSnapshotCacheRef.current = snapshot;
89
+
90
+ // Return the freshly read server snapshot.
91
+ return snapshot;
92
+ }, [queryHandle]);
93
+
94
+ // Let React subscribe to the external store and read snapshots through the callbacks above.
95
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
96
+ }
@@ -0,0 +1 @@
1
+ export { useQueryHandle } from './hooks/index.js';
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "extends": "./tsconfig.json",
3
3
  "include": [
4
- "src/**/*.ts"
4
+ "src/**/*.ts",
5
+ "src/**/*.tsx"
5
6
  ],
6
7
  "exclude": [
7
8
  "coverage",
package/tsconfig.json CHANGED
@@ -4,8 +4,10 @@
4
4
  "declaration": true,
5
5
  "declarationDir": "dist",
6
6
  "esModuleInterop": true,
7
+ "jsx": "react",
7
8
  "lib": [
8
- "es2022"
9
+ "es2022",
10
+ "dom"
9
11
  ],
10
12
  "module": "es2022",
11
13
  "moduleResolution": "bundler",
@@ -19,7 +21,8 @@
19
21
  ]
20
22
  },
21
23
  "include": [
22
- "src/**/*.ts"
24
+ "src/**/*.ts",
25
+ "src/**/*.tsx"
23
26
  ],
24
27
  "exclude": [
25
28
  "coverage",
@@ -1,9 +0,0 @@
1
- import type { QueryService } from './query.js';
2
- export interface QueryRegistry<TParams, TKey extends readonly unknown[]> {
3
- clear: () => void;
4
- getKey: (params: TParams) => TKey;
5
- name: string;
6
- resolve: <TData, TError = Error>(params: TParams, create: (queryKey: TKey) => QueryService<TData, TError>) => QueryService<TData, TError>;
7
- }
8
- export declare function createQueryRegistry<TParams, TKey extends readonly unknown[]>(name: string, createKey: (params: TParams) => TKey): QueryRegistry<TParams, TKey>;
9
- export declare function serializeQueryKey(queryKey: readonly unknown[]): string;
@@ -1,28 +0,0 @@
1
- import { hashKey } from '@tanstack/query-core';
2
- export function createQueryRegistry(name, createKey) {
3
- const entries = new Map();
4
- return {
5
- name,
6
- clear() {
7
- entries.clear();
8
- },
9
- getKey(params) {
10
- return createKey(params);
11
- },
12
- resolve(params, create) {
13
- const queryKey = createKey(params);
14
- const cacheKey = serializeQueryKey(queryKey);
15
- const existingEntry = entries.get(cacheKey);
16
- if (existingEntry) {
17
- return existingEntry;
18
- }
19
- const nextEntry = create(queryKey);
20
- entries.set(cacheKey, nextEntry);
21
- return nextEntry;
22
- },
23
- };
24
- }
25
- export function serializeQueryKey(queryKey) {
26
- return hashKey(queryKey);
27
- }
28
- //# sourceMappingURL=query-registry.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"query-registry.js","sourceRoot":"","sources":["../src/query-registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAc/C,MAAM,UAAU,mBAAmB,CACjC,IAAY,EACZ,SAAoC;IAEpC,MAAM,OAAO,GAAG,IAAI,GAAG,EAA0C,CAAC;IAElE,OAAO;QACL,IAAI;QACJ,KAAK;YACH,OAAO,CAAC,KAAK,EAAE,CAAC;QAClB,CAAC;QACD,MAAM,CAAC,MAAM;YACX,OAAO,SAAS,CAAC,MAAM,CAAC,CAAC;QAC3B,CAAC;QACD,OAAO,CACL,MAAe,EACf,MAAuD;YAEvD,MAAM,QAAQ,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC;YACnC,MAAM,QAAQ,GAAG,iBAAiB,CAAC,QAAQ,CAAC,CAAC;YAC7C,MAAM,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ,CAA4C,CAAC;YAEvF,IAAI,aAAa,EAAE,CAAC;gBAClB,OAAO,aAAa,CAAC;YACvB,CAAC;YAED,MAAM,SAAS,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;YAEnC,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,SAA2C,CAAC,CAAC;YAEnE,OAAO,SAAS,CAAC;QACnB,CAAC;KACF,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,QAA4B;IAC5D,OAAO,OAAO,CAAC,QAAQ,CAAC,CAAC;AAC3B,CAAC"}