Skip to content

Commit a3505b4

Browse files
committed
idempotent optimistic updates and undos
1 parent c500077 commit a3505b4

File tree

3 files changed

+104
-42
lines changed

3 files changed

+104
-42
lines changed

examples/vite/src/App.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ const deletePostMutation = builder.withMethod('delete').withPath('/posts/:id').w
8383
});
8484

8585
function App() {
86+
const [enablePrefetch, setEnablePrefetch] = useState(false);
8687
const [postId, setPostId] = useState<number | null>(null);
8788

8889
const posts = postsQuery.useInfiniteQuery({}, { enabled: postId != null });
@@ -104,26 +105,26 @@ function App() {
104105
Reset
105106
</button>
106107

108+
<label>
109+
<input type="checkbox" onChange={(e) => setEnablePrefetch(e.target.checked)} checked={enablePrefetch} />
110+
Enable prefetch
111+
</label>
112+
107113
{posts.isLoading
108114
? 'Loading...'
109115
: posts.isError
110116
? posts.error.message
111117
: posts.data?.pages?.flat().map((post) => (
112118
<div key={post.id}>
113119
<a>
114-
<h2
115-
onClick={() => setPostId(post.id)}
116-
onMouseOver={() => {
117-
postQuery.client.prefetch({ id: post.id });
118-
}}
119-
>
120-
{post.title}
120+
<h2 onClick={() => setPostId(post.id)} onMouseOver={() => enablePrefetch && postQuery.client.prefetch({ id: post.id })}>
121+
{post.id} - {post.title}
121122
</h2>
122123
</a>
123124

124125
<button
125126
onClick={() => deletePostMutation.client.mutate({ params: { id: post.id } })}
126-
disabled={deletePostMutation.client.isMutating() > 0}
127+
// disabled={deletePostMutation.client.isMutating() > 0}
127128
>
128129
Delete
129130
</button>

src/builder/createUpdateMiddleware.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { operateOnTags } from '../tags/operateOnTags';
22
import { resolveTags } from '../tags/resolveTags';
33
import type { QueryTagContext, QueryTagOption, QueryUpdateTagObject } from '../tags/types';
4-
import { type UpdateTagsUndoer, undoUpdateTags, updateTags } from '../tags/updateTags';
4+
import { type UpdateTagsUndoer, updateTags } from '../tags/updateTags';
55
import type { MiddlewareFn } from './createMiddlewareFunction';
66

77
type CreateUpdateMiddleware = <TVars, TData, TError, TKey extends unknown[], TTags extends Record<string, unknown>>(
@@ -15,46 +15,54 @@ export const createUpdateMiddleware: CreateUpdateMiddleware = (tags) =>
1515
type TagObj = QueryUpdateTagObject<any, any, any, any>;
1616
type TagCtx = QueryTagContext<any>;
1717

18-
let undos: UpdateTagsUndoer[] | null = null;
18+
const undos: UpdateTagsUndoer[] = [];
1919
const invalidates: TagObj[] = [];
2020
const preCtx: TagCtx = { client: ctx.client, vars: ctx.vars, data: undefined };
2121

2222
try {
2323
const preUpdates = resolveTags({ tags, ...preCtx }).filter((u) => u.optimistic);
24-
undos = updateTags({
24+
const preUndos = updateTags({
2525
queryClient: ctx.client,
2626
tags: preUpdates.filter((x) => x.updater),
2727
ctx: preCtx,
2828
optimistic: true,
2929
});
30+
3031
invalidates.push(...preUpdates);
32+
preUndos.forEach((undo) => undo.subscribe());
33+
undos.push(...preUndos);
3134

32-
const optToInvalidate = preUpdates.filter((tag) => ['pre', 'both'].includes(tag.invalidate || 'both'));
33-
operateOnTags({ queryClient: ctx.client, tags: optToInvalidate }, { refetchType: 'none' });
35+
const preInvalidate = preUpdates.filter((tag) => ['pre', 'both'].includes(tag.invalidate || 'both'));
36+
operateOnTags({ queryClient: ctx.client, tags: preInvalidate }, { refetchType: 'none' });
3437

3538
const data = await next(ctx);
3639

3740
const postCtx: TagCtx = { ...preCtx, data };
3841
const postUpdates = resolveTags({ tags, ...postCtx }).filter((u) => !u.optimistic);
39-
updateTags({ queryClient: ctx.client, tags: postUpdates.filter((x) => x.updater), ctx: postCtx });
42+
const postUndos = updateTags({ queryClient: ctx.client, tags: postUpdates.filter((x) => x.updater), ctx: postCtx });
43+
4044
invalidates.push(...postUpdates);
45+
postUndos.forEach((undo) => undo.subscribe());
46+
undos.push(...postUndos);
4147

4248
return data;
4349
} catch (error) {
44-
if (undos?.length) undoUpdateTags(undos, ctx.client);
50+
if (undos?.length) undos.forEach((undo) => undo.undo());
4551

4652
const postCtx: TagCtx = { ...preCtx, error };
4753
const postUpdates = resolveTags({ tags, ...postCtx }).filter((u) => !u.optimistic);
4854
invalidates.push(...postUpdates);
4955

5056
throw error;
5157
} finally {
52-
const tagsToInvalidate = invalidates.filter((tag) => ['post', 'both'].includes(tag.invalidate || 'both'));
58+
const finalInvalidates = invalidates.filter((tag) => ['post', 'both'].includes(tag.invalidate || 'both'));
5359

54-
operateOnTags({ tags: tagsToInvalidate, queryClient: ctx.client });
60+
operateOnTags({ tags: finalInvalidates, queryClient: ctx.client }).finally(() => {
61+
if (undos?.length) undos.forEach((undo) => undo.dispose());
62+
});
5563

5664
if (config.syncChannel) {
57-
const tagsToSync = tagsToInvalidate.map(({ type, id }) => ({ type, id }));
65+
const tagsToSync = finalInvalidates.map(({ type, id }) => ({ type, id }));
5866
config.syncChannel.postMessage({ type: 'invalidate', data: tagsToSync });
5967
}
6068
}

src/tags/updateTags.ts

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1-
import { type InfiniteData, InfiniteQueryObserver, type QueryClient, type QueryState } from '@tanstack/react-query';
1+
import { type InfiniteData, InfiniteQueryObserver, type QueryClient, QueryObserver, type QueryState } from '@tanstack/react-query';
22
import { queryMatchesTag } from './operateOnTags';
33
import type { QueryTagContext, QueryUpdateTag } from './types';
44
import { getUpdater } from './updaters';
55

6-
export type UpdateTagsUndoer = { hash: string; data: unknown };
6+
export type UpdateTagsUndoer = {
7+
hash: string;
8+
data: unknown;
9+
newData: unknown;
10+
subscribe(): void;
11+
dispose(): void;
12+
undo(): void;
13+
};
714

815
/**
916
* Works similar to invalidateTags, but instead of invalidating queries, it updates them with the provided updater or the data resulting from the mutation.
@@ -43,36 +50,82 @@ export function updateTags({
4350
const willInvalidate = typeof tag !== 'object' || ['post', 'both'].includes(tag.invalidate || 'both');
4451

4552
for (const q of list) {
46-
undos.push({ hash: q.queryHash, data: q.state.data });
47-
48-
let newData: unknown;
49-
if (q.observers[0] && q.observers[0] instanceof InfiniteQueryObserver) {
50-
const data = q.state.data as InfiniteData<unknown>;
51-
if (data.pages && Array.isArray(data.pages)) {
52-
newData = {
53-
...data,
54-
pages: data.pages.map((page) => updaterFn(ctx, page)),
55-
} as InfiniteData<unknown>;
53+
let isInfinite = false;
54+
55+
function getNewData() {
56+
if (!updaterFn) return undefined;
57+
58+
let newData: unknown;
59+
if (q.observers[0] && q.observers[0] instanceof InfiniteQueryObserver) {
60+
isInfinite = true;
61+
const data = q.state.data as InfiniteData<unknown>;
62+
if (data.pages && Array.isArray(data.pages)) {
63+
newData = {
64+
...data,
65+
pages: data.pages.map((page) => updaterFn(ctx, page)),
66+
} as InfiniteData<unknown>;
67+
}
68+
} else {
69+
newData = updaterFn(ctx, q.state.data);
5670
}
57-
} else {
58-
newData = updaterFn(ctx, q.state.data);
71+
72+
return newData;
5973
}
6074

61-
setDataToExistingQuery(queryClient, q.queryHash, newData, willInvalidate ? { isInvalidated: true } : undefined, {
62-
updated: optimistic ? 'optimistic' : 'pessimistic',
63-
});
75+
const newData = getNewData();
76+
77+
let observer: QueryObserver<any, any> | InfiniteQueryObserver<any, any> | null = null;
78+
79+
const updateType = optimistic ? ('optimistic' as const) : ('pessimistic' as const);
80+
const meta = { updated: updateType };
81+
82+
let subscribePaused = false;
83+
const undoObj: UpdateTagsUndoer = {
84+
hash: q.queryHash,
85+
data: q.state.data,
86+
newData,
87+
subscribe: () => {
88+
subscribePaused = false;
89+
observer = isInfinite
90+
? new InfiniteQueryObserver(queryClient, { ...(q.options as any), enabled: false })
91+
: new QueryObserver(queryClient, { queryKey: q.queryKey, enabled: false });
92+
observer.trackProp('data');
93+
94+
q.addObserver(observer);
95+
observer.subscribe((ev) => {
96+
if (subscribePaused) return;
97+
98+
const newData = getNewData();
99+
undoObj.newData = newData;
100+
101+
subscribePaused = true;
102+
setDataToExistingQuery(queryClient, undoObj.hash, newData, willInvalidate ? { isInvalidated: true } : undefined, meta);
103+
subscribePaused = false;
104+
});
105+
},
106+
dispose: () => {
107+
if (!observer) return;
108+
q.removeObserver(observer);
109+
observer.destroy();
110+
observer = null;
111+
subscribePaused = true;
112+
},
113+
undo: () => {
114+
undoObj.dispose();
115+
setDataToExistingQuery(queryClient, undoObj.hash, undoObj.data, undefined, { updated: 'undone' });
116+
},
117+
};
118+
undos.push(undoObj);
119+
120+
subscribePaused = true;
121+
setDataToExistingQuery(queryClient, undoObj.hash, newData, willInvalidate ? { isInvalidated: true } : undefined, meta);
122+
subscribePaused = false;
64123
}
65124
}
66125

67126
return undos;
68127
}
69128

70-
export function undoUpdateTags(undos: UpdateTagsUndoer[], queryClient: QueryClient) {
71-
for (const { hash, data } of undos) {
72-
setDataToExistingQuery(queryClient, hash, data, {}, { updated: 'undone' });
73-
}
74-
}
75-
76129
/**
77130
* The `setQueryData` method of react-query does not take query hash fn into account.
78131
* It creates duplicate queries even though the query key is the same.
@@ -88,6 +141,6 @@ function setDataToExistingQuery(
88141
},
89142
) {
90143
const query = queryClient.getQueryCache().get(hash);
91-
query?.setData(newData);
144+
query?.setData(newData, { manual: true });
92145
if (state || meta) query?.setState(state || {}, { meta });
93146
}

0 commit comments

Comments
 (0)