diff --git a/plugins/auto-refetch/README.md b/plugins/auto-refetch/README.md new file mode 100644 index 0000000..e713600 --- /dev/null +++ b/plugins/auto-refetch/README.md @@ -0,0 +1,44 @@ +

+ Pinia Colada logo + Pinia Colada Auto Refetch +

+ + + npm package + + +Automatically refetch queries when they become stale in Pinia Colada. + +## Installation + +```sh +npm install @pinia/colada-plugin-auto-refetch +``` + +## Usage + +```js +import { PiniaColadaAutoRefetch } from '@pinia/colada-plugin-auto-refetch' + +// Pass the plugin to Pinia Colada options +app.use(PiniaColada, { + // ... + plugins: [ + PiniaColadaAutoRefetch({ autoRefetch: true }), // enable globally + ], +}) +``` + +You can customize the refetch behavior individually for each query with the `autoRefetch` option: + +```ts +useQuery({ + key: ['todos'], + query: getTodos, + autoRefetch: true, // override local autoRefetch +}) +``` + +## License + +[MIT](http://opensource.org/licenses/MIT) diff --git a/plugins/auto-refetch/package.json b/plugins/auto-refetch/package.json new file mode 100644 index 0000000..a9674ef --- /dev/null +++ b/plugins/auto-refetch/package.json @@ -0,0 +1,71 @@ +{ + "name": "@pinia/colada-plugin-auto-refetch", + "type": "module", + "publishConfig": { + "access": "public" + }, + "version": "0.0.1", + "description": "Automatically refetch queries when they become stale in Pinia Colada", + "author": { + "name": "Yusuf Mansur Ozer", + "email": "ymansurozer@gmail.com" + }, + "license": "MIT", + "homepage": "https://github.com/posva/pinia-colada/plugins/auto-refetch#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/posva/pinia-colada.git" + }, + "bugs": { + "url": "https://github.com/posva/pinia-colada/issues" + }, + "keywords": [ + "pinia", + "plugin", + "data", + "fetching", + "query", + "mutation", + "cache", + "layer", + "refetch" + ], + "sideEffects": false, + "exports": { + ".": { + "types": { + "import": "./dist/index.d.ts", + "require": "./dist/index.d.cts" + }, + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "typesVersions": { + "*": { + "*": [ + "./dist/*", + "./*" + ] + } + }, + "files": [ + "LICENSE", + "README.md", + "dist" + ], + "scripts": { + "build": "tsup", + "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s --commit-path . -l @pinia/colada-plugin-auto-refetch -r 1", + "test": "vitest --ui" + }, + "peerDependencies": { + "@pinia/colada": "workspace:^" + }, + "devDependencies": { + "@pinia/colada": "workspace:^" + } +} diff --git a/plugins/auto-refetch/src/auto-refetch.spec.ts b/plugins/auto-refetch/src/auto-refetch.spec.ts new file mode 100644 index 0000000..92a3399 --- /dev/null +++ b/plugins/auto-refetch/src/auto-refetch.spec.ts @@ -0,0 +1,160 @@ +/** + * @vitest-environment happy-dom + */ +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { createPinia } from 'pinia' +import { useQuery, PiniaColada } from '@pinia/colada' +import type { UseQueryOptions } from '@pinia/colada' +import type { PiniaColadaAutoRefetchOptions } from '.' +import { PiniaColadaAutoRefetch } from '.' + +describe('Auto Refetch plugin', () => { + beforeEach(() => { + vi.clearAllTimers() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + enableAutoUnmount(afterEach) + + function mountQuery( + queryOptions?: Partial, + pluginOptions?: PiniaColadaAutoRefetchOptions, + ) { + const query = vi.fn(async () => 'result') + const wrapper = mount( + defineComponent({ + template: '
', + setup() { + return useQuery({ + query, + key: ['test'], + ...queryOptions, + }) + }, + }), + { + global: { + plugins: [ + createPinia(), + [PiniaColada, { + plugins: [PiniaColadaAutoRefetch({ autoRefetch: true, ...pluginOptions })], + ...pluginOptions, + }], + ], + }, + }, + ) + + return { wrapper, query } + } + + it('automatically refetches when stale time is reached', async () => { + const { query } = mountQuery({ + staleTime: 1000, + }) + + // Wait for initial query + await flushPromises() + expect(query).toHaveBeenCalledTimes(1) + + // Advance time past stale time in one go + vi.advanceTimersByTime(1000) + await flushPromises() + + expect(query).toHaveBeenCalledTimes(2) + }) + + it('respects enabled option globally', async () => { + const { query } = mountQuery( + { + staleTime: 1000, + }, + { + autoRefetch: false, + }, + ) + + await flushPromises() + expect(query).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(2000) + await flushPromises() + expect(query).toHaveBeenCalledTimes(1) + }) + + it('respects disabled option per query', async () => { + const { query } = mountQuery({ + staleTime: 1000, + autoRefetch: false, + }) + + await flushPromises() + expect(query).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(2000) + await flushPromises() + expect(query).toHaveBeenCalledTimes(1) + }) + + it('avoids refetching an unactive query', async () => { + const { wrapper, query } = mountQuery({ + staleTime: 1000, + }) + + await flushPromises() + expect(query).toHaveBeenCalledTimes(1) + + wrapper.unmount() + vi.advanceTimersByTime(2000) + await flushPromises() + expect(query).toHaveBeenCalledTimes(1) + }) + + it('does not refetch when staleTime is not set', async () => { + const { query } = mountQuery({}) + + await flushPromises() + expect(query).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(2000) + await flushPromises() + expect(query).toHaveBeenCalledTimes(1) + }) + + it('resets the stale timer when a new request occurs', async () => { + const { query } = mountQuery({ + staleTime: 1000, + }) + + // Wait for initial query + await flushPromises() + expect(query).toHaveBeenCalledTimes(1) + + // Advance time partially (500ms) + vi.advanceTimersByTime(500) + + // Manually trigger a new request + query.mockImplementationOnce(async () => 'new result') + await query() + await flushPromises() + expect(query).toHaveBeenCalledTimes(2) + + // Advance time to what would have been the original stale time (500ms more) + vi.advanceTimersByTime(500) + await flushPromises() + // Should not have triggered another request yet + expect(query).toHaveBeenCalledTimes(2) + + // Advance to the new stale time (500ms more to reach full 1000ms from last request) + vi.advanceTimersByTime(500) + await flushPromises() + // Now it should have triggered another request + expect(query).toHaveBeenCalledTimes(3) + }) +}) diff --git a/plugins/auto-refetch/src/index.ts b/plugins/auto-refetch/src/index.ts new file mode 100644 index 0000000..aec7f6d --- /dev/null +++ b/plugins/auto-refetch/src/index.ts @@ -0,0 +1,123 @@ +import type { PiniaColadaPlugin, UseQueryEntry, UseQueryOptions } from '@pinia/colada' +import type { MaybeRefOrGetter } from 'vue' +import { toValue } from 'vue' + +export interface PiniaColadaAutoRefetchOptions { + /** + * Whether to enable auto refresh by default. + * @default false + */ + autoRefetch?: boolean +} + +const createMapKey = (options: UseQueryOptions) => toValue(options.key).join('/') + +/** + * Plugin that automatically refreshes queries when they become stale + */ +export function PiniaColadaAutoRefetch( + options: PiniaColadaAutoRefetchOptions = {}, +): PiniaColadaPlugin { + const { autoRefetch = false } = options + + return ({ queryCache }) => { + // Skip setting auto-refetch on the server + if (typeof document === 'undefined') return + + // Keep track of active entries and their timeouts + const refetchTimeouts = new Map() + + queryCache.$onAction(({ name, args, after }) => { + function scheduleRefetch(options: UseQueryOptions) { + const key = createMapKey(options) + + // Always clear existing timeout first + clearExistingTimeout(key) + + // Schedule next refetch + const timeout = setTimeout(() => { + if (options) { + const entry: UseQueryEntry | undefined = queryCache.getEntries({ + key: toValue(options.key), + })?.[0] + if (entry && entry.active) { + queryCache.refresh(entry).catch(console.error) + } + refetchTimeouts.delete(key) + } + }, options.staleTime) + + refetchTimeouts.set(key, timeout) + } + + function clearExistingTimeout(key: string) { + const existingTimeout = refetchTimeouts.get(key) + if (existingTimeout) { + clearTimeout(existingTimeout) + refetchTimeouts.delete(key) + } + } + + /** + * Whether to schedule a refetch for the given entry + */ + function shouldScheduleRefetch(options: UseQueryOptions) { + const queryEnabled = toValue(options.autoRefetch) ?? autoRefetch + const staleTime = options.staleTime + return Boolean(queryEnabled && staleTime) + } + + // Trigger a fetch on creation to enable auto-refetch on initial load + if (name === 'ensure') { + const [options] = args + if (!shouldScheduleRefetch(options)) return + + scheduleRefetch(options) + } + + // Set up auto-refetch on every fetch + if (name === 'fetch') { + const [entry] = args + + // Clear any existing timeout before scheduling a new one + if (entry.options) { + const key = createMapKey(entry.options) + clearExistingTimeout(key) + } + + after(async () => { + if (!entry.options) return + if (!shouldScheduleRefetch(entry.options)) return + + // Schedule new refetch only if the entry is still active + if (entry.active) { + scheduleRefetch(entry.options) + } + }) + } + + // Clean up timeouts when entry is removed + if (name === 'remove') { + const [entry] = args + if (!entry.options) return + + const key = createMapKey(entry.options) + const timeout = refetchTimeouts.get(key) + if (timeout) { + clearTimeout(timeout) + refetchTimeouts.delete(key) + } + } + }) + } +} + +// Add types for the new option +declare module '@pinia/colada' { + interface UseQueryOptions { + /** + * Whether to automatically refresh this query when it becomes stale. + */ + autoRefetch?: MaybeRefOrGetter + } +} diff --git a/plugins/auto-refetch/tsup.config.ts b/plugins/auto-refetch/tsup.config.ts new file mode 100644 index 0000000..49d270e --- /dev/null +++ b/plugins/auto-refetch/tsup.config.ts @@ -0,0 +1,19 @@ +import { type Options, defineConfig } from 'tsup' + +const commonOptions = { + // splitting: false, + sourcemap: true, + format: ['cjs', 'esm'], + external: ['vue', 'pinia', '@pinia/colada'], + dts: true, + target: 'esnext', +} satisfies Options + +export default defineConfig([ + { + ...commonOptions, + clean: true, + entry: ['src/index.ts'], + globalName: 'PiniaColadaAutoRefetch', + }, +]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4d5403..80c520d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,12 @@ importers: specifier: workspace:^ version: link:../.. + plugins/auto-refetch: + devDependencies: + '@pinia/colada': + specifier: workspace:^ + version: link:../.. + plugins/cache-persister: devDependencies: '@pinia/colada': diff --git a/scripts/release.mjs b/scripts/release.mjs index 55a4115..5472100 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import fs from 'node:fs/promises' import { existsSync } from 'node:fs' import { dirname, join } from 'node:path'