Skip to content

Commit feca839

Browse files
committed
add tags and updates based on middlewares
1 parent 3b4b7ba commit feca839

File tree

10 files changed

+178
-116
lines changed

10 files changed

+178
-116
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ It uses the builder pattern, the best pattern that works with complex Typescript
3333

3434
- Strong typed customizable tags
3535
- Middlewares on builder
36-
- Context on builder
36+
- Tags and updates as middlewares - withUpdates, withTags
37+
- Predefined update functions - clear, merge, replace, insert-by-id, delete-by-id, update-by-id, upsert-by-id
3738

3839
## Examples
3940

examples/vite/src/App.tsx

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,10 @@ import { queryClient } from './client';
88
const baseQuery = new HttpQueryBuilder({ queryClient }).withBaseUrl(baseUrl);
99
const baseMutation = baseQuery.asMutationBuilder();
1010

11-
const resetMutation = baseMutation.withPath('/reset').withConfig({ invalidates: '*' });
11+
const resetMutation = baseMutation.withPath('/reset').withUpdates('*');
1212

1313
const postsQuery = baseQuery
14-
.withConfig({ tags: 'refreshable' })
15-
.withConfig({ tags: { type: 'posts' as any, id: 'LIST' } })
14+
.withTags('refreshable', { type: 'posts' as any, id: 'LIST' })
1615
.withPath('/posts')
1716
.withData<PostData[]>();
1817

@@ -21,7 +20,7 @@ const postQuery = baseQuery
2120
.withPath('/posts/:id')
2221
.withData<PostData>()
2322
.withConfig({
24-
tags: (ctx) => [{ type: 'posts' as any, id: ctx.vars.params.id }],
23+
tags: (ctx) => [{ type: 'posts' as any, id: ctx.data.id }],
2524
})
2625
.withMiddleware(async (ctx, next) => {
2726
const res = await next(ctx);
@@ -42,29 +41,31 @@ const editPostMutation = baseMutation
4241
.withPath('/posts/:id')
4342
.withVars({ method: 'put' })
4443
.withBody<Partial<PostData>>()
45-
.withConfig({
46-
invalidates: () => [{ type: 'posts' as any, id: 'LIST' }],
47-
optimisticUpdates: (ctx) => [
48-
{
49-
type: 'posts' as any,
50-
id: ctx.vars.params.id,
51-
updater(ctx, target) {
52-
return { ...target!, ...ctx.vars.body };
53-
},
54-
},
55-
],
44+
.withUpdates({ type: 'posts' as any, id: 'LIST' }, (ctx) => ({
45+
type: 'posts' as any,
46+
id: ctx.vars.params.id,
47+
optimistic: true,
48+
updater(ctx, target) {
49+
return { ...target!, ...(ctx.vars as any).body };
50+
},
51+
}))
52+
.withMiddleware(async (ctx, next) => {
53+
const res = await next({
54+
...ctx,
55+
vars: { ...ctx.vars, body: { ...ctx.vars.body, body: `${ctx.vars.body.body} \n Last updated ${new Date()}` } },
56+
});
57+
return res;
5658
});
5759

5860
const deletePostMutation = baseMutation
5961
.withVars({ method: 'delete' })
6062
.withPath('/posts/:id')
61-
.withConfig({
62-
optimisticUpdates: {
63-
type: 'posts' as any,
64-
id: 'LIST',
65-
updater(ctx, target) {
66-
return (target as PostData[]).filter((post) => post.id !== ctx.vars.params.id);
67-
},
63+
.withUpdates({
64+
type: 'posts' as any,
65+
id: 'LIST',
66+
optimistic: true,
67+
updater(ctx, target) {
68+
return (target as PostData[]).filter((post) => post.id !== (ctx.vars as any).params.id);
6869
},
6970
});
7071

src/builder/MutationBuilder.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import {
1313
useQueryClient,
1414
} from '@tanstack/react-query';
1515
import { mergeTagOptions } from '../tags/mergeTagOptions';
16-
import { QueryInvalidatesMetadata } from '../tags/types';
16+
import { QueryInvalidatesMetadata, QueryTagOption, QueryUpdateTagObject } from '../tags/types';
1717
import { QueryBuilder } from './QueryBuilder';
1818
import { MiddlewareFn, applyMiddleware } from './middlewares';
1919
import { BuilderMergeVarsFn, BuilderQueryFn, SetAllTypes, SetDataType, SetErrorType } from './types';
2020
import { AppendVarsType, BuilderTypeTemplate } from './types';
21+
import { createUpdateMiddleware } from './updates';
2122
import { areKeysEqual, mergeMutationOptions, mergeVars } from './utils';
2223

2324
function getRandomKey() {
@@ -105,7 +106,7 @@ export class MutationBuilderFrozen<T extends BuilderTypeTemplate> {
105106
useMutation: (
106107
opts?: MutationBuilderConfig<T>['options'],
107108
) => ReturnType<typeof useMutation<T['data'], T['error'], T['vars']>> = (opts) => {
108-
const queryClient = useQueryClient();
109+
const queryClient = useQueryClient(this.config.queryClient);
109110
return useMutation(this.getMutationOptions(queryClient, opts), this.config.queryClient);
110111
};
111112

@@ -189,6 +190,10 @@ export class MutationBuilder<T extends BuilderTypeTemplate = BuilderTypeTemplate
189190
return newBuilder.withConfig({ queryFn: applyMiddleware(this.config.queryFn, middleware) });
190191
}
191192

193+
withUpdates(...tags: QueryTagOption<T['vars'], T['data'], T['error'], QueryUpdateTagObject>[]): this {
194+
return this.withMiddleware(createUpdateMiddleware<T>(tags)) as unknown as this;
195+
}
196+
192197
freeze(): MutationBuilderFrozen<T> {
193198
return this;
194199
}

src/builder/QueryBuilder.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { QueryTagOption } from '../tags/types';
2121
import { FunctionType } from '../types/utils';
2222
import { MutationBuilder } from './MutationBuilder';
2323
import { MiddlewareFn, applyMiddleware } from './middlewares';
24+
import { createTagMiddleware } from './tags';
2425
import {
2526
BuilderMergeVarsFn,
2627
BuilderQueriesResult,
@@ -223,6 +224,10 @@ export class QueryBuilder<T extends BuilderTypeTemplate = BuilderTypeTemplate> e
223224
return newBuilder.withConfig({ queryFn: applyMiddleware(this.config.queryFn, middleware) });
224225
}
225226

227+
withTags(...tags: QueryTagOption<T['vars'], T['data'], T['error']>[]): this {
228+
return this.withMiddleware(createTagMiddleware<T>(tags)) as unknown as this;
229+
}
230+
226231
freeze(): QueryBuilderFrozen<T> {
227232
return this;
228233
}

src/builder/tags.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { hashKey } from '@tanstack/react-query';
2+
import { BuilderMutationCache } from '../tags/cache';
3+
import { resolveTags } from '../tags/resolveTags';
4+
import { QueryTagObject, QueryTagOption } from '../tags/types';
5+
import { MiddlewareFn } from './middlewares';
6+
import { BuilderTypeTemplate } from './types';
7+
8+
type CreateTagMiddleware = <T extends BuilderTypeTemplate>(
9+
tags: QueryTagOption[],
10+
) => MiddlewareFn<T['vars'], T['data'], T['error'], T>;
11+
12+
export const createTagMiddleware: CreateTagMiddleware = (tags) =>
13+
async function tagMiddlware(ctx, next) {
14+
const mutationCache = ctx.client.getMutationCache();
15+
if (!(mutationCache instanceof BuilderMutationCache)) return next(ctx);
16+
17+
let resolvedTags: QueryTagObject[] = [];
18+
19+
try {
20+
const data = await next(ctx);
21+
22+
resolvedTags = resolveTags<any>({ tags, client: ctx.client, vars: ctx.vars, data });
23+
24+
return data;
25+
} catch (error) {
26+
resolvedTags = resolveTags<any>({ tags, client: ctx.client, vars: ctx.vars, error });
27+
28+
throw error;
29+
} finally {
30+
if (resolvedTags.length) {
31+
const hash = hashKey(ctx.queryKey);
32+
33+
mutationCache.tagsCache.push(
34+
...resolvedTags.map((tag) => ({
35+
hash,
36+
tag,
37+
})),
38+
);
39+
}
40+
}
41+
};

src/builder/updates.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { BuilderMutationCache } from '../tags/cache';
2+
import { operateOnTags } from '../tags/operateOnTags';
3+
import { resolveTags } from '../tags/resolveTags';
4+
import { QueryTagContext, QueryTagOption, QueryUpdateTagObject } from '../tags/types';
5+
import { UpdateTagsUndoer, undoUpdateTags, updateTags } from '../tags/updateTags';
6+
import { MiddlewareFn } from './middlewares';
7+
import { BuilderTypeTemplate } from './types';
8+
9+
type CreateUpdateMiddleware = <T extends BuilderTypeTemplate>(
10+
tags: QueryTagOption<T['vars'], T['data'], T['error'], QueryUpdateTagObject>[],
11+
) => MiddlewareFn<T['vars'], T['data'], T['error'], T>;
12+
13+
export const createUpdateMiddleware: CreateUpdateMiddleware = (tags) =>
14+
async function updateMiddleware(ctx, next) {
15+
const cache = ctx.client.getMutationCache();
16+
if (!(cache instanceof BuilderMutationCache)) return next(ctx);
17+
18+
let undos: UpdateTagsUndoer[] | null = null;
19+
const invalidates: QueryUpdateTagObject[] = [];
20+
const optCtx: QueryTagContext<unknown> = { client: ctx.client, vars: ctx.vars, data: undefined };
21+
22+
try {
23+
const optUpdates = resolveTags<any, QueryUpdateTagObject>({ tags, ...optCtx }).filter((u) => u.optimistic);
24+
undos = updateTags({
25+
queryClient: ctx.client,
26+
tags: optUpdates.filter((x) => x.updater),
27+
ctx: optCtx,
28+
optimistic: true,
29+
});
30+
invalidates.push(...optUpdates);
31+
32+
const optToInvalidate = optUpdates.filter((tag) => ['pre', 'both'].includes(tag.invalidate || 'both'));
33+
operateOnTags({ queryClient: ctx.client, tags: optToInvalidate }, { refetchType: 'none' });
34+
35+
const data = await next(ctx);
36+
37+
const pesCtx: QueryTagContext<unknown> = { ...optCtx, data };
38+
const pesUpdates = resolveTags<any, QueryUpdateTagObject>({ tags, ...pesCtx }).filter((u) => !u.optimistic);
39+
updateTags({ queryClient: ctx.client, tags: pesUpdates.filter((x) => x.updater), ctx: pesCtx });
40+
invalidates.push(...pesUpdates);
41+
42+
return data;
43+
} catch (error) {
44+
if (undos?.length) undoUpdateTags(undos, ctx.client);
45+
46+
const pesCtx: QueryTagContext<unknown> = { ...optCtx, error };
47+
const pesUpdates = resolveTags<any, QueryUpdateTagObject>({ tags, ...pesCtx }).filter((u) => !u.optimistic);
48+
invalidates.push(...pesUpdates);
49+
50+
throw error;
51+
} finally {
52+
const tagsToInvalidate = invalidates.filter((tag) => ['post', 'both'].includes(tag.invalidate || 'both'));
53+
54+
operateOnTags({ tags: tagsToInvalidate, queryClient: ctx.client });
55+
56+
if (cache.syncChannel) {
57+
const tagsToSync = tagsToInvalidate.map(({ type, id }) => ({ type, id }));
58+
cache.syncChannel.postMessage({ type: 'invalidate', data: tagsToSync });
59+
}
60+
}
61+
};

src/tags/cache.ts

Lines changed: 6 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,20 @@
11
import { MutationCache, QueryClient } from '@tanstack/react-query';
22
import { operateOnTags } from './operateOnTags';
3-
import { resolveTags } from './resolveTags';
4-
import type { QueryTagContext } from './types';
5-
import { type UpdateTagsUndoer, undoUpdateTags, updateTags } from './updateTags';
3+
import type { QueryTagObject } from './types';
64

75
type BuilderMutationCacheOptions = {
86
getQueryClient: () => QueryClient;
97
syncChannel?: BroadcastChannel;
108
};
119

1210
export class BuilderMutationCache extends MutationCache {
13-
constructor(config: MutationCache['config'], { getQueryClient, syncChannel }: BuilderMutationCacheOptions) {
14-
// onMutate does not allow returning a context value in global MutationCache.
15-
// So we store the undos in our own map based on the mutationId.
16-
// See: https://tanstack.com/query/latest/docs/reference/MutationCache#global-callbacks
17-
type MutationContext = { undos?: UpdateTagsUndoer[] };
18-
19-
const mutationContexts = new Map<number, MutationContext>();
20-
21-
super({
22-
onMutate: async (...args) => {
23-
await config?.onMutate?.(...args);
24-
25-
const [vars, mutation] = args;
26-
const queryClient = getQueryClient();
27-
28-
const data = !vars || typeof vars !== 'object' ? undefined : Reflect.get(vars, 'body');
29-
const optUpdates = resolveTags({ client: queryClient, tags: mutation.meta?.optimisticUpdates, vars, data });
30-
const ctx: QueryTagContext<unknown> = { client: queryClient, vars, data };
31-
const undos = updateTags({ queryClient, tags: optUpdates, ctx, optimistic: true });
32-
if (undos.length) mutationContexts.set(mutation.mutationId, { undos });
33-
34-
const tags = optUpdates.filter(
35-
(tag) => typeof tag !== 'object' || ['pre', 'both'].includes(tag.invalidate || 'both'),
36-
);
37-
38-
operateOnTags({ queryClient, tags }, { refetchType: 'none' });
39-
},
40-
onSuccess: async (...args) => {
41-
await config?.onSuccess?.(...args);
42-
43-
const [data, vars, , mutation] = args;
44-
const queryClient = getQueryClient();
45-
46-
const updates = resolveTags({ client: queryClient, tags: mutation.meta?.updates, vars, data });
47-
updateTags({ queryClient, tags: updates, ctx: { client: queryClient, vars, data } });
48-
},
49-
onError: async (...args) => {
50-
await config?.onError?.(...args);
51-
52-
const [, , , mutation] = args;
53-
const queryClient = getQueryClient();
54-
55-
const { undos } = mutationContexts.get(mutation.mutationId) || {};
56-
if (undos) undoUpdateTags(undos, queryClient);
57-
},
58-
onSettled: async (...args): Promise<void> => {
59-
await config?.onSettled?.(...args);
60-
61-
const [data, error, vars, , mutation] = args;
62-
const queryClient = getQueryClient();
63-
64-
mutationContexts.delete(mutation.mutationId);
65-
66-
const optUpdates = resolveTags({ client: queryClient, tags: mutation.meta?.optimisticUpdates, vars, data });
67-
const optUpdateTags = optUpdates.filter((tag) => ['post', 'both'].includes(tag.invalidate || 'both'));
68-
operateOnTags({ queryClient, tags: optUpdateTags });
69-
70-
const pesUpdates = resolveTags({ client: queryClient, tags: mutation.meta?.updates, vars, data });
71-
const pesUpdateTags = pesUpdates.filter((tag) => ['post', 'both'].includes(tag.invalidate || 'both'));
72-
operateOnTags({ queryClient, tags: pesUpdateTags });
73-
74-
const tags = resolveTags({ client: queryClient, tags: mutation.meta?.invalidates, vars, data, error });
11+
syncChannel?: BroadcastChannel;
12+
tagsCache: { hash: string; tag: QueryTagObject }[] = [];
7513

76-
if (syncChannel) {
77-
const tagsToSync = [...tags, ...optUpdateTags, ...pesUpdateTags].map(({ type, id }) => ({ type, id }));
78-
syncChannel.postMessage({ type: 'invalidate', data: tagsToSync });
79-
}
14+
constructor(config: MutationCache['config'], { getQueryClient, syncChannel }: BuilderMutationCacheOptions) {
15+
super(config);
8016

81-
return operateOnTags({ queryClient, tags });
82-
},
83-
});
17+
this.syncChannel = syncChannel;
8418

8519
syncChannel?.addEventListener('message', (event) => {
8620
const { type, data } = event.data;

src/tags/operateOnTags.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
QueryClient,
66
QueryFilters,
77
} from '@tanstack/react-query';
8+
import { BuilderMutationCache } from './cache';
89
import { resolveQueryTags } from './resolveTags';
910
import type { QueryTag } from './types';
1011

@@ -26,6 +27,13 @@ function tagMatchesTag(queryTag: QueryTag, comparedTag: QueryTag) {
2627
}
2728

2829
export function queryMatchesTag(client: QueryClient, query: Query, tag: QueryTag) {
30+
const cache = client.getMutationCache() as BuilderMutationCache;
31+
const tagsInCache = cache.tagsCache
32+
?.filter((t) => t.hash === query.queryHash)
33+
.map((t) => t.tag)
34+
.filter((t) => tagMatchesTag(t, tag));
35+
if (tagsInCache?.length) return true;
36+
2937
const queryTags = resolveQueryTags({ query, client });
3038
return queryTags.some((queryTag) => tagMatchesTag(queryTag, tag));
3139
}

src/tags/resolveTags.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
11
import type { Query, QueryClient } from '@tanstack/react-query';
2-
import type { QueryTag, QueryTagOption } from './types';
2+
import type { QueryTag, QueryTagObject, QueryTagOption } from './types';
33

4-
export type ResolveTagsParams<TVars = void, TData = unknown, TErr = unknown, TTag extends QueryTag = QueryTag> = {
5-
tags?: QueryTagOption<TVars, TData, TErr, TTag> | null;
4+
export type ResolveTagsParams<
5+
TVars = void,
6+
TData = unknown,
7+
TErr = unknown,
8+
TTag extends QueryTagObject = QueryTagObject,
9+
> = {
10+
tags?: QueryTagOption<TVars, TData, TErr, TTag> | QueryTagOption<TVars, TData, TErr, TTag>[] | null;
611
vars: TVars;
712
data?: TData;
813
error?: unknown;
914
};
1015

11-
function normalizeTag<TTag extends QueryTag = QueryTag>(tag: TTag): Exclude<TTag, string> {
12-
return typeof tag === 'string'
13-
? ({ type: tag, invalidate: 'both' } as unknown as Exclude<TTag, string>)
14-
: (tag as Exclude<TTag, string>);
16+
function normalizeTag<TTag extends QueryTagObject = QueryTagObject>(tag: TTag | QueryTag): TTag {
17+
return typeof tag === 'string' ? ({ type: tag, invalidate: 'both' } as unknown as TTag) : (tag as TTag);
1518
}
1619

1720
/**
1821
* Resolve a tags array or function to an array of tags based on passed data, error, and vars.
1922
*/
20-
export function resolveTags<TVars = void, TTag extends QueryTag = QueryTag>({
23+
export function resolveTags<TVars = void, TTag extends QueryTagObject = QueryTagObject>({
2124
client,
2225
tags,
2326
vars,

0 commit comments

Comments
 (0)