UNPKG

@pinia/colada

Version:

The smart data fetching layer for Vue.js

1,424 lines (1,423 loc) 49.1 kB
import { computed, customRef, effectScope, getCurrentInstance, getCurrentScope, hasInjectionContext, inject, isRef, markRaw, onMounted, onScopeDispose, onServerPrefetch, onUnmounted, shallowRef, toValue, triggerRef, watch } from "vue"; import { defineStore, getActivePinia, skipHydrate } from "pinia"; //#region src/entry-keys.ts /** * Serializes the given {@link EntryKey | key} (query or mutation key) to a string. * * @param key - The key to serialize. * * @see {@link EntryKey} */ function toCacheKey(key) { return key && JSON.stringify(key, (_, val) => !val || typeof val !== "object" || Array.isArray(val) ? val : Object.keys(val).sort().reduce((result, key) => { result[key] = val[key]; return result; }, {})); } /** * Checks whether `subsetKey` is a subset of `fullsetKey` by matching partially objects and arrays. * * @param subsetKey - subset key to check * @param fullsetKey - fullset key to check against */ function isSubsetOf(subsetKey, fullsetKey) { return subsetKey === fullsetKey ? true : typeof subsetKey !== typeof fullsetKey ? false : subsetKey && fullsetKey && typeof subsetKey === "object" && typeof fullsetKey === "object" ? Object.keys(subsetKey).every((key) => isSubsetOf(subsetKey[key], fullsetKey[key])) : false; } /** * Finds entries that partially match the given key. If no key is provided, all * entries are returned. * * @param map - The map to search in. * @param partialKey - The key to match against. If not provided, all entries are yield. * * @internal */ function* find(map, partialKey) { for (const entry of map.values()) if (!partialKey || entry.key && isSubsetOf(partialKey, entry.key)) yield entry; } /** * Empty starting object for extensions that allows to detect when to update. * * @internal */ const START_EXT = Object.freeze(process.env.NODE_ENV === "production" ? {} : { message: "This is a placeholder object for Pinia Colada extensions, it should never be used directly, but only checked for identity. This is here to simplify debugging during dev." }); //#endregion //#region src/utils.ts function useEventListener(target, event, listener, options) { target.addEventListener(event, listener, options); if (getCurrentScope()) onScopeDispose(() => { target.removeEventListener(event, listener); }); } const IS_CLIENT = typeof window !== "undefined"; /** * Transforms a value or a function that returns a value to a value. * * @param valFn either a value or a function that returns a value * @param args arguments to pass to the function if `valFn` is a function * * @internal */ function toValueWithArgs(valFn, ...args) { return typeof valFn === "function" ? valFn(...args) : valFn; } /** * @internal */ const noop = () => {}; /** * Dev only warning that is only shown once. */ const warnedMessages = /* @__PURE__ */ new Set(); /** * Warns only once. This should only be used in dev * * @param message - Message to show * @param id - Unique id for the message, defaults to the message */ function warnOnce(message, id = message) { if (warnedMessages.has(id)) return; warnedMessages.add(id); console.warn(`[@pinia/colada]: ${message}`); } //#endregion //#region src/query-options.ts /** * Default options for `useQuery()`. Modifying this object will affect all the queries that don't override these */ const USE_QUERY_DEFAULTS = { staleTime: 1e3 * 5, gcTime: 1e3 * 60 * 5, refetchOnWindowFocus: true, refetchOnReconnect: true, refetchOnMount: true, enabled: true }; const USE_QUERY_OPTIONS_KEY = process.env.NODE_ENV !== "production" ? Symbol("useQueryOptions") : Symbol(); /** * Injects the global query options. * * @internal */ const useQueryOptions = () => inject(USE_QUERY_OPTIONS_KEY, USE_QUERY_DEFAULTS); //#endregion //#region src/query-store.ts /** * Keep track of the entry being defined so we can add the queries in ensure * this allows us to refresh the entry when a defined query is used again * and refetchOnMount is true * * @internal */ let currentDefineQueryEntry; /** * Returns whether the entry is using a placeholder data. * * @template TDataInitial - Initial data type * @param entry - entry to check */ function isEntryUsingPlaceholderData(entry) { return entry?.placeholderData != null && entry.state.value.status === "pending"; } /** * UseQueryEntry method to serialize the entry to JSON. * * @param entry - entry to serialize * @returns Serialized version of the entry */ const queryEntry_toJSON = ({ state: { value }, when, meta }) => [ value.data, value.error, when ? Date.now() - when : -1, meta ]; /** * Composable to get the cache of the queries. As any other composable, it can * be used inside the `setup` function of a component, within another * composable, or in injectable contexts like stores and navigation guards. */ const useQueryCache = /* @__PURE__ */ defineStore("_pc_query", ({ action }) => { const cachesRaw = /* @__PURE__ */ new Map(); const caches = skipHydrate(shallowRef(cachesRaw)); if (process.env.NODE_ENV !== "production") watch(() => caches.value !== cachesRaw, (isDifferent) => { if (isDifferent) console.error(`[@pinia/colada] The query cache cannot be directly set, it must be modified only. This will fail on production`); }); const scope = getCurrentScope(); const app = getActivePinia()._a; if (process.env.NODE_ENV !== "production") { if (!hasInjectionContext()) warnOnce("useQueryCache() was called outside of an injection context (component setup, store, navigation guard) You will get a warning about \"inject\" being used incorrectly from Vue. Make sure to use it only in allowed places.\nSee https://vuejs.org/guide/reusability/composables.html#usage-restrictions"); } const optionDefaults = useQueryOptions(); /** * Creates a new query entry in the cache. Shouldn't be called directly. * * @param key - Serialized key of the query * @param [options] - options attached to the query * @param [initialData] - initial data of the query if any * @param [error] - initial error of the query if any * @param [when] - relative when was the data or error fetched (will be substracted to Date.now()) * @param [meta] - resolved meta information for the query */ const create = action((key, options = null, initialData, error = null, when = 0, meta = {}) => scope.run(() => { const state = shallowRef({ data: initialData, error, status: error ? "error" : initialData !== void 0 ? "success" : "pending" }); const asyncStatus = shallowRef("idle"); return markRaw({ key, keyHash: toCacheKey(key), state, placeholderData: null, when: initialData === void 0 ? 0 : Date.now() - when, asyncStatus, pending: null, deps: markRaw(/* @__PURE__ */ new Set()), gcTimeout: void 0, ext: START_EXT, options, meta, get stale() { return !this.options || !this.when || Date.now() >= this.when + this.options.staleTime; }, get active() { return this.deps.size > 0; } }); })); const defineQueryMap = /* @__PURE__ */ new WeakMap(); /** * Ensures a query created with {@link defineQuery} is present in the cache. If it's not, it creates a new one. * @param fn - function that defines the query */ const ensureDefinedQuery = action((fn) => { let defineQueryEntry = defineQueryMap.get(fn); if (!defineQueryEntry) { currentDefineQueryEntry = defineQueryEntry = scope.run(() => [ [], null, effectScope(), shallowRef(false) ]); defineQueryEntry[1] = app.runWithContext(() => defineQueryEntry[2].run(fn)); currentDefineQueryEntry = null; defineQueryMap.set(fn, defineQueryEntry); } else { defineQueryEntry[2].resume(); defineQueryEntry[3].value = false; defineQueryEntry[0] = defineQueryEntry[0].map((oldEntry) => oldEntry.options ? ensure(oldEntry.options, oldEntry) : oldEntry); } return defineQueryEntry; }); /** * Tracks an effect or component that uses a query. * * @param entry - the entry of the query * @param effect - the effect or component to untrack * * @see {@link untrack} */ function track(entry, effect) { if (!effect) return; entry.deps.add(effect); clearTimeout(entry.gcTimeout); entry.gcTimeout = void 0; triggerRef(caches); } /** * Untracks an effect or component that uses a query. * * @param entry - the entry of the query * @param effect - the effect or component to untrack * * @see {@link track} */ function untrack(entry, effect) { if (!effect || !entry.deps.has(effect)) return; entry.deps.delete(effect); triggerRef(caches); scheduleGarbageCollection(entry); } function scheduleGarbageCollection(entry) { if (entry.deps.size > 0 || !entry.options) return; clearTimeout(entry.gcTimeout); if (entry.state.value.status === "pending") entry.pending?.abortController.abort(); if (Number.isFinite(entry.options.gcTime)) entry.gcTimeout = setTimeout(() => { remove(entry); }, entry.options.gcTime); } /** * Invalidates and cancel matched queries, and then refetches (in parallel) * all active ones. If you need to further control which queries are * invalidated, canceled, and/or refetched, you can use the filters, you * can direcly call {@link invalidate} on {@link getEntries}: * * ```ts * // instead of doing * await queryCache.invalidateQueries(filters) * await Promise.all(queryCache.getEntries(filters).map(entry => { * queryCache.invalidate(entry) * // this is the default behavior of invalidateQueries * // return entry.active && queryCache.fetch(entry) * // here to refetch everything, even non active queries * return queryCache.fetch(entry) * }) * ``` * * @param filters - filters to apply to the entries * @param refetchActive - whether to refetch active queries or not. Set * to `'all'` to refetch all queries * * @see {@link invalidate} * @see {@link cancel} */ const invalidateQueries = action((filters, refetchActive = true) => { return Promise.all(getEntries(filters).map((entry) => { invalidate(entry); return (refetchActive === "all" || entry.active && refetchActive) && toValue(entry.options?.enabled) && fetch(entry); })); }); /** * Returns all the entries in the cache that match the filters. * * @param filters - filters to apply to the entries */ const getEntries = action((filters = {}) => { return (filters.exact ? filters.key ? [caches.value.get(toCacheKey(filters.key))].filter((v) => !!v) : [] : [...find(caches.value, filters.key)]).filter((entry) => (filters.stale == null || entry.stale === filters.stale) && (filters.active == null || entry.active === filters.active) && (!filters.status || entry.state.value.status === filters.status) && (!filters.predicate || filters.predicate(entry))); }); /** * Ensures a query entry is present in the cache. If it's not, it creates a * new one. The resulting entry is required to call other methods like * {@link fetch}, {@link refresh}, or {@link invalidate}. * * @param opts - options to create the query * @param previousEntry - the previous entry that was associated with the same options */ const ensure = action((opts, previousEntry) => { const options = { ...optionDefaults, ...opts }; const key = toValue(options.key); const keyHash = toCacheKey(key); if (process.env.NODE_ENV !== "production" && keyHash === "[]") throw new Error(`useQuery() was called with an empty array as the key. It must have at least one element.`); if (previousEntry && keyHash === previousEntry.keyHash) { previousEntry.options = options; return previousEntry; } let entry = cachesRaw.get(keyHash); if (!entry) { const initialDataUpdatedAt = toValue(options.initialDataUpdatedAt); cachesRaw.set(keyHash, entry = create(key, options, options.initialData?.(), null, initialDataUpdatedAt != null ? Date.now() - initialDataUpdatedAt : 0, toValue(options.meta))); if (options.placeholderData && entry.state.value.status === "pending") entry.placeholderData = toValueWithArgs(options.placeholderData, isEntryUsingPlaceholderData(previousEntry) ? previousEntry.placeholderData : previousEntry?.state.value.data, previousEntry); triggerRef(caches); } if (process.env.NODE_ENV !== "production") { const currentInstance = getCurrentInstance(); if (currentInstance) { const type = currentInstance.type; if (type.__hmrId) { const components = (entry.__hmr ??= { components: /* @__PURE__ */ new Map() }).components; const prev = components.get(type.__hmrId); if (prev && (prev.setup !== type.setup || prev.render !== type.render)) invalidate(entry); components.set(type.__hmrId, { setup: type.setup, render: type.render }); } } } entry.options = options; if (entry.ext === START_EXT) { entry.ext = {}; extend(entry); } currentDefineQueryEntry?.[0].push(entry); return entry; }); /** * Action called when an entry is ensured for the first time to allow plugins to extend it. * * @param _entry - the entry of the query to extend */ const extend = action((_entry) => {}); /** * Invalidates and cancels a query entry. It effectively sets the `when` * property to `0` and {@link cancel | cancels} the pending request. * * @param entry - the entry of the query to invalidate * * @see {@link cancel} */ const invalidate = action((entry) => { entry.when = 0; cancel(entry); }); /** * Ensures the current data is fresh. If the data is stale or if the status * is 'error', calls {@link fetch}, if not return the current data. Can only * be called if the entry has been initialized with `useQuery()` and has * options. * * @param entry - the entry of the query to refresh * @param options - the options to use for the fetch * * @see {@link fetch} */ const refresh = action(async (entry, options = entry.options) => { if (process.env.NODE_ENV !== "production" && !options) throw new Error(`"entry.refresh()" was called but the entry has no options. This is probably a bug, report it to pinia-colada with a boiled down example to reproduce it. Thank you!`); if (entry.state.value.error || entry.stale) return entry.pending?.refreshCall ?? fetch(entry, options); return entry.state.value; }); /** * Fetch an entry. Ignores fresh data and triggers a new fetch. Can only be called if the entry has options. * * @param entry - the entry of the query to fetch * @param options - the options to use for the fetch */ const fetch = action(async (entry, options = entry.options) => { if (process.env.NODE_ENV !== "production" && !options) throw new Error(`"entry.fetch()" was called but the entry has no options. This is probably a bug, report it to pinia-colada with a boiled down example to reproduce it. Thank you!`); entry.asyncStatus.value = "loading"; const abortController = new AbortController(); const { signal } = abortController; entry.pending?.abortController.abort(); const pendingCall = entry.pending = { abortController, refreshCall: (async () => options.query({ signal, entry }))().then((data) => { if (pendingCall === entry.pending) setEntryState(entry, { data, error: null, status: "success" }); return entry.state.value; }).catch((error) => { if (pendingCall === entry.pending && (error !== signal.reason || !signal.aborted)) setEntryState(entry, { status: "error", data: entry.state.value.data, error }); throw error; }).finally(() => { entry.asyncStatus.value = "idle"; if (pendingCall === entry.pending) { entry.pending = null; if (entry.state.value.status !== "pending") entry.placeholderData = null; } }), when: Date.now() }; return pendingCall.refreshCall; }); /** * Cancels an entry's query if it's currently pending. This will effectively abort the `AbortSignal` of the query and any * pending request will be ignored. * * @param entry - the entry of the query to cancel * @param reason - the reason passed to the abort controller */ const cancel = action((entry, reason) => { entry.pending?.abortController.abort(reason); entry.asyncStatus.value = "idle"; entry.pending = null; }); /** * Cancels queries if they are currently pending. This will effectively abort the `AbortSignal` of the query and any * pending request will be ignored. * * @param filters - filters to apply to the entries * @param reason - the reason passed to the abort controller * * @see {@link cancel} */ const cancelQueries = action((filters, reason) => { getEntries(filters).forEach((entry) => cancel(entry, reason)); }); /** * Sets the state of a query entry in the cache and updates the * {@link UseQueryEntry['pending']['when'] | `when` property}. This action is * called every time the cache state changes and can be used by plugins to * detect changes. * * @param entry - the entry of the query to set the state * @param state - the new state of the entry */ const setEntryState = action((entry, state) => { entry.state.value = state; entry.when = Date.now(); scheduleGarbageCollection(entry); }); /** * Gets a single query entry from the cache based on the key of the query. * * @param key - the key of the query */ function get(key) { return caches.value.get(toCacheKey(key)); } /** * Set the data of a query entry in the cache. It also sets the `status` to `success`. * * @param key - the key of the query * @param data - the new data to set * * @see {@link setEntryState} */ const setQueryData = action((key, data) => { const keyHash = toCacheKey(key); let entry = cachesRaw.get(keyHash); if (!entry) cachesRaw.set(keyHash, entry = create(key)); setEntryState(entry, { error: null, status: "success", data: toValueWithArgs(data, entry.state.value.data) }); triggerRef(caches); }); /** * Sets the data of all queries in the cache that are children of a key. It * also sets the `status` to `success`. Differently from {@link * setQueryData}, this method recursively sets the data for all queries. This * is why it requires a function to set the data. * * @param filters - filters used to get the entries * @param updater - the function to set the data * * @example * ```ts * // let's suppose we want to optimistically update all contacts in the cache * setQueriesData(['contacts', 'list'], (contactList: Contact[]) => { * const contactToReplaceIndex = contactList.findIndex(c => c.id === updatedContact.id) * return contactList.toSpliced(contactToReplaceIndex, 1, updatedContact) * }) * ``` * * @see {@link setQueryData} */ function setQueriesData(filters, updater) { for (const entry of getEntries(filters)) setEntryState(entry, { error: null, status: "success", data: updater(entry.state.value.data) }); triggerRef(caches); } /** * Gets the data of a query entry in the cache based on the key of the query. * * @param key - the key of the query */ function getQueryData(key) { return caches.value.get(toCacheKey(key))?.state.value.data; } /** * Removes a query entry from the cache. * * @param entry - the entry of the query to remove */ const remove = action((entry) => { clearTimeout(entry.gcTimeout); cachesRaw.delete(entry.keyHash); triggerRef(caches); }); return { caches, ensureDefinedQuery, /** * Scope to track effects and components that use the query cache. * @internal */ _s: markRaw(scope), setQueryData, setQueriesData, getQueryData, invalidateQueries, cancelQueries, invalidate, fetch, refresh, ensure, extend, track, untrack, cancel, create, remove, get, setEntryState, getEntries }; }); /** * Checks if the given object is a query cache. Used in SSR to apply custom serialization. * * @param cache - the object to check * * @see {@link QueryCache} * @see {@link serializeQueryCache} */ function isQueryCache(cache) { return typeof cache === "object" && !!cache && cache.$id === "_pc_query"; } /** * Hydrates the query cache with the serialized cache. Used during SSR. * * @param queryCache - query cache * @param serializedCache - serialized cache */ function hydrateQueryCache(queryCache, serializedCache) { for (const keyHash in serializedCache) queryCache.caches.set(keyHash, queryCache.create(JSON.parse(keyHash), void 0, ...serializedCache[keyHash] ?? [])); } /** * Serializes the query cache to a compressed version. Used during SSR. * * @param queryCache - query cache */ function serializeQueryCache(queryCache) { return Object.fromEntries([...queryCache.caches.entries()].map(([keyHash, entry]) => [keyHash, queryEntry_toJSON(entry)])); } //#endregion //#region src/define-query.ts /** * The current effect scope where the function returned by `defineQuery` is * being called. This allows `useQuery()` to know if it should be attached to * an effect scope or not * * @internal */ let currentDefineQueryEffect; function defineQuery(optionsOrSetup) { const setupFn = typeof optionsOrSetup === "function" ? optionsOrSetup : () => useQuery(optionsOrSetup); let hasBeenEnsured; let refCount = 0; return () => { const queryCache = useQueryCache(); const previousEffect = currentDefineQueryEffect; const currentScope = getCurrentInstance() || (currentDefineQueryEffect = getCurrentScope()); const [ensuredEntries, ret, scope, isPaused] = queryCache.ensureDefinedQuery(setupFn); if (hasBeenEnsured) ensuredEntries.forEach((entry) => { if (entry.options?.refetchOnMount && toValue(entry.options.enabled)) if (toValue(entry.options.refetchOnMount) === "always") queryCache.fetch(entry).catch(noop); else queryCache.refresh(entry).catch(noop); }); hasBeenEnsured = true; if (currentScope) { refCount++; ensuredEntries.forEach((entry) => { queryCache.track(entry, currentScope); }); onScopeDispose(() => { ensuredEntries.forEach((entry) => { queryCache.untrack(entry, currentScope); }); if (--refCount < 1) { scope.pause(); isPaused.value = true; } }); } currentDefineQueryEffect = previousEffect; return ret; }; } //#endregion //#region src/use-query.ts /** * Ensures and return a shared query state based on the `key` option. * * @param _options - The options of the query */ function useQuery(_options) { const queryCache = useQueryCache(); const optionDefaults = useQueryOptions(); const hasCurrentInstance = getCurrentInstance(); const defineQueryEffect = currentDefineQueryEntry?.[2]; const currentEffect = currentDefineQueryEffect || getCurrentScope(); const isPaused = currentDefineQueryEntry?.[3]; const options = computed(() => ({ ...optionDefaults, ...toValue(_options) })); const enabled = () => toValue(options.value.enabled); let lastEntry; const entry = computed(() => isPaused?.value ? lastEntry : lastEntry = queryCache.ensure(options.value, lastEntry)); lastEntry = entry.value; const errorCatcher = () => entry.value.state.value; const refresh = (throwOnError) => queryCache.refresh(entry.value, options.value).catch(throwOnError || errorCatcher); const refetch = (throwOnError) => queryCache.fetch(entry.value, options.value).catch(throwOnError || errorCatcher); const isPlaceholderData = computed(() => isEntryUsingPlaceholderData(entry.value)); const state = computed(() => isPlaceholderData.value ? { status: "success", data: entry.value.placeholderData, error: null } : entry.value.state.value); const extensions = {}; for (const key in lastEntry.ext) extensions[key] = computed({ get: () => toValue(entry.value.ext[key]), set(value) { const target = entry.value.ext[key]; if (isRef(target)) target.value = value; else entry.value.ext[key] = value; } }); const queryReturn = { ...extensions, state, status: computed(() => state.value.status), data: computed(() => state.value.data), error: computed(() => entry.value.state.value.error), asyncStatus: computed(() => entry.value.asyncStatus.value), isPlaceholderData, isPending: computed(() => state.value.status === "pending"), isLoading: computed(() => entry.value.asyncStatus.value === "loading"), refresh, refetch }; if (hasCurrentInstance) onServerPrefetch(async () => { if (enabled()) await refresh(!options.value.ssrCatchError); }); let isActive = false; if (hasCurrentInstance) { onMounted(() => { isActive = true; queryCache.track(lastEntry, hasCurrentInstance); }); onUnmounted(() => { queryCache.untrack(lastEntry, hasCurrentInstance); }); } else { isActive = true; if (currentEffect !== defineQueryEffect) { queryCache.track(lastEntry, currentEffect); onScopeDispose(() => { queryCache.untrack(lastEntry, currentEffect); }); } } watch(entry, (entry, previousEntry) => { if (!isActive) return; if (previousEntry) { queryCache.untrack(previousEntry, hasCurrentInstance); queryCache.untrack(previousEntry, currentEffect); } queryCache.track(entry, hasCurrentInstance); if (!hasCurrentInstance && currentEffect !== defineQueryEffect) queryCache.track(entry, currentEffect); if (enabled()) refresh(); }, { immediate: true }); watch(enabled, (newEnabled) => { if (newEnabled) refresh(); }); if (hasCurrentInstance) onMounted(() => { if (enabled()) { const refetchControl = toValue(options.value.refetchOnMount); if (refetchControl === "always") refetch(); else if (refetchControl || entry.value.state.value.status === "pending") refresh(); } }); if (IS_CLIENT) { useEventListener(document, "visibilitychange", () => { const refetchControl = toValue(options.value.refetchOnWindowFocus); if (document.visibilityState === "visible" && enabled() && entry.value.active) { if (refetchControl === "always") refetch(); else if (refetchControl) refresh(); } }); useEventListener(window, "online", () => { if (enabled() && entry.value.active) { const refetchControl = toValue(options.value.refetchOnReconnect); if (refetchControl === "always") refetch(); else if (refetchControl) refresh(); } }); } return queryReturn; } //#endregion //#region src/define-query-options.ts /** * Define type-safe query options. Can be static or dynamic. Define the arguments based * on what's needed on the query and the key. Use an object if you need * multiple properties. * * @param setupOrOptions - The query options or a function that returns the query options. * * @example * ```ts * import { defineQueryOptions } from '@pinia/colada' * * const documentDetailsQuery = defineQueryOptions((id: number ) => ({ * key: ['documents', id], * query: () => fetchDocument(id), * })) * * queryCache.getQueryData(documentDetailsQuery(4).key) // typed * ``` * * @__NO_SIDE_EFFECTS__ */ function defineQueryOptions(setupOrOptions) { return setupOrOptions; } //#endregion //#region src/use-query-state.ts function useQueryState(key) { const queryCache = useQueryCache(); const entry = computed(() => queryCache.get(toValue(key))); const state = computed(() => entry.value?.state.value); return { state, data: computed(() => state.value?.data), error: computed(() => state.value?.error), status: computed(() => state.value?.status), asyncStatus: computed(() => entry.value?.asyncStatus.value), isPending: computed(() => !state.value || state.value.status === "pending") }; } //#endregion //#region src/infinite-query.ts /** * Type guard to check if a query entry is an infinite query entry. * * @param entry - The query entry to check. * * @internal */ function isInfiniteQueryEntry(entry) { return !!entry.meta.__i; } const installedMap = /* @__PURE__ */ new WeakMap(); function PiniaColadaInfiniteQueryPlugin(scope, queryCache) { if (!installedMap.has(queryCache)) { queryCache.$onAction(({ name, args }) => { if (name === "extend") { const [entry] = args; if (isInfiniteQueryEntry(entry)) scope.run(() => { const nextPageParam = shallowRef(); const hasNextPage = computed(() => nextPageParam.value != null); const previousPageParam = shallowRef(); const hasPreviousPage = computed(() => previousPageParam.value != null); entry.ext.nextPageParam = nextPageParam; entry.ext.hasNextPage = hasNextPage; entry.ext.previousPageParam = previousPageParam; entry.ext.hasPreviousPage = hasPreviousPage; entry.ext.nextPageIndicator = 0; }); } }, true); installedMap.set(queryCache, true); } } /** * Sets the data of an infinite query entry in the cache. Unlike {@link QueryCache.setQueryData | `queryCache.setQueryData()`}, * this function properly marks the entry as an infinite query and initializes the * infinite query extensions (`hasNextPage`, `hasPreviousPage`, etc.). * * Use this when you need to set infinite query data before `useInfiniteQuery()` is mounted * (e.g. optimistic updates from a mutation on a different page). * * @param queryCache - The query cache instance * @param key - The key of the infinite query * @param data - The data to set, or an updater function */ function setInfiniteQueryData(queryCache, key, data) { PiniaColadaInfiniteQueryPlugin(queryCache._s, queryCache); queryCache.setQueryData(key, data); queryCache.get(key).meta.__i = 1; } function useInfiniteQuery(options) { const queryCache = useQueryCache(); PiniaColadaInfiniteQueryPlugin(queryCache._s, queryCache); let entry; const query = useQuery(() => { const opts = toValueWithArgs(options); const key = toValue(opts.key); entry = queryCache.get(key); return { ...opts, key, query: async (context) => { const currentEntry = entry = context.entry; const exts = currentEntry.ext; const state = currentEntry.state.value; const { data } = state; let pages; let pageParams; let pageParam; if ((state.status === "pending" || !data?.pages.length) && !exts.nextPageIndicator) exts.nextPageIndicator = 1; const position = exts.nextPageIndicator > 0 ? -1 : 0; if (!exts.nextPageIndicator && data) { const pagesToRefetch = data.pages.length; pages = []; pageParams = []; pageParam = data.pageParams[0]; for (let i = 0; i < pagesToRefetch; i++) { const page = await opts.query({ ...context, pageParam }); pages.push(page); pageParams.push(pageParam); if (i < pagesToRefetch - 1) { const nextParam = opts.getNextPageParam(page, pages, pageParam, pageParams); if (nextParam == null) break; pageParam = nextParam; } } } else { exts.nextPageIndicator = 0; if (process.env.NODE_ENV !== "production") { if (position === 0 && !opts.getPreviousPageParam) { const msg = "[useInfiniteQuery] Trying to load previous page but `getPreviousPageParam` is not defined in options. This will fail in production."; console.warn(msg); throw new Error(msg); } } computePageParams(currentEntry, data); pageParam = (position ? exts.nextPageParam : exts.previousPageParam).value; if (pageParam == null && data) return data; pageParam ??= toValue(opts.initialPageParam); const page = await opts.query({ ...context, pageParam }); pages = data?.pages.slice() ?? []; pageParams = data?.pageParams.slice() ?? []; const arrayMethod = position ? "push" : "unshift"; pages[arrayMethod](page); pageParams[arrayMethod](pageParam); } if (opts.maxPages && pages.length > opts.maxPages) if (position) { pages.splice(0, pages.length - opts.maxPages); pageParams.splice(0, pageParams.length - opts.maxPages); } else { pages.splice(opts.maxPages); pageParams.splice(opts.maxPages); } computePageParams(currentEntry, { pages, pageParams }); return { pages, pageParams }; }, meta: { ...toValue(opts.meta), __i: 1 } }; }); function computePageParams(entry, data = entry?.state.value.data) { if (!entry?.options) return; const opts = entry.options; const lastPageParam = data?.pageParams.at(-1); const exts = entry.ext; exts.nextPageParam.value = data && data.pages.length > 0 ? opts.getNextPageParam(data.pages.at(-1), data.pages, lastPageParam, data.pageParams) : null; const firstPageParam = data?.pageParams.at(0); exts.previousPageParam.value = data && data.pages.length > 0 ? opts.getPreviousPageParam?.(data.pages.at(0), data.pages, firstPageParam, data.pageParams) : null; } computePageParams(entry); async function loadPage(page, { throwOnError, cancelRefetch = true } = {}) { const opts = toValueWithArgs(options); const entry = queryCache.get(toValue(opts.key)); if (!entry) { if (process.env.NODE_ENV !== "production") console.warn("[useInfiniteQuery] Cannot load next page: query entry not found in cache."); return null; } if (!cancelRefetch && entry.pending) return entry.pending.refreshCall; entry.ext.nextPageIndicator = page; return queryCache.fetch(entry).catch(throwOnError ? void 0 : noop); } return { ...query, loadNextPage: (options) => loadPage(1, options), loadPreviousPage: (options) => loadPage(-1, options) }; } //#endregion //#region src/define-infinite-query-options.ts /** * Define type-safe infinite query options. Can be static or dynamic. Define the * arguments based on what's needed on the query and the key. * * @param setupOrOptions - The infinite query options or a function that returns * them. * * @example * ```ts * import { defineInfiniteQueryOptions } from '@pinia/colada' * * const itemsQuery = defineInfiniteQueryOptions({ * key: ['items'], * query: ({ pageParam }) => fetchItems(pageParam), * initialPageParam: 0, * getNextPageParam: (lastPage) => lastPage.nextCursor, * }) * * queryCache.getQueryData(itemsQuery.key) // typed * ``` * * @__NO_SIDE_EFFECTS__ */ function defineInfiniteQueryOptions(setupOrOptions) { return setupOrOptions; } //#endregion //#region src/mutation-options.ts /** * Default options for `useMutation()`. Modifying this object will affect all mutations. */ const USE_MUTATION_DEFAULTS = { gcTime: 1e3 * 60 }; const USE_MUTATION_OPTIONS_KEY = process.env.NODE_ENV !== "production" ? Symbol("useMutationOptions") : Symbol(); /** * Injects the global query options. * * @internal */ const useMutationOptions = () => inject(USE_MUTATION_OPTIONS_KEY, USE_MUTATION_DEFAULTS); /** * Composable to get the cache of the mutations. As any other composable, it * can be used inside the `setup` function of a component, within another * composable, or in injectable contexts like stores and navigation guards. */ const useMutationCache = /* @__PURE__ */ defineStore("_pc_mutation", ({ action }) => { const cachesRaw = /* @__PURE__ */ new Map(); let triggerCache; const caches = skipHydrate(customRef((track, trigger) => (triggerCache = trigger) && { get: () => (track(), cachesRaw), set: process.env.NODE_ENV !== "production" ? () => { console.error(`[@pinia/colada]: The mutation cache instance cannot be set directly, it must be modified. This will fail in production.`); } : noop })); const scope = getCurrentScope(); if (process.env.NODE_ENV !== "production") { if (!hasInjectionContext()) warnOnce("useMutationCache() was called outside of an injection context (component setup, store, navigation guard) You will get a warning about \"inject\" being used incorrectly from Vue. Make sure to use it only in allowed places.\nSee https://vuejs.org/guide/reusability/composables.html#usage-restrictions"); } const app = getActivePinia()._a; const globalOptions = useMutationOptions(); const defineMutationMap = /* @__PURE__ */ new WeakMap(); let nextMutationId = 1; /** * Action called when an entry is created for the first time to allow plugins to extend it. * * @param _entry - the entry of the mutation to extend */ const extend = action((_entry) => _entry); /** * Creates a mutation entry and its state without adding it to the cache. * This allows for the state to exist in `useMutation()` before the mutation * is actually called. The mutation must be _ensured_ with {@link ensure} * before being called. * * @param options - options to create the mutation */ const create = action((options, key, vars) => extend(scope.run(() => markRaw({ id: 0, state: shallowRef({ status: "pending", data: void 0, error: null }), gcTimeout: void 0, asyncStatus: shallowRef("idle"), when: 0, vars, key, meta: options.meta ?? {}, options, ext: {} })))); /** * Ensures a mutation entry in the cache by assigning it an `id` and a `key` based on `vars`. Usually, a mutation is ensured twice * * @param entry - entry to ensure * @param vars - variables to call the mutation with */ function ensure(entry, vars) { const options = entry.options; const id = nextMutationId++; const key = options.key && toValueWithArgs(options.key, vars); entry = entry.id ? (untrack(entry), create(options, key, vars)) : entry; entry.id = id; entry.key = key; entry.vars = vars; cachesRaw.set(id, entry); triggerCache(); return entry; } /** * Ensures a mutation created with {@link defineMutation} is present in the cache. If it's not, it creates a new one. * @param fn - function that defines the mutation */ const ensureDefinedMutation = action((fn) => { let entry = defineMutationMap.get(fn); if (!entry) { entry = scope.run(() => [null, effectScope()]); entry[0] = app.runWithContext(() => entry[1].run(fn)); defineMutationMap.set(fn, entry); } else entry[1].resume(); return entry; }); /** * Gets a single mutation entry from the cache based on the ID of the mutation. * * @param id - the ID of the mutation */ function get(id) { return caches.value.get(id); } /** * Sets the state of a query entry in the cache and updates the * {@link UseQueryEntry['pending']['when'] | `when` property}. This action is * called every time the cache state changes and can be used by plugins to * detect changes. * * @param entry - the entry of the query to set the state * @param state - the new state of the entry */ const setEntryState = action((entry, state) => { entry.state.value = state; entry.when = Date.now(); }); /** * Removes a mutation entry from the cache if it has an id. If it doesn't then it does nothing. * * @param entry - the entry of the mutation to remove */ const remove = action((entry) => { cachesRaw.delete(entry.id); triggerCache(); }); /** * Returns all the entries in the cache that match the filters. * Note that you can have multiple entries with the exact same key if they * were called multiple times. * * @param filters - filters to apply to the entries */ const getEntries = action((filters = {}) => { return [...find(caches.value, filters.key)].filter((entry) => (filters.status == null || entry.state.value.status === filters.status) && (!filters.predicate || filters.predicate(entry))); }); /** * Untracks a mutation entry, scheduling garbage collection. * * @param entry - the entry of the mutation to untrack */ const untrack = action((entry) => { if (entry.gcTimeout) return; if (Number.isFinite(entry.options.gcTime)) entry.gcTimeout = setTimeout(() => { remove(entry); }, entry.options.gcTime); }); /** * Mutate a previously ensured mutation entry. * * @param entry - the entry to mutate */ async function mutate(entry) { const { vars, options } = entry; if (process.env.NODE_ENV !== "production") { const key = entry.key?.join("/"); const keyMessage = key ? `with key "${key}"` : "without a key"; if (entry.id === 0) console.error(`[@pinia/colada] A mutation entry ${keyMessage} was mutated before being ensured. If you are manually calling the "mutationCache.mutate()", you should always ensure the entry first If not, this is probably a bug. Please, open an issue on GitHub with a boiled down reproduction.`); if (entry.state.value.status !== "pending" || entry.asyncStatus.value === "loading") console.error(`[@pinia/colada] A mutation entry ${keyMessage} was reused. If you are manually calling the "mutationCache.mutate()", you should always ensure the entry first: "mutationCache.mutate(mutationCache.ensure(entry, vars))". If not this is probably a bug. Please, open an issue on GitHub with a boiled down reproduction.`); } entry.asyncStatus.value = "loading"; let currentData; let currentError; let context = { entry }; try { const globalOnMutateContext = globalOptions.onMutate?.(vars, context); context = { entry, ...globalOnMutateContext instanceof Promise ? await globalOnMutateContext : globalOnMutateContext }; const onMutateContext = await options.onMutate?.(vars, context); context = { ...context, ...onMutateContext }; const newData = currentData = await options.mutation(vars, context); await globalOptions.onSuccess?.(newData, vars, context); await options.onSuccess?.(newData, vars, context); setEntryState(entry, { status: "success", data: newData, error: null }); } catch (newError) { currentError = newError; await globalOptions.onError?.(currentError, vars, context); await options.onError?.(currentError, vars, context); setEntryState(entry, { status: "error", data: entry.state.value.data, error: currentError }); throw newError; } finally { await globalOptions.onSettled?.(currentData, currentError, vars, context); await options.onSettled?.(currentData, currentError, vars, context); entry.asyncStatus.value = "idle"; } return currentData; } return { caches, create, ensure, ensureDefinedMutation, mutate, remove, extend, get, setEntryState, getEntries, untrack, /** * Scope to track effects and components that use the mutation cache. * @internal */ _s: scope }; }); /** * Checks if the given object is a mutation cache. Used in SSR to apply custom serialization. * * @param cache - the object to check * * @see {@link MutationCache} */ function isMutationCache(cache) { return typeof cache === "object" && !!cache && cache.$id === "_pc_mutation"; } //#endregion //#region src/use-mutation.ts /** * Setups a mutation. * * @param options - Options to create the mutation * * @example * ```ts * const queryCache = useQueryCache() * const { mutate, status, error } = useMutation({ * mutation: (id: number) => fetch(`/api/todos/${id}`), * onSuccess() { * queryCache.invalidateQueries('todos') * }, * }) * ``` */ function useMutation(options) { const mutationCache = useMutationCache(); const hasCurrentInstance = getCurrentInstance(); const currentEffect = getCurrentScope(); const mergedOptions = { ...useMutationOptions(), onMutate: void 0, onSuccess: void 0, onError: void 0, onSettled: void 0, ...options }; const entry = shallowRef(mutationCache.create(mergedOptions)); const extensions = {}; for (const key in entry.value.ext) extensions[key] = computed({ get: () => toValue(entry.value.ext[key]), set(value) { const target = entry.value.ext[key]; if (isRef(target)) target.value = value; else entry.value.ext[key] = value; } }); if (hasCurrentInstance) onUnmounted(() => { mutationCache.untrack(entry.value); }); if (currentEffect) onScopeDispose(() => { mutationCache.untrack(entry.value); }); const state = computed(() => entry.value.state.value); const status = computed(() => state.value.status); const data = computed(() => state.value.data); const error = computed(() => state.value.error); const asyncStatus = computed(() => entry.value.asyncStatus.value); const variables = computed(() => entry.value.vars); async function mutateAsync(vars) { return mutationCache.mutate(entry.value = mutationCache.ensure(entry.value, vars)); } function mutate(vars) { mutateAsync(vars).catch(noop); } function reset() { entry.value = mutationCache.create(mergedOptions); } return { ...extensions, state, data, isLoading: computed(() => asyncStatus.value === "loading"), status, variables, asyncStatus, error, mutate, mutateAsync, reset }; } //#endregion //#region src/define-mutation.ts function defineMutation(optionsOrSetup) { const setupFn = typeof optionsOrSetup === "function" ? optionsOrSetup : () => useMutation(optionsOrSetup); let refCount = 0; return () => { const mutationCache = useMutationCache(); const currentScope = getCurrentInstance() || getCurrentScope(); const [ret, scope] = mutationCache.ensureDefinedMutation(setupFn); if (currentScope) { refCount++; onScopeDispose(() => { if (--refCount < 1) scope.pause(); }); } else if (process.env.NODE_ENV !== "production") console.warn(`[@pinia/colada]: defineMutation() composable was called outside of a component or effect scope. The mutation effects will never be cleaned up, which may cause memory leaks. Make sure to call it inside a component setup, an effect scope, or a store.`); return ret; }; } //#endregion //#region src/define-mutation-options.ts /** * Define type-safe mutation options. Can be static or dynamic. Define the * arguments based on what's needed. Use an object if you need multiple * properties. * * @param setupOrOptions - The mutation options or a function that returns the mutation options. * * @example * ```ts * import { defineMutationOptions } from '@pinia/colada' * * const deleteItemMutation = defineMutationOptions({ * mutation: (id: number) => fetch(`/api/items/${id}`, { method: 'DELETE' }), * }) * * // use in a component * const { mutate } = useMutation(deleteItemMutation) * ``` * * @__NO_SIDE_EFFECTS__ */ function defineMutationOptions(setupOrOptions) { return setupOrOptions; } //#endregion //#region src/pinia-colada.ts /** * Plugin that installs the Query and Mutation plugins alongside some extra plugins. * * @see {@link QueryPlugin} to only install the Query plugin. * * @param app - Vue App * @param options - Pinia Colada options */ const PiniaColada = (app, options = {}) => { const { pinia = app.config.globalProperties.$pinia, plugins, queryOptions, mutationOptions } = options; app.provide(USE_QUERY_OPTIONS_KEY, { ...USE_QUERY_DEFAULTS, ...queryOptions }); app.provide(USE_MUTATION_OPTIONS_KEY, { ...USE_MUTATION_DEFAULTS, ...mutationOptions }); if (process.env.NODE_ENV !== "production" && !pinia) throw new Error("[@pinia/colada] root pinia plugin not detected. Make sure you install pinia before installing the \"PiniaColada\" plugin or to manually pass the pinia instance."); const queryCache = useQueryCache(pinia); plugins?.forEach((plugin) => plugin({ scope: queryCache._s, queryCache, pinia })); }; //#endregion //#region src/plugins/query-hooks.ts /** * Allows to add global hooks to all queries: * - `onSuccess`: called when a query is successful * - `onError`: called when a query throws an error * - `onSettled`: called when a query is settled (either successfully or with an error) * @param options - Pinia Colada Query Hooks plugin options * * @example * ```ts * import { PiniaColada, PiniaColadaQueryHooksPlugin } from '@pinia/colada' * * const app = createApp(App) * // app setup with other plugins * app.use(PiniaColada, { * plugins: [ * PiniaColadaQueryHooksPlugin({ * onError(error, entry) { * // ... * }, * }), * ], * }) * ``` */ function PiniaColadaQueryHooksPlugin(options) { return ({ queryCache }) => { queryCache.$onAction(({ name, after, onError, args }) => { if (name === "fetch") { const [entry] = args; after(async ({ data }) => { await options.onSuccess?.(data, entry); options.onSettled?.(data, null, entry); }); onError(async (error) => { await options.onError?.(error, entry); options.onSettled?.(void 0, error, entry); }); } }); }; } //#endregion //#region src/plugins/no-gc-ssr.ts /** * Forces `gcTime: false` on every query and mutation entry, so no garbage * collection timers are scheduled. Designed for SSR / SSG / build pipelines * where pending `setTimeout` calls keep the Node.js process alive after * rendering completes, and where setTimeout closures retain entry memory * across requests. * * Apply conditionally — usually only on the server. * * @example * ```ts * import { PiniaColada, PiniaColadaSSRNoGc } from '@pinia/colada' * * app.use(PiniaColada, { * plugins: import.meta.env.SSR ? [PiniaColadaSSRNoGc()] : [], * }) * ``` */ function PiniaColadaSSRNoGc() { return ({ queryCache, pinia }) => { queryCache.$onAction(({ name, after }) => { if (name === "ensure") after((entry) => { if (entry.options) entry.options.gcTime = false; }); }); useMutationCache(pinia).$onAction(({ name, after }) => { if (name === "extend") after((entry) => { if (entry.options) entry.options.gcTime = false; }); }); }; } //#endregion export { PiniaColada, PiniaColadaQueryHooksPlugin, PiniaColadaSSRNoGc, queryEntry_toJSON as _queryEntry_toJSON, toValueWithArgs as _toValueWithArgs, defineInfiniteQueryOptions, defineMutation, defineMutationOptions, defineQuery, defineQueryOptions, hydrateQueryCache, isMutationCache, isQueryCache, serializeQueryCache, setInfiniteQueryData, toCacheKey, useInfiniteQuery, useMutation, useMutationCache, useQuery, useQueryCache, useQueryState }; //# sourceMappingURL=index.mjs.map