@effector-tanstack-query/core 0.4.0 → 1.0.0-rc.1

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.
@@ -6,7 +6,7 @@ import {
6
6
  sample,
7
7
  scopeBind,
8
8
  } from 'effector'
9
- import type { EventCallable, Store } from 'effector'
9
+ import type { Event, EventCallable, Store } from 'effector'
10
10
  import type {
11
11
  FetchStatus,
12
12
  QueryClient,
@@ -40,6 +40,17 @@ export interface BaseObserverResult<TData, TError> {
40
40
  isFetching: boolean
41
41
  fetchStatus: FetchStatus
42
42
  isPlaceholderData: boolean
43
+ /**
44
+ * Timestamp (ms) of the last successful data resolution. Monotonically
45
+ * increases per successful fetch — used to detect newly-finished fetches
46
+ * for the `finished.success` lifecycle event.
47
+ */
48
+ dataUpdatedAt: number
49
+ /**
50
+ * Timestamp (ms) of the last error. Increments per failed fetch — used to
51
+ * detect newly-finished failures for the `finished.failure` lifecycle event.
52
+ */
53
+ errorUpdatedAt: number
43
54
  }
44
55
 
45
56
  export interface BaseQueryStores<TData, TError, TObserver> {
@@ -71,6 +82,17 @@ export interface BaseQueryStores<TData, TError, TObserver> {
71
82
  refresh: EventCallable<void>
72
83
  mounted: EventCallable<void>
73
84
  unmounted: EventCallable<void>
85
+ /**
86
+ * Lifecycle events for `sample`-driven reactions to fetch completion.
87
+ * `success` fires with the (post-`select`) data on every newly-finished
88
+ * successful fetch; `failure` fires with the error on every failed fetch.
89
+ * Neither fires for the baseline state observed on mount (e.g. hydrated
90
+ * cache) — they track *new* fetches, not initial observability.
91
+ */
92
+ finished: {
93
+ success: Event<TData>
94
+ failure: Event<TError>
95
+ }
74
96
  }
75
97
 
76
98
  export interface BaseQueryOptions {
@@ -186,6 +208,11 @@ export function createBaseQuery<
186
208
  const fetchStatusUpdated = createEvent<FetchStatus>()
187
209
  const isPlaceholderDataUpdated = createEvent<boolean>()
188
210
 
211
+ // Lifecycle events. Created once at factory time; dispatched per-scope via
212
+ // scopeBind inside the mount effect so `allSettled` / fork isolation work.
213
+ const finishedSuccess = createEvent<TData>()
214
+ const finishedFailure = createEvent<TError>()
215
+
189
216
  const $data = createStore<TData | undefined>(undefined, {
190
217
  skipVoid: false,
191
218
  ...sidConfig(name, '$data'),
@@ -268,8 +295,19 @@ export function createBaseQuery<
268
295
  const dispatchIsPlaceholderData = scopeBind(isPlaceholderDataUpdated, {
269
296
  safe: true,
270
297
  })
298
+ const dispatchFinishedSuccess = scopeBind(finishedSuccess, { safe: true })
299
+ const dispatchFinishedFailure = scopeBind(finishedFailure, { safe: true })
271
300
  const dispatchExtras = extras?.bindDispatcher()
272
301
 
302
+ // Per-mount, per-scope baseline for lifecycle events. The first
303
+ // notification (the immediate getCurrentResult() emit below, or the
304
+ // observer's first callback) establishes the baseline without firing —
305
+ // so hydrated cache data on mount doesn't dispatch `finished.success`.
306
+ // Subsequent increments of dataUpdatedAt / errorUpdatedAt are genuine
307
+ // new fetches and do fire.
308
+ let lastDataUpdatedAt = -1
309
+ let lastErrorUpdatedAt = -1
310
+
273
311
  observerSubscriptions.get(observer)?.()
274
312
  observer.setOptions({
275
313
  ...observer.options,
@@ -289,6 +327,30 @@ export function createBaseQuery<
289
327
  dispatchFetchStatus(result.fetchStatus)
290
328
  dispatchIsPlaceholderData(result.isPlaceholderData)
291
329
  dispatchExtras?.(result)
330
+
331
+ if (lastDataUpdatedAt === -1) {
332
+ // Baseline — record current timestamps without emitting.
333
+ lastDataUpdatedAt = result.dataUpdatedAt
334
+ lastErrorUpdatedAt = result.errorUpdatedAt
335
+ } else {
336
+ // A newly-resolved successful fetch. Guard against placeholderData,
337
+ // which carries status 'success' but never advances dataUpdatedAt.
338
+ if (
339
+ result.dataUpdatedAt > lastDataUpdatedAt &&
340
+ result.status === 'success' &&
341
+ !result.isPlaceholderData
342
+ ) {
343
+ lastDataUpdatedAt = result.dataUpdatedAt
344
+ dispatchFinishedSuccess(result.data as TData)
345
+ }
346
+ if (
347
+ result.errorUpdatedAt > lastErrorUpdatedAt &&
348
+ result.status === 'error'
349
+ ) {
350
+ lastErrorUpdatedAt = result.errorUpdatedAt
351
+ dispatchFinishedFailure(result.error as TError)
352
+ }
353
+ }
292
354
  }
293
355
 
294
356
  const unsubscribe = observer.subscribe(dispatch)
@@ -423,6 +485,10 @@ export function createBaseQuery<
423
485
  refresh,
424
486
  mounted,
425
487
  unmounted,
488
+ finished: {
489
+ success: finishedSuccess,
490
+ failure: finishedFailure,
491
+ },
426
492
  ...(extras?.stores ?? ({} as TExtraStores)),
427
493
  }
428
494
  }
@@ -0,0 +1,172 @@
1
+ import { attach, createEvent, createStore, sample } from 'effector'
2
+ import type { EventCallable, Store } from 'effector'
3
+ import type { QueryClient, QueryFilters, QueryKey } from '@tanstack/query-core'
4
+ import { $queryClient } from './queryClient'
5
+ import { resolveKey } from './resolve'
6
+ import type { EffectorQueryKey } from './types'
7
+
8
+ /**
9
+ * Options shared by `createCancel` / `createRemove` / `createReset`.
10
+ *
11
+ * Structurally a `QueryFilters` (from `@tanstack/query-core`) with the
12
+ * `queryKey` field widened to the reactive `EffectorQueryKey` shape — drop a
13
+ * `Store` anywhere in the array and the action resolves it on every call.
14
+ *
15
+ * `queryKey` is **optional**: omit it to target *all* queries, mirroring
16
+ * `queryClient.cancelQueries()` / `removeQueries()` / `resetQueries()` with no
17
+ * filters.
18
+ */
19
+ export interface CacheActionOptions extends Omit<QueryFilters, 'queryKey'> {
20
+ /**
21
+ * Key (or key prefix) to act on. Supports the same reactive shape as
22
+ * `createQuery.queryKey`. Omit to match every query in the cache.
23
+ */
24
+ queryKey?: EffectorQueryKey
25
+ }
26
+
27
+ /** Distinct named aliases so each factory documents its own option type. */
28
+ export type CreateCancelOptions = CacheActionOptions
29
+ export type CreateRemoveOptions = CacheActionOptions
30
+ export type CreateResetOptions = CacheActionOptions
31
+
32
+ /**
33
+ * The QueryClient method a given action drives. Receives the resolved filters
34
+ * (with `queryKey` already merged in when present). May be sync (`removeQueries`
35
+ * returns `void`) or async (`cancelQueries` / `resetQueries` return a Promise).
36
+ */
37
+ type CacheActionRunner = (qc: QueryClient, filters: QueryFilters) => unknown
38
+
39
+ /**
40
+ * Shared builder. Returns a `void` event that, when fired, runs `action`
41
+ * against the resolved `QueryClient` with the resolved filters. Mirrors
42
+ * `createInvalidate`'s locking + reactive-key + per-scope semantics exactly.
43
+ */
44
+ function buildCacheAction(
45
+ action: CacheActionRunner,
46
+ explicitClient: QueryClient | null,
47
+ options: CacheActionOptions,
48
+ ): EventCallable<void> {
49
+ const { queryKey, ...restFilters } = options
50
+
51
+ // Same locking semantics as the other factories: explicit client freezes the
52
+ // store, default flows through global $queryClient (and respects
53
+ // fork({ values: [[$queryClient, qc]] }) for per-scope isolation).
54
+ const $effectiveClient: Store<QueryClient | null> = explicitClient
55
+ ? createStore(explicitClient as QueryClient | null, {
56
+ serialize: 'ignore',
57
+ })
58
+ : $queryClient
59
+
60
+ // No queryKey → a store of `undefined`, so the effect omits the field and the
61
+ // action matches all queries.
62
+ const $resolvedKey: Store<QueryKey | undefined> = queryKey
63
+ ? resolveKey(queryKey)
64
+ : createStore<QueryKey | undefined>(undefined, { skipVoid: false })
65
+
66
+ const run = createEvent<void>()
67
+
68
+ const runFx = attach({
69
+ source: { qc: $effectiveClient, key: $resolvedKey },
70
+ effect: ({ qc, key }) => {
71
+ if (!qc) return
72
+ const filters: QueryFilters = { ...restFilters }
73
+ if (key !== undefined) filters.queryKey = key
74
+ return action(qc, filters)
75
+ },
76
+ })
77
+
78
+ sample({ clock: run, target: runFx })
79
+
80
+ return run
81
+ }
82
+
83
+ function parseArgs(
84
+ arg1: QueryClient | CacheActionOptions,
85
+ arg2?: CacheActionOptions,
86
+ ): [QueryClient | null, CacheActionOptions] {
87
+ if (arg2 !== undefined) return [arg1 as QueryClient, arg2]
88
+ return [null, arg1 as CacheActionOptions]
89
+ }
90
+
91
+ /**
92
+ * Builds a `sample`-friendly event that **cancels** in-flight queries on the
93
+ * resolved `QueryClient` (without removing cached data). Wraps
94
+ * `queryClient.cancelQueries` — async, so `await allSettled(cancel, { scope })`
95
+ * waits for cancellation to settle.
96
+ *
97
+ * @example Cancel a user's queries on logout
98
+ * const cancelUserQueries = createCancel({ queryKey: ['user', $userId] })
99
+ * sample({ clock: logoutClicked, target: cancelUserQueries })
100
+ *
101
+ * @example Explicit client (same back-compat overload as createInvalidate)
102
+ * const cancelAll = createCancel(queryClient, {})
103
+ */
104
+ export function createCancel(options: CacheActionOptions): EventCallable<void>
105
+ export function createCancel(
106
+ queryClient: QueryClient,
107
+ options: CacheActionOptions,
108
+ ): EventCallable<void>
109
+ export function createCancel(
110
+ arg1: QueryClient | CacheActionOptions,
111
+ arg2?: CacheActionOptions,
112
+ ): EventCallable<void> {
113
+ const [explicitClient, options] = parseArgs(arg1, arg2)
114
+ return buildCacheAction(
115
+ (qc, filters) => qc.cancelQueries(filters),
116
+ explicitClient,
117
+ options,
118
+ )
119
+ }
120
+
121
+ /**
122
+ * Builds a `sample`-friendly event that **removes** matching entries from the
123
+ * cache entirely. Wraps `queryClient.removeQueries` — synchronous (no Promise).
124
+ * The next mount of a removed query starts from a pending state.
125
+ *
126
+ * @example Drop stale cache on navigation
127
+ * const removeStale = createRemove({ queryKey: ['stale-cache'] })
128
+ * sample({ clock: navigated, target: removeStale })
129
+ */
130
+ export function createRemove(options: CacheActionOptions): EventCallable<void>
131
+ export function createRemove(
132
+ queryClient: QueryClient,
133
+ options: CacheActionOptions,
134
+ ): EventCallable<void>
135
+ export function createRemove(
136
+ arg1: QueryClient | CacheActionOptions,
137
+ arg2?: CacheActionOptions,
138
+ ): EventCallable<void> {
139
+ const [explicitClient, options] = parseArgs(arg1, arg2)
140
+ return buildCacheAction(
141
+ (qc, filters) => qc.removeQueries(filters),
142
+ explicitClient,
143
+ options,
144
+ )
145
+ }
146
+
147
+ /**
148
+ * Builds a `sample`-friendly event that **resets** matching queries back to
149
+ * their initial state and refetches any active observers. Wraps
150
+ * `queryClient.resetQueries` — async, so `await allSettled(reset, { scope })`
151
+ * waits for the refetch to settle.
152
+ *
153
+ * @example Reset search results when the filters are cleared
154
+ * const resetSearch = createReset({ queryKey: ['search'] })
155
+ * sample({ clock: filtersCleared, target: resetSearch })
156
+ */
157
+ export function createReset(options: CacheActionOptions): EventCallable<void>
158
+ export function createReset(
159
+ queryClient: QueryClient,
160
+ options: CacheActionOptions,
161
+ ): EventCallable<void>
162
+ export function createReset(
163
+ arg1: QueryClient | CacheActionOptions,
164
+ arg2?: CacheActionOptions,
165
+ ): EventCallable<void> {
166
+ const [explicitClient, options] = parseArgs(arg1, arg2)
167
+ return buildCacheAction(
168
+ (qc, filters) => qc.resetQueries(filters),
169
+ explicitClient,
170
+ options,
171
+ )
172
+ }
@@ -272,6 +272,7 @@ export function createInfiniteQuery<
272
272
  prefetch,
273
273
  mounted: base.mounted,
274
274
  unmounted: base.unmounted,
275
+ finished: base.finished,
275
276
  }
276
277
 
277
278
  Object.defineProperty(result, '__createObserver', {
@@ -124,6 +124,7 @@ export function createQuery<
124
124
  prefetch,
125
125
  mounted: base.mounted,
126
126
  unmounted: base.unmounted,
127
+ finished: base.finished,
127
128
  }
128
129
 
129
130
  // Internal: used by useSuspenseQuery to construct a transient observer
package/src/index.ts CHANGED
@@ -4,6 +4,17 @@ export { createQueries } from './createQueries'
4
4
  export { createMutation } from './createMutation'
5
5
  export { createInvalidate } from './createInvalidate'
6
6
  export type { CreateInvalidateOptions } from './createInvalidate'
7
+ export {
8
+ createCancel,
9
+ createRemove,
10
+ createReset,
11
+ } from './createCacheAction'
12
+ export type {
13
+ CacheActionOptions,
14
+ CreateCancelOptions,
15
+ CreateRemoveOptions,
16
+ CreateResetOptions,
17
+ } from './createCacheAction'
7
18
  export { $queryClient, setQueryClient } from './queryClient'
8
19
  export { prefetchQueries } from './prefetchQueries'
9
20
  export type {
package/src/types.ts CHANGED
@@ -160,6 +160,30 @@ export interface QueryResult<TData, TError = Error> {
160
160
  * `$queryClient` and honors `fork({ values: [[$queryClient, qc]] })`.
161
161
  */
162
162
  $queryClient: Store<QueryClient | null>
163
+ /**
164
+ * Lifecycle events for `sample`-driven reactions to fetch completion.
165
+ *
166
+ * - `success` fires with the (post-`select`) data on every newly-finished
167
+ * successful fetch — fresh fetch, refetch, reactive key change, or a
168
+ * cross-scope `setQueryData`.
169
+ * - `failure` fires with the error on every failed fetch.
170
+ *
171
+ * Neither fires for the baseline state observed on `mounted()` (e.g.
172
+ * SSR-hydrated cache data) — the events track *new* fetches, not the
173
+ * initial observation. Each fork scope tracks its own baseline.
174
+ *
175
+ * @example
176
+ * sample({ clock: userQuery.finished.success, target: loadSettings })
177
+ * sample({
178
+ * clock: userQuery.finished.failure,
179
+ * fn: (err) => `Failed: ${err.message}`,
180
+ * target: showToast,
181
+ * })
182
+ */
183
+ finished: {
184
+ success: Event<TData>
185
+ failure: Event<TError>
186
+ }
163
187
  }
164
188
 
165
189
  export interface CreateInfiniteQueryOptions<
@@ -231,6 +255,11 @@ export interface InfiniteQueryResult<
231
255
  >
232
256
  /** See {@link QueryResult.$queryClient}. */
233
257
  $queryClient: Store<QueryClient | null>
258
+ /** See {@link QueryResult.finished}. */
259
+ finished: {
260
+ success: Event<TData>
261
+ failure: Event<TError>
262
+ }
234
263
  }
235
264
 
236
265
  export type CreateMutationOptions<