@uniflowed/query 0.0.0-alpha.10

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/infinite.js ADDED
@@ -0,0 +1,311 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/query/infinite`: many pages, one entry.
4
+ //
5
+ // "Load more" is not a sequence of queries. If page two were its own cache
6
+ // entry, then the list on screen would be the concatenation of three entries
7
+ // that can be invalidated, refetched and garbage-collected independently — and
8
+ // the first time one of them refreshed on its own the reader would see a list
9
+ // with a hole in it, or the same row twice.
10
+ //
11
+ // So a paged query is *one* entry whose value happens to be a list of pages:
12
+ //
13
+ // ```js
14
+ // { pages: [[…], […]], pageParams: [undefined, "cursor-2"] }
15
+ // ```
16
+ //
17
+ // One key, one staleness clock, one invalidation, one garbage collection, and
18
+ // one thing to render. `pageParams` is kept beside the pages because a refetch
19
+ // has to be able to ask for the same pages again, and a cursor is not
20
+ // recoverable from the rows it returned.
21
+ //
22
+ // # Why this is a fetcher and not a second cache
23
+ //
24
+ // Everything else about a paged query — de-duplication, retries, cancellation,
25
+ // staleness, structural sharing — is identical to an ordinary one. The only
26
+ // difference is what "fetch this entry" means, so that is the only thing this
27
+ // module replaces: [`infinitePages`] builds the function [`Query`] calls, and
28
+ // [`InfiniteQueryObserver`] is the ordinary observer with the page controls
29
+ // added to its snapshot.
30
+ //
31
+ // Structural sharing is why appending a page is cheap: the new value is a new
32
+ // array with the *same* page objects in it, so the pages already rendered keep
33
+ // their identity and a memoised row component does not re-render because its
34
+ // neighbour arrived.
35
+ //
36
+ // # Why a refetch re-asks for every page
37
+ //
38
+ // The alternative is to refetch page one and keep the rest, which produces a
39
+ // list that is coherent nowhere: the first page is from now and the second is
40
+ // from ten minutes ago, and an item that moved between them appears twice or
41
+ // not at all. So a refetch walks the recorded `pageParams` in order and asks
42
+ // again for each. It is more expensive, and it is the only version that is
43
+ // correct.
44
+ //
45
+ // The recorded parameters are reused rather than recomputed from
46
+ // `getNextPageParam`, so a refetch asks for the pages the reader is looking
47
+ // at. A cursor that has since expired is the server's to reject, and the
48
+ // failure is reported rather than hidden behind a silently different list.
49
+ //
50
+ // # What is out of scope
51
+ //
52
+ // `client.fetchInfiniteQuery` — prefetching a paged query from outside React —
53
+ // is not implemented. It needs the same page walk with a different entry
54
+ // point, and until there is a server-rendering path that wants it, it would be
55
+ // an untested API. `useInfiniteQuery` inside a tree covers what applications
56
+ // do today.
57
+
58
+ import type { QueryKey } from "./key.js";
59
+ import { QueryObserver } from "./observer.js";
60
+ import type { QueryResult, ResolvedQueryOptions } from "./observer.js";
61
+ import type { FetchContext, FetchDirection, Fetcher, Query, QueryState } from "./query.js";
62
+
63
+ /** The value a paged entry holds. */
64
+ export type InfiniteData<TPage, TParam> = {|
65
+ readonly pages: $ReadOnlyArray<TPage>,
66
+ readonly pageParams: $ReadOnlyArray<TParam>,
67
+ |};
68
+
69
+ /** What the query function is called with, once per page. */
70
+ export type InfinitePageContext<TParam> = {|
71
+ readonly queryKey: QueryKey,
72
+ readonly signal: AbortSignal,
73
+ readonly pageParam: TParam,
74
+ readonly direction: FetchDirection,
75
+ |};
76
+
77
+ /**
78
+ * Where the next page starts, or `null` when there is no next page.
79
+ *
80
+ * Returning `null` is how the list ends, and it is why `hasNextPage` can be
81
+ * answered without an extra request: the server already said so in the answer
82
+ * it gave for the last page.
83
+ */
84
+ export type PageParamFn<TPage, TParam> = (
85
+ lastPage: TPage,
86
+ allPages: $ReadOnlyArray<TPage>,
87
+ lastPageParam: TParam,
88
+ allPageParams: $ReadOnlyArray<TParam>,
89
+ ) => TParam | null | void;
90
+
91
+ export type InfiniteQueryOptions<TPage, TParam, TSelected = InfiniteData<TPage, TParam>> = {|
92
+ readonly queryKey: QueryKey,
93
+ readonly queryFn: (context: InfinitePageContext<TParam>) => Promise<TPage>,
94
+ /** The parameter the first page is asked for with. */
95
+ readonly initialPageParam: TParam,
96
+ readonly getNextPageParam: PageParamFn<TPage, TParam>,
97
+ readonly getPreviousPageParam?: PageParamFn<TPage, TParam>,
98
+ /** Keep at most this many pages, dropping from the far end. */
99
+ readonly maxPages?: number,
100
+ readonly enabled?: boolean,
101
+ readonly staleTime?: number,
102
+ readonly gcTime?: number,
103
+ readonly retry?: mixed,
104
+ readonly retryDelay?: mixed,
105
+ readonly select?: (data: InfiniteData<TPage, TParam>) => TSelected,
106
+ readonly placeholderData?: mixed,
107
+ readonly refetchInterval?: number | null,
108
+ readonly refetchOnWindowFocus?: boolean,
109
+ readonly refetchOnReconnect?: boolean,
110
+ |};
111
+
112
+ /** An ordinary result, plus the two ends of the list. */
113
+ export type InfiniteQueryResult<TSelected> = {|
114
+ ...QueryResult<TSelected>,
115
+ readonly hasNextPage: boolean,
116
+ readonly hasPreviousPage: boolean,
117
+ readonly isFetchingNextPage: boolean,
118
+ readonly isFetchingPreviousPage: boolean,
119
+ readonly fetchNextPage: () => Promise<void>,
120
+ readonly fetchPreviousPage: () => Promise<void>,
121
+ |};
122
+
123
+ /**
124
+ * The function the cache calls to fill a paged entry.
125
+ *
126
+ * `direction` decides which of the three things it means: extend the list
127
+ * forward, extend it backward, or — when there is no direction — refetch every
128
+ * page the entry already holds. A first fetch has nothing to extend, so it
129
+ * asks for `initialPageParam` whatever the direction says.
130
+ */
131
+ export function infinitePages<TPage, TParam>(
132
+ options: InfiniteQueryOptions<TPage, TParam, mixed>,
133
+ direction: FetchDirection | null,
134
+ ): Fetcher<InfiniteData<TPage, TParam>> {
135
+ return async (context: FetchContext<InfiniteData<TPage, TParam>>) => {
136
+ const previous = context.previousData;
137
+ const askFor = (pageParam: TParam, towards: FetchDirection): Promise<TPage> =>
138
+ options.queryFn({
139
+ queryKey: context.queryKey,
140
+ pageParam,
141
+ direction: towards,
142
+ // Delegated rather than read, so a paged query function that ignores
143
+ // the signal leaves the entry uncancellable in exactly the same way an
144
+ // ordinary one does. Reading it here instead would opt every paged
145
+ // query in on its behalf. See `query.js` for what the getter means.
146
+ // uf-lint-disable-next-line flow/unsafe-getters-setters
147
+ get signal(): AbortSignal {
148
+ return context.signal;
149
+ },
150
+ });
151
+
152
+ if (previous == null || previous.pages.length === 0) {
153
+ const page = await askFor(options.initialPageParam, "forward");
154
+ return { pages: [page], pageParams: [options.initialPageParam] };
155
+ }
156
+
157
+ if (direction === "forward") {
158
+ const param = nextParam(options, previous);
159
+ if (param == null) {
160
+ return previous;
161
+ }
162
+ const page = await askFor(param, "forward");
163
+ return trim(
164
+ { pages: [...previous.pages, page], pageParams: [...previous.pageParams, param] },
165
+ options.maxPages,
166
+ "forward",
167
+ );
168
+ }
169
+
170
+ if (direction === "backward") {
171
+ const param = previousParam(options, previous);
172
+ if (param == null) {
173
+ return previous;
174
+ }
175
+ const page = await askFor(param, "backward");
176
+ return trim(
177
+ { pages: [page, ...previous.pages], pageParams: [param, ...previous.pageParams] },
178
+ options.maxPages,
179
+ "backward",
180
+ );
181
+ }
182
+
183
+ // No direction: refresh what is on screen, page by page, in order.
184
+ const pages: Array<TPage> = [];
185
+ for (const pageParam of previous.pageParams) {
186
+ pages.push(await askFor(pageParam, "forward"));
187
+ }
188
+ return { pages, pageParams: previous.pageParams.slice() };
189
+ };
190
+ }
191
+
192
+ /** Whether the server said there is another page after the ones held. */
193
+ export function hasMore<TPage, TParam>(
194
+ options: InfiniteQueryOptions<TPage, TParam, mixed>,
195
+ data: InfiniteData<TPage, TParam> | void,
196
+ towards: FetchDirection,
197
+ ): boolean {
198
+ if (data == null || data.pages.length === 0) {
199
+ return false;
200
+ }
201
+ return (towards === "forward" ? nextParam(options, data) : previousParam(options, data)) != null;
202
+ }
203
+
204
+ /**
205
+ * The observer for a paged query.
206
+ *
207
+ * Everything about watching, fetching, retrying and snapshotting is inherited;
208
+ * the two differences are that the entry is filled a page at a time and that
209
+ * the snapshot carries the controls for the two ends of the list.
210
+ */
211
+ export class InfiniteQueryObserver<TPage, TParam, TSelected> extends QueryObserver<
212
+ InfiniteData<TPage, TParam>,
213
+ TSelected,
214
+ > {
215
+ readonly fetchNextPage: () => Promise<void>;
216
+ readonly fetchPreviousPage: () => Promise<void>;
217
+
218
+ constructor(
219
+ client: $FlowFixMe,
220
+ getOptions: () => InfiniteQueryOptions<TPage, TParam, TSelected>,
221
+ ) {
222
+ super(client, getOptions as $FlowFixMe);
223
+ // Stable for the observer's life: a snapshot that carried a new function
224
+ // every render would never compare equal to the one before it, and every
225
+ // notification would become a re-render.
226
+ // `cancelRefetch: true`, because a page control is a request for something
227
+ // the entry does not have yet. Joining whatever is in flight would return
228
+ // the refetch's answer and add no page at all — the directional fetcher
229
+ // would never run — so pressing "load more" while a background refetch was
230
+ // going did nothing, silently.
231
+ this.fetchNextPage = () => this.fetch({ cancelRefetch: true, direction: "forward" });
232
+ this.fetchPreviousPage = () => this.fetch({ cancelRefetch: true, direction: "backward" });
233
+ }
234
+
235
+ pageOptions(): InfiniteQueryOptions<TPage, TParam, mixed> {
236
+ return this.getOptions() as $FlowFixMe;
237
+ }
238
+
239
+ buildFetcher(
240
+ _options: ResolvedQueryOptions<InfiniteData<TPage, TParam>, TSelected>,
241
+ direction: FetchDirection | null,
242
+ ): Fetcher<InfiniteData<TPage, TParam>> {
243
+ return infinitePages(this.pageOptions(), direction);
244
+ }
245
+
246
+ // $FlowFixMe[incompatible-extend] the paged snapshot is the base snapshot plus
247
+ // the page controls. Flow has no way to state "the same exact object with
248
+ // more fields" for an override, and widening the base result to an inexact
249
+ // type to allow it would stop catching typos in every ordinary query.
250
+ buildResult(
251
+ query: Query<InfiniteData<TPage, TParam>> | void,
252
+ state: QueryState<InfiniteData<TPage, TParam>>,
253
+ options: ResolvedQueryOptions<InfiniteData<TPage, TParam>, TSelected>,
254
+ ): InfiniteQueryResult<TSelected> {
255
+ const base = super.buildResult(query, state, options);
256
+ const pages = this.pageOptions();
257
+ // Asked of the raw pages rather than of `base.data`, which may have been
258
+ // narrowed by `select` into something with no pages in it at all.
259
+ const data = state.data;
260
+ const isFetching = state.fetchStatus === "fetching";
261
+ return {
262
+ ...base,
263
+ hasNextPage: hasMore(pages, data, "forward"),
264
+ hasPreviousPage: hasMore(pages, data, "backward"),
265
+ isFetchingNextPage: isFetching && state.direction === "forward",
266
+ isFetchingPreviousPage: isFetching && state.direction === "backward",
267
+ fetchNextPage: this.fetchNextPage,
268
+ fetchPreviousPage: this.fetchPreviousPage,
269
+ };
270
+ }
271
+ }
272
+
273
+ function nextParam<TPage, TParam>(
274
+ options: InfiniteQueryOptions<TPage, TParam, mixed>,
275
+ data: InfiniteData<TPage, TParam>,
276
+ ): TParam | null | void {
277
+ const index = data.pages.length - 1;
278
+ return options.getNextPageParam(
279
+ data.pages[index],
280
+ data.pages,
281
+ data.pageParams[index],
282
+ data.pageParams,
283
+ );
284
+ }
285
+
286
+ function previousParam<TPage, TParam>(
287
+ options: InfiniteQueryOptions<TPage, TParam, mixed>,
288
+ data: InfiniteData<TPage, TParam>,
289
+ ): TParam | null | void {
290
+ const get = options.getPreviousPageParam;
291
+ if (get == null) {
292
+ return null;
293
+ }
294
+ return get(data.pages[0], data.pages, data.pageParams[0], data.pageParams);
295
+ }
296
+
297
+ /** Keep at most `maxPages`, dropping from the end the reader is moving away from. */
298
+ function trim<TPage, TParam>(
299
+ data: InfiniteData<TPage, TParam>,
300
+ maxPages: number | void,
301
+ direction: FetchDirection,
302
+ ): InfiniteData<TPage, TParam> {
303
+ if (maxPages == null || maxPages <= 0 || data.pages.length <= maxPages) {
304
+ return data;
305
+ }
306
+ const from = direction === "forward" ? data.pages.length - maxPages : 0;
307
+ return {
308
+ pages: data.pages.slice(from, from + maxPages),
309
+ pageParams: data.pageParams.slice(from, from + maxPages),
310
+ };
311
+ }
package/key.js ADDED
@@ -0,0 +1,124 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/query/key`: what makes two requests the same request.
4
+ //
5
+ // Everything else in this package is downstream of one decision: when two
6
+ // components ask for `["user", 1]`, is that one entry or two? The answer has
7
+ // to be a *value* comparison — the arrays are written inline in two different
8
+ // files and will never be the same object — so a key is reduced to a string
9
+ // and the string is the identity.
10
+ //
11
+ // # Why the hash is order-stable
12
+ //
13
+ // `JSON.stringify` of the array would be enough if every caller wrote object
14
+ // members in the same order. They do not: `["users", {page: 1, size: 20}]` and
15
+ // `["users", {size: 20, page: 1}]` are the same request written by two people,
16
+ // and hashing them apart means two cache entries, two requests, and two
17
+ // answers that can disagree on screen. So object keys are sorted as the walk
18
+ // serialises them.
19
+ //
20
+ // Arrays are *not* sorted — order is meaning there — and neither is anything
21
+ // else. A `Date` or a class instance in a key serialises through its own
22
+ // `toJSON`, which is a reasonable default and a bad idea to rely on; keys
23
+ // should be built from strings, numbers and plain records.
24
+ //
25
+ // # Why `1` and `"1"` are different keys
26
+ //
27
+ // They come from different code paths — a route parameter is a string, a
28
+ // database id is a number — and treating them as one entry means a component
29
+ // reading `["user", 1]` is shown the answer fetched for `["user", "1"]`. That
30
+ // is a bug that surfaces as *the wrong data*, never as an exception, which is
31
+ // the worst failure mode a cache has. `JSON.stringify` keeps them apart for
32
+ // free, so the cheap behaviour is also the correct one.
33
+ //
34
+ // # Why matching is by prefix
35
+ //
36
+ // Invalidation is written as `["users"]` and has to reach `["users", 1]` and
37
+ // `["users", 2]` without the caller enumerating them, because after creating a
38
+ // user the caller does not know which ids are cached. [`matchesKey`] is that
39
+ // rule, and it is structural rather than a string `startsWith` on the hash:
40
+ // the string form has no idea where one member ends and the next begins, so
41
+ // `["user"]` would match `["users", 1]` for a large enough coincidence of
42
+ // punctuation. Comparing the arrays cannot make that mistake.
43
+
44
+ import { isPlainObject } from "./structural.js";
45
+
46
+ /** A key, as a caller writes it: `["user", id]`. */
47
+ export type QueryKey = $ReadOnlyArray<mixed>;
48
+
49
+ /**
50
+ * A key as a string, stable across member order inside object members.
51
+ *
52
+ * Two keys with the same hash are the same request by definition. That is the
53
+ * contract every other module in this package relies on, including the one
54
+ * that decides a component does not need to resubscribe.
55
+ */
56
+ export function hashKey(key: QueryKey): string {
57
+ return JSON.stringify(key, (_name, value) =>
58
+ isPlainObject(value)
59
+ ? Object.keys(value)
60
+ .sort()
61
+ .reduce((sorted: $FlowFixMe, name: string) => {
62
+ sorted[name] = (value as $FlowFixMe)[name];
63
+ return sorted;
64
+ }, {})
65
+ : value,
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Whether `key` is described by `pattern`.
71
+ *
72
+ * By default `pattern` is a prefix: `["users"]` matches `["users", 1]`, and
73
+ * `["users", {done: true}]` matches `["users", {done: true, page: 2}]` —
74
+ * object members match partially, so a filter can name the parts of a
75
+ * parameter record it cares about and ignore the rest.
76
+ *
77
+ * With `exact`, the two must hash identically. That is the option for "this
78
+ * one entry and not the list it belongs to", which is the difference between
79
+ * refreshing a row and refetching a table.
80
+ */
81
+ export function matchesKey(key: QueryKey, pattern: QueryKey, exact: boolean = false): boolean {
82
+ if (exact) {
83
+ return hashKey(key) === hashKey(pattern);
84
+ }
85
+ if (pattern.length > key.length) {
86
+ return false;
87
+ }
88
+ for (let index = 0; index < pattern.length; index += 1) {
89
+ if (!partiallyMatches(key[index], pattern[index])) {
90
+ return false;
91
+ }
92
+ }
93
+ return true;
94
+ }
95
+
96
+ /**
97
+ * Whether `value` satisfies everything `pattern` states about it.
98
+ *
99
+ * Object patterns are partial — they constrain the members they name — while
100
+ * arrays must line up member for member. The asymmetry is deliberate: a record
101
+ * in a key is a bag of parameters and naming one of them is a useful filter,
102
+ * whereas a shorter array is a different list, not a laxer description of one.
103
+ */
104
+ function partiallyMatches(value: mixed, pattern: mixed): boolean {
105
+ if (Object.is(value, pattern)) {
106
+ return true;
107
+ }
108
+ if (Array.isArray(pattern)) {
109
+ if (!Array.isArray(value) || value.length !== pattern.length) {
110
+ return false;
111
+ }
112
+ return pattern.every((member, index) => partiallyMatches(value[index], member));
113
+ }
114
+ if (isPlainObject(pattern)) {
115
+ if (!isPlainObject(value)) {
116
+ return false;
117
+ }
118
+ const record = value as $FlowFixMe;
119
+ return Object.keys(pattern as $FlowFixMe).every((name) =>
120
+ partiallyMatches(record[name], (pattern as $FlowFixMe)[name]),
121
+ );
122
+ }
123
+ return false;
124
+ }
package/mutation.js ADDED
@@ -0,0 +1,249 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/query/mutation`: the write, and the lie you tell before it.
4
+ //
5
+ // A mutation is not a query with a different verb. A query can be repeated,
6
+ // abandoned, de-duplicated and garbage-collected because asking twice is free;
7
+ // none of that is true of "create the invoice". So this module shares the
8
+ // retry loop with queries and nothing else: no cache entry, no de-duplication,
9
+ // no staleness, and — deliberately — no cancellation.
10
+ //
11
+ // # Why an unmount does not cancel a mutation
12
+ //
13
+ // A request that has left the building cannot be un-sent. Aborting it would
14
+ // stop the *answer* arriving, not the write happening, and the application
15
+ // would be left unable to say whether the invoice exists. So a mutation runs
16
+ // to completion and its callbacks fire even if the component that started it
17
+ // has gone. The callbacks are where cache updates and invalidations live,
18
+ // which is exactly the work that still needs doing when the reader has moved
19
+ // on.
20
+ //
21
+ // # Why `onMutate` returns a context and `onError` is given it
22
+ //
23
+ // Optimistic updates are the reason this API has a shape at all. The sequence
24
+ // is fixed and each step exists because of a specific failure:
25
+ //
26
+ // ```js
27
+ // onMutate: async (next) => {
28
+ // // A refetch already in flight would land *after* the optimistic write and
29
+ // // put the server's old answer back on screen. Stop it first.
30
+ // await client.cancelQueries({ queryKey: ["todos"] });
31
+ // const previous = client.getQueryData(["todos"]);
32
+ // client.setQueryData(["todos"], (todos) => [...todos, next]);
33
+ // return { previous }; // ← the rollback, captured before the guess
34
+ // },
35
+ // onError: (_error, _next, context) => {
36
+ // client.setQueryData(["todos"], context.previous);
37
+ // },
38
+ // onSettled: () => client.invalidateQueries({ queryKey: ["todos"] }),
39
+ // ```
40
+ //
41
+ // The context has to be produced by `onMutate` and handed back to `onError`
42
+ // because nothing else can hold it: a `useRef` in the component is gone if the
43
+ // component unmounted, and a variable in the caller's closure belongs to one
44
+ // call and two mutations can be in flight at once. Passing it through the
45
+ // mutation itself is what makes the rollback correct for *this* call.
46
+ //
47
+ // `onError` runs before the state becomes `error`, and `onSettled` before
48
+ // either terminal state, so by the time a component is told the mutation
49
+ // failed, the rollback has already happened — the reader never sees the
50
+ // optimistic value and the failure message at the same time.
51
+ //
52
+ // # Why the state is an external store rather than `useState`
53
+ //
54
+ // So a snapshot is one immutable object with a stable identity, comparable by
55
+ // `Object.is`, which is what `useSyncExternalStore` requires and what keeps a
56
+ // re-render from being scheduled for a mutation that has not moved. It also
57
+ // means the state machine is testable without rendering anything.
58
+
59
+ import { asError, runWithRetry } from "./retry.js";
60
+ import type { RetryDelay, RetryPolicy } from "./retry.js";
61
+
62
+ export type MutationStatus = "idle" | "pending" | "success" | "error";
63
+
64
+ export type MutationState<TVariables, TData> = {|
65
+ readonly status: MutationStatus,
66
+ readonly data: TData | void,
67
+ readonly error: Error | null,
68
+ /** What the last call was given, so a retry button can repeat it. */
69
+ readonly variables: TVariables | void,
70
+ readonly failureCount: number,
71
+ readonly submittedAt: number,
72
+ |};
73
+
74
+ /**
75
+ * The callbacks a single call may add.
76
+ *
77
+ * Both sets run — the ones on the hook and the ones on the call — with the
78
+ * hook's first. The hook's are the ones that keep the cache correct and must
79
+ * not be skippable by a caller who only wanted a toast.
80
+ */
81
+ export type MutationCallbacks<TVariables, TData, TContext> = {|
82
+ readonly onSuccess?: (data: TData, variables: TVariables, context: TContext | void) => mixed,
83
+ readonly onError?: (error: Error, variables: TVariables, context: TContext | void) => mixed,
84
+ readonly onSettled?: (
85
+ data: TData | void,
86
+ error: Error | null,
87
+ variables: TVariables,
88
+ context: TContext | void,
89
+ ) => mixed,
90
+ |};
91
+
92
+ export type MutationOptions<TVariables, TData, TContext> = {|
93
+ readonly mutationFn: (variables: TVariables) => Promise<TData>,
94
+ /** Runs first; what it returns is handed to the other three. */
95
+ readonly onMutate?: (variables: TVariables) => Promise<TContext | void> | TContext | void,
96
+ readonly onSuccess?: (data: TData, variables: TVariables, context: TContext | void) => mixed,
97
+ readonly onError?: (error: Error, variables: TVariables, context: TContext | void) => mixed,
98
+ readonly onSettled?: (
99
+ data: TData | void,
100
+ error: Error | null,
101
+ variables: TVariables,
102
+ context: TContext | void,
103
+ ) => mixed,
104
+ readonly retry?: RetryPolicy,
105
+ readonly retryDelay?: RetryDelay,
106
+ |};
107
+
108
+ /** The same options with the client's defaults filled in. */
109
+ export type ResolvedMutationOptions<TVariables, TData, TContext> = {|
110
+ ...MutationOptions<TVariables, TData, TContext>,
111
+ readonly retry: RetryPolicy,
112
+ readonly retryDelay: RetryDelay,
113
+ |};
114
+
115
+ /** What a mutation looks like to a component. */
116
+ export type MutationResult<TVariables, TData, TContext> = {|
117
+ readonly data: TData | void,
118
+ readonly error: Error | null,
119
+ readonly status: MutationStatus,
120
+ readonly variables: TVariables | void,
121
+ readonly failureCount: number,
122
+ readonly isIdle: boolean,
123
+ readonly isPending: boolean,
124
+ readonly isSuccess: boolean,
125
+ readonly isError: boolean,
126
+ /** Fire and forget. The failure is in `error`, not in a rejected promise. */
127
+ readonly mutate: (
128
+ variables: TVariables,
129
+ callbacks?: MutationCallbacks<TVariables, TData, TContext>,
130
+ ) => void,
131
+ /** The same call, awaited. Rejects, so a caller can branch on the failure. */
132
+ readonly mutateAsync: (
133
+ variables: TVariables,
134
+ callbacks?: MutationCallbacks<TVariables, TData, TContext>,
135
+ ) => Promise<TData>,
136
+ readonly reset: () => void,
137
+ |};
138
+
139
+ const IDLE: MutationState<empty, empty> = Object.freeze({
140
+ status: "idle",
141
+ data: undefined,
142
+ error: null,
143
+ variables: undefined,
144
+ failureCount: 0,
145
+ submittedAt: 0,
146
+ });
147
+
148
+ export class Mutation<TVariables, TData, TContext> {
149
+ state: MutationState<TVariables, TData> = IDLE;
150
+ listeners: Set<() => void> = new Set();
151
+
152
+ /**
153
+ * Which call owns the state.
154
+ *
155
+ * Two calls can be in flight — a reader who clicked twice — and the state
156
+ * describes the latest, not whichever settled last. The callbacks of both
157
+ * still run: each of them was a real write with real consequences, and the
158
+ * cache updates in them are not the loser's to skip.
159
+ */
160
+ runId: number = 0;
161
+
162
+ subscribe(listener: () => void): () => void {
163
+ this.listeners.add(listener);
164
+ return () => {
165
+ this.listeners.delete(listener);
166
+ };
167
+ }
168
+
169
+ /** Back to idle, forgetting the last result. */
170
+ reset(): void {
171
+ this.runId += 1;
172
+ this.setState(IDLE as $FlowFixMe);
173
+ }
174
+
175
+ async execute(
176
+ variables: TVariables,
177
+ options: ResolvedMutationOptions<TVariables, TData, TContext>,
178
+ callbacks?: MutationCallbacks<TVariables, TData, TContext>,
179
+ ): Promise<TData> {
180
+ const id = this.runId + 1;
181
+ this.runId = id;
182
+ this.setState({
183
+ status: "pending",
184
+ data: undefined,
185
+ error: null,
186
+ variables,
187
+ failureCount: 0,
188
+ submittedAt: Date.now(),
189
+ });
190
+
191
+ // Declared outside the `try` so the rollback in `onError` can still be
192
+ // given whatever `onMutate` managed to record before things went wrong.
193
+ let context: TContext | void;
194
+ try {
195
+ context = await options.onMutate?.(variables);
196
+ const data = await runWithRetry({
197
+ attempt: () => options.mutationFn(variables),
198
+ retry: options.retry,
199
+ retryDelay: options.retryDelay,
200
+ // Never aborted: see the module docs on why a write is not cancelled.
201
+ signal: new AbortController().signal,
202
+ onFailure: (failureCount) => {
203
+ if (this.runId === id) {
204
+ this.setState({ failureCount });
205
+ }
206
+ },
207
+ });
208
+
209
+ // Before the state moves to `success`, so a component told the mutation
210
+ // succeeded is looking at a cache that already knows.
211
+ await options.onSuccess?.(data, variables, context);
212
+ await callbacks?.onSuccess?.(data, variables, context);
213
+ await options.onSettled?.(data, null, variables, context);
214
+ await callbacks?.onSettled?.(data, null, variables, context);
215
+
216
+ if (this.runId === id) {
217
+ this.setState({ status: "success", data, error: null });
218
+ }
219
+ return data;
220
+ } catch (thrown) {
221
+ const error = asError(thrown);
222
+ try {
223
+ await options.onError?.(error, variables, context);
224
+ await callbacks?.onError?.(error, variables, context);
225
+ await options.onSettled?.(undefined, error, variables, context);
226
+ await callbacks?.onSettled?.(undefined, error, variables, context);
227
+ } finally {
228
+ // Committed whatever the callbacks did. A callback that throws is the
229
+ // caller's bug and still reaches them — it comes out of `execute` in
230
+ // place of the rethrow below — but a mutation left `pending` for ever
231
+ // because a listener threw would be this module's bug, and the
232
+ // component showing a spinner has no way back from it.
233
+ if (this.runId === id) {
234
+ this.setState({ status: "error", error, data: undefined });
235
+ }
236
+ }
237
+ // Rethrown so `mutateAsync` can be branched on. `mutate` swallows it,
238
+ // which is why the two exist.
239
+ throw error;
240
+ }
241
+ }
242
+
243
+ setState(patch: { +[string]: mixed }): void {
244
+ this.state = { ...this.state, ...patch } as $FlowFixMe;
245
+ for (const listener of Array.from(this.listeners)) {
246
+ listener();
247
+ }
248
+ }
249
+ }