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 Auto Refetch
+
+
+
+
+
+
+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'