diff --git a/packages/mst-query/package-lock.json b/packages/mst-query/package-lock.json index 3ba5970..02be978 100644 --- a/packages/mst-query/package-lock.json +++ b/packages/mst-query/package-lock.json @@ -1,12 +1,12 @@ { "name": "mst-query", - "version": "4.1.1", + "version": "4.1.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mst-query", - "version": "4.1.1", + "version": "4.1.2", "license": "MIT", "dependencies": { "@wry/equality": "0.5.7" diff --git a/packages/mst-query/package.json b/packages/mst-query/package.json index e714dac..0fd922b 100644 --- a/packages/mst-query/package.json +++ b/packages/mst-query/package.json @@ -1,6 +1,6 @@ { "name": "mst-query", - "version": "4.1.1", + "version": "4.1.2", "description": "Query library for mobx-state-tree", "source": "src/index.ts", "type": "module", diff --git a/packages/mst-query/src/MstQueryHandler.ts b/packages/mst-query/src/MstQueryHandler.ts index 665dc91..25294e7 100644 --- a/packages/mst-query/src/MstQueryHandler.ts +++ b/packages/mst-query/src/MstQueryHandler.ts @@ -38,10 +38,15 @@ type NotifyOptions = { onMutate?: boolean; }; +export type CacheOptions = { + cacheTime?: number; + cacheKey?: string; +}; + type OnResponseOptions = { shouldUpdate?: boolean; updateRecorder?: IPatchRecorder; -}; +} & CacheOptions; export class DisposedError extends Error {} @@ -65,6 +70,10 @@ export class QueryObserver { return this.query.__MstQueryHandler; } + get queryStore() { + return this.handler.queryClient.queryStore; + } + subscribe() { if (this.query) { this.handler.addQueryObserver(this); @@ -116,12 +125,25 @@ export class QueryObserver { } if (options.initialData && !options.isMounted) { + this.handler.hydrate(options.initialData, options); + const isStale = isDataStale(options.initialDataUpdatedAt, options.staleTime); - if (!isStale) { - this.handler.hydrate(options); - } else { + if (isStale) { this.handler.queryWhenChanged(options); } + } else if (options.cacheKey) { + const cacheEntry = this.queryStore.getQueryData( + this.handler.type, + options.cacheKey, + ); + if (cacheEntry) { + this.handler.hydrate(cacheEntry.data, options); + } + + const isStale = !cacheEntry || isDataStale(cacheEntry.cachedAt, options.staleTime); + if (isStale) { + this.handler.queryWhenChanged(options); + } } else { if (!options.isRequestEqual) { this.query.setData(null); @@ -269,7 +291,7 @@ export class MstQueryHandler { return; } - const notInitialized = !this.isFetched && !this.isLoading && !this.cachedAt; + const notInitialized = !this.isFetched && !this.isLoading; if (!options.isMounted) { if (notInitialized) { return this.model.query(options); @@ -314,7 +336,7 @@ export class MstQueryHandler { query(options: any = {}): Promise<() => any> { return this.run(options).then( - (result) => this.onSuccess(result), + (result) => this.onSuccess(result, options), (err) => this.onError(err), ); } @@ -389,7 +411,7 @@ export class MstQueryHandler { let data; if (shouldUpdate) { - data = this.setData(result); + data = this.setData(result, options); } else { data = this.prepareData(result); } @@ -527,7 +549,9 @@ export class MstQueryHandler { return merge(data, this.type.properties.data, this.queryClient.config.env, true); } - setData(data: any) { + setData(data: any, options: CacheOptions = {}) { + const opts = options ?? this.options; + this.model.__MstQueryHandlerAction(() => { if (isStateTreeNode(data)) { if (isReferenceType(this.type.properties.data)) { @@ -542,20 +566,31 @@ export class MstQueryHandler { this.queryClient.config.env, ); } + + if (opts.cacheKey && this.model.data) { + this.queryClient.queryStore.setQueryData( + this.type, + opts.cacheKey, + this.model, + opts.cacheTime, + ); + } }); return this.model.data; } - hydrate(options: any) { - const { initialData, request, pagination } = options; + hydrate(data: any, options: any) { + const { enabled, request, pagination } = options; - this.setVariables({ request, pagination }); + if (enabled) { + this.setVariables({ request, pagination }); + } this.options.meta = options.meta; this.isLoading = false; - this.setData(initialData); + this.setData(data, options); this.cachedAt = new Date(); } diff --git a/packages/mst-query/src/QueryClient.ts b/packages/mst-query/src/QueryClient.ts index 4bd126a..77c6f2f 100644 --- a/packages/mst-query/src/QueryClient.ts +++ b/packages/mst-query/src/QueryClient.ts @@ -25,6 +25,7 @@ const defaultConfig = { env: {}, queryOptions: { staleTime: 0, + cacheTime: 0, refetchOnMount: 'if-stale', refetchOnChanged: 'all', }, diff --git a/packages/mst-query/src/QueryStore.ts b/packages/mst-query/src/QueryStore.ts index 50265b3..16e2c48 100644 --- a/packages/mst-query/src/QueryStore.ts +++ b/packages/mst-query/src/QueryStore.ts @@ -8,15 +8,22 @@ import { getIdentifier, IAnyComplexType, } from 'mobx-state-tree'; -import { observable, action, makeObservable} from 'mobx'; +import { observable, action, makeObservable } from 'mobx'; export const getKey = (type: IAnyComplexType, id: string | number) => { return `${type.name}:${id}`; }; +type QueryCacheEntry = { + cachedAt: number; + data: any; + timeout: number; +}; + export class QueryStore { #scheduledGc = null as null | number; #queryClient: any; + #queryData = new Map() as Map; #cache = new Map() as Map; models = new Map() as Map; @@ -31,9 +38,38 @@ export class QueryStore { this.#queryClient = queryClient; } + getQueryData(type: IAnyComplexType, key: string) { + return this.#queryData.get(getKey(type, key)); + } + + setQueryData( + type: IAnyComplexType, + key: string, + model: Instance, + cacheTime: number = 0, + ) { + const existingEntry = this.#queryData.get(getKey(type, key)); + if (existingEntry) { + window.clearTimeout(existingEntry.timeout); + } + + const cacheEntry = { + cachedAt: Date.now(), + data: model.data, + timeout: window.setTimeout(() => { + this.removeQueryData(type, key); + }, cacheTime), + }; + this.#queryData.set(getKey(type, key), cacheEntry); + } + + removeQueryData(type: IAnyComplexType, key: string) { + this.#queryData.delete(getKey(type, key)); + } + getQueries( queryDef: T, - matcherFn: (query: Instance) => boolean = () => true + matcherFn: (query: Instance) => boolean = () => true, ): Instance[] { let results = []; const arr = this.#cache.get(queryDef.name) ?? []; @@ -66,7 +102,7 @@ export class QueryStore { 'delete', getType(obj), getIdentifier(obj), - obj + obj, ); } @@ -93,6 +129,10 @@ export class QueryStore { collectSeenIdentifiers(query.data, seenIdentifiers); collectSeenIdentifiers(query.request, seenIdentifiers); + + for (let [_, queryData] of this.#queryData) { + collectSeenIdentifiers(queryData.data, seenIdentifiers); + } } } diff --git a/packages/mst-query/src/create.ts b/packages/mst-query/src/create.ts index 6463494..f0803ed 100644 --- a/packages/mst-query/src/create.ts +++ b/packages/mst-query/src/create.ts @@ -1,5 +1,5 @@ import { types, IAnyType, flow, SnapshotIn, Instance } from 'mobx-state-tree'; -import { MstQueryHandler } from './MstQueryHandler'; +import { type CacheOptions, MstQueryHandler } from './MstQueryHandler'; type TypeOrFrozen = T extends IAnyType ? T : ReturnType; @@ -141,8 +141,8 @@ export function createQuery( invalidate() { self.__MstQueryHandler.invalidate(); }, - setData(data: any) { - return self.__MstQueryHandler.setData(data); + setData(data: any, options?: CacheOptions) { + return self.__MstQueryHandler.setData(data, options); }, abort: self.__MstQueryHandler.abort, })); diff --git a/packages/mst-query/src/hooks.ts b/packages/mst-query/src/hooks.ts index 4a3fbf5..9beb0ca 100644 --- a/packages/mst-query/src/hooks.ts +++ b/packages/mst-query/src/hooks.ts @@ -8,7 +8,7 @@ import { } from './create'; import { Context } from './QueryClientProvider'; import { QueryClient } from './QueryClient'; -import { EmptyPagination, EmptyRequest, QueryObserver } from './MstQueryHandler'; +import { CacheOptions, EmptyPagination, EmptyRequest, QueryObserver } from './MstQueryHandler'; import { useEvent } from './utils'; function mergeWithDefaultOptions(key: string, options: any, queryClient: QueryClient) { @@ -32,7 +32,7 @@ type QueryOptions> = { initialData?: any; initialDataUpdatedAt?: number; meta?: { [key: string]: any }; -}; +} & CacheOptions; export function useQuery>( query: T, diff --git a/packages/mst-query/tests/mstQuery.test.tsx b/packages/mst-query/tests/mstQuery.test.tsx index e34a23c..a581c8a 100644 --- a/packages/mst-query/tests/mstQuery.test.tsx +++ b/packages/mst-query/tests/mstQuery.test.tsx @@ -225,6 +225,55 @@ test('useQuery - reactive request', async () => { configureMobx({ enforceActions: 'observed' }); }); +test('useQuery - cacheKey and cacheTime', async () => { + const { render, q, queryClient } = setup(); + + configureMobx({ enforceActions: 'never' }); + + const getItem = vi.fn(({ request }) => api.getItem({ request })); + const testApi = { + ...api, + getItem, + }; + + let id = observable.box('test'); + const Comp = observer(() => { + const { query } = useQuery(q.itemQuery, { + request: { id: id.get() }, + cacheKey: id.get(), + cacheTime: 25, + staleTime: 25, + meta: { getItem: testApi.getItem }, + }); + return
; + }); + render(); + + await wait(0); + expect(q.itemQuery.data?.id).toBe('test'); + + id.set('different-test'); + await wait(0); + expect(q.itemQuery.data?.id).toBe('different-test'); + expect(q.itemQuery.variables.request?.id).toBe('different-test'); + + id.set('test'); + await wait(0); + expect(q.itemQuery.data?.id).toBe('test'); + expect(getItem).toHaveBeenCalledTimes(2); + + await wait(50); + queryClient.queryStore.runGc(); + await wait(0); + + id.set('different-test'); + await wait(0); + expect(q.itemQuery.data?.id).toBe('different-test'); + expect(getItem).toHaveBeenCalledTimes(3); + + configureMobx({ enforceActions: 'observed' }); +}); + test('onQueryMore', async () => { const { render, q } = setup(); @@ -900,7 +949,7 @@ test('useQuery should not run when initialData is passed and staleTime is larger expect(q.itemQuery.data?.id).toBe('different-test'); expect(q.itemQuery.variables.request?.id).toBe('different-test'); expect(loadingStates).toEqual([true, false]); - expect(dataStates).toEqual(['test', null, "different-test"]); + expect(dataStates).toEqual(['test', null, 'different-test']); isLoadingReaction(); dataReaction(); @@ -978,6 +1027,30 @@ test('useQuery should run when initialData is given and invalidate is called', a configureMobx({ enforceActions: 'observed' }); }); +test('useQuery should set initialData when enabled is false', async () => { + const { render, q } = setup(); + + configureMobx({ enforceActions: 'never' }); + + let id = observable.box('test'); + const initialData = await api.getItem({ request: { id: id.get() } }); + + const Comp = observer(() => { + useQuery(q.itemQuery, { + initialData, + enabled: false, + }); + return
; + }); + render(); + + await wait(0); + + expect(q.itemQuery.data?.id).toBe('test'); + + configureMobx({ enforceActions: 'observed' }); +}); + test('refetchOnRequestChanged function', async () => { const { render, q } = setup(); @@ -995,9 +1068,9 @@ test('refetchOnRequestChanged function', async () => { const Comp = observer(() => { useQuery(q.itemQuery, { request: { id: id.get(), id2: id2.get() }, - refetchOnChanged({ prevRequest }) { + refetchOnChanged({ prevRequest }) { return prevRequest.id !== id.get(); - }, + }, staleTime: 5000, meta: { getItem: testApi.getItem }, });