├── .npmrc ├── lib ├── cache │ ├── index.ts │ └── adapters │ │ └── map.ts ├── vite-env.d.ts ├── config │ ├── enviroment.ts │ ├── index.ts │ ├── global-state.ts │ ├── injection-keys.ts │ └── swr-config.ts ├── index.ts ├── composables │ ├── index.ts │ ├── scope-state │ │ ├── index.ts │ │ └── scope-state.spec.ts │ ├── swr │ │ ├── swr-ssr.spec.ts │ │ ├── swr-cache.spec.ts │ │ ├── swr-types.test-d.ts │ │ ├── swr-fetcher.spec.ts │ │ ├── swr-deduping.spec.ts │ │ ├── swr-mutate.spec.ts │ │ ├── swr-callbacks.spec.ts │ │ ├── swr-revalidate.spec.ts │ │ ├── index.ts │ │ ├── swr-stale.spec.ts │ │ └── swr.spec.ts │ └── global-swr-config │ │ ├── index.ts │ │ └── global-swr-config.spec.ts ├── types │ ├── index.ts │ ├── utils.ts │ ├── generics.ts │ └── lib.ts ├── utils │ ├── type-assertions │ │ └── index.ts │ ├── index.ts │ ├── chain-fn │ │ ├── index.ts │ │ └── chain-fn.spec.ts │ ├── check-types │ │ ├── index.ts │ │ └── check-types.spec.ts │ ├── merge-config │ │ ├── index.ts │ │ └── merge-config.spec.ts │ ├── subscribe-key │ │ ├── index.ts │ │ └── subscribe-key.spec.ts │ ├── serialize-key │ │ ├── index.ts │ │ └── serialize-key.spec.ts │ ├── with-setup │ │ └── index.ts │ ├── test │ │ ├── test.spec.ts │ │ └── index.ts │ └── stable-hash │ │ ├── index.ts │ │ └── stable-hash.spec.ts └── devtools.ts ├── .husky ├── pre-commit └── commit-msg ├── tsconfig.eslint.json ├── commitlint.config.js ├── .eslintrc.json ├── .releaserc.json ├── .prettierrc.json ├── .gitignore ├── docs ├── typescript.md ├── index.md ├── introduction.md ├── data-fetching.md ├── global-configuration.md ├── conditional-fetching.md ├── revalidation.md ├── .vitepress │ └── config.js ├── options.md ├── getting-started.md ├── error-handling.md └── mutation.md ├── .github └── workflows │ ├── pull-request.yml │ ├── deploy-docs.yml │ └── release.yml ├── tsconfig.json ├── vite.config.ts ├── LICENSE ├── README.md ├── vitest.setup.ts ├── CONTRIBUTING.md └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /lib/cache/index.ts: -------------------------------------------------------------------------------- 1 | export * from './adapters/map'; 2 | -------------------------------------------------------------------------------- /lib/cache/adapters/map.ts: -------------------------------------------------------------------------------- 1 | export const MapAdapter = Map; 2 | -------------------------------------------------------------------------------- /lib/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /lib/config/enviroment.ts: -------------------------------------------------------------------------------- 1 | export { isClient } from '@vueuse/core'; 2 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './composables'; 2 | export * from './devtools'; 3 | -------------------------------------------------------------------------------- /lib/composables/index.ts: -------------------------------------------------------------------------------- 1 | export * from './swr'; 2 | export * from './global-swr-config'; 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm exec lint-staged 5 | -------------------------------------------------------------------------------- /lib/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './generics'; 2 | export * from './lib'; 3 | export * from './utils'; 4 | -------------------------------------------------------------------------------- /lib/types/utils.ts: -------------------------------------------------------------------------------- 1 | export type OmitFirstArrayIndex = T extends [any, ...infer Rest] ? Rest : never; 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm exec commitlint --edit "${1}" 5 | -------------------------------------------------------------------------------- /lib/utils/type-assertions/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | export * from 'tsd'; 3 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["lib", "vite.config.ts", "commitlint.config.js", "vitest.setup.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | ignores: [(message) => message.includes('[skip ci]')], 4 | }; 5 | -------------------------------------------------------------------------------- /lib/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './swr-config'; 2 | export * from './injection-keys'; 3 | export * from './enviroment'; 4 | export * from './global-state'; 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "@edumudu/eslint-config/vue-ts", 4 | "parserOptions": { 5 | "project": "./tsconfig.eslint.json" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './check-types'; 2 | export * from './stable-hash'; 3 | export * from './serialize-key'; 4 | export * from './merge-config'; 5 | export * from './chain-fn'; 6 | export * from './subscribe-key'; 7 | -------------------------------------------------------------------------------- /lib/types/generics.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from 'vue'; 2 | 3 | export type AnyFunction = (...args: any[]) => any | Promise; 4 | 5 | export type MaybeRef = T | Ref; 6 | export type DeepMaybeRef = { [K in keyof T]: MaybeRef }; 7 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"], 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | "@semantic-release/release-notes-generator", 6 | "@semantic-release/npm", 7 | "@semantic-release/github", 8 | "@semantic-release/git" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /lib/config/global-state.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue'; 2 | 3 | import { CacheProvider, CacheState, ScopeState } from '@/types'; 4 | 5 | /** 6 | * Holds the scope's states, isolated using cacheProvider as key 7 | */ 8 | export const globalState = reactive(new WeakMap, ScopeState>()); 9 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "arrowParens": "always", 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "bracketSpacing": true, 7 | "semi": true, 8 | "tabWidth": 2, 9 | "bracketSameLine": false, 10 | "vueIndentScriptAndStyle": false, 11 | "singleAttributePerLine": true 12 | } 13 | -------------------------------------------------------------------------------- /lib/config/injection-keys.ts: -------------------------------------------------------------------------------- 1 | import { DeepReadonly, InjectionKey, Ref } from 'vue'; 2 | 3 | import { SWRConfig } from '@/types'; 4 | 5 | /** 6 | * Key for provide and get current context configs 7 | * @internal 8 | */ 9 | export const globalConfigKey = Symbol('SWR global config key') as InjectionKey< 10 | DeepReadonly> 11 | >; 12 | -------------------------------------------------------------------------------- /lib/utils/chain-fn/index.ts: -------------------------------------------------------------------------------- 1 | import { AnyFunction } from '@/types'; 2 | 3 | export const chainFns = (...fns: F) => { 4 | const validFns = fns.filter((maybeFn: T | undefined): maybeFn is T => !!maybeFn); 5 | 6 | return (...params: Parameters>) => 7 | validFns.forEach((fn) => fn(...params)); 8 | }; 9 | -------------------------------------------------------------------------------- /lib/utils/check-types/index.ts: -------------------------------------------------------------------------------- 1 | import type { AnyFunction } from '@/types'; 2 | 3 | export const isFunction = (value: unknown): value is T => 4 | typeof value === 'function'; 5 | 6 | export const isDate = (date: any): date is T => date.constructor === Date; 7 | 8 | export const isUndefined = (value: T | undefined): value is undefined => 9 | typeof value === 'undefined'; 10 | 11 | export { isObject } from '@vueuse/core'; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .eslintcache 27 | 28 | # test 29 | coverage 30 | 31 | # docs 32 | docs/.vitepress/dist/* 33 | docs/.vitepress/cache/* 34 | -------------------------------------------------------------------------------- /lib/config/swr-config.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue'; 2 | 3 | import { SWRConfig } from '@/types'; 4 | import { MapAdapter } from '@/cache'; 5 | 6 | export const defaultConfig: SWRConfig = { 7 | cacheProvider: reactive(new MapAdapter()), 8 | revalidateOnFocus: true, 9 | revalidateOnReconnect: true, 10 | revalidateIfStale: true, 11 | focusThrottleInterval: 5000, 12 | dedupingInterval: 2000, 13 | refreshInterval: 0, 14 | refreshWhenHidden: false, 15 | refreshWhenOffline: false, 16 | }; 17 | -------------------------------------------------------------------------------- /docs/typescript.md: -------------------------------------------------------------------------------- 1 | # Typescript 2 | 3 | `swr-vue` is written in typescript and is type safe out of the box. 4 | 5 | ## Generics 6 | 7 | To specify the type of `data` by default, it will use the return type of `fetcher` (with `undefined` for the non-ready state) as the data type, but you can also pass it as a parameter: 8 | 9 | ```ts 10 | // Specify the data type: 11 | // `fetchUser` is `(endpoint: string) => User`. 12 | const { data } = useSWR('/api/user', fetchUser) 13 | 14 | // Specify the error type: 15 | // `fetchUser` is `(endpoint: string) => User`. 16 | const { data } = useSWR('/api/user', fetchUser) 17 | ``` 18 | -------------------------------------------------------------------------------- /lib/utils/merge-config/index.ts: -------------------------------------------------------------------------------- 1 | import { SWRConfig } from '@/types'; 2 | 3 | import { chainFns } from '../chain-fn'; 4 | 5 | export const mergeConfig = , D extends Partial>( 6 | fallbackConfig: T, 7 | config: D, 8 | ) => { 9 | const onSuccess = [config.onSuccess, fallbackConfig.onSuccess].filter(Boolean); 10 | const onError = [config.onError, fallbackConfig.onError].filter(Boolean); 11 | 12 | return { 13 | ...fallbackConfig, 14 | ...config, 15 | 16 | onSuccess: onSuccess.length > 0 ? chainFns(...onSuccess) : undefined, 17 | onError: onError.length > 0 ? chainFns(...onError) : undefined, 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /lib/utils/subscribe-key/index.ts: -------------------------------------------------------------------------------- 1 | export type SubscribeCallback = () => void; 2 | export type SubscribeCallbackCache = Map; 3 | 4 | export const unsubscribeCallback = ( 5 | key: string, 6 | cb: SubscribeCallback, 7 | cbCache: SubscribeCallbackCache, 8 | ) => { 9 | const callbacks = cbCache.get(key) || []; 10 | const newCallbacks = callbacks.filter((currentCb) => currentCb !== cb); 11 | 12 | cbCache.set(key, newCallbacks); 13 | }; 14 | 15 | export const subscribeCallback = ( 16 | key: string, 17 | cb: SubscribeCallback, 18 | cbCache: SubscribeCallbackCache, 19 | ) => { 20 | const callbacks = cbCache.get(key) || []; 21 | 22 | cbCache.set(key, [...callbacks, cb]); 23 | 24 | return () => unsubscribeCallback(key, cb, cbCache); 25 | }; 26 | -------------------------------------------------------------------------------- /lib/utils/serialize-key/index.ts: -------------------------------------------------------------------------------- 1 | import { createUnrefFn } from '@vueuse/core'; 2 | 3 | import type { Key, KeyArguments } from '@/types'; 4 | 5 | import { isFunction } from '../check-types'; 6 | import { stableHash } from '../stable-hash'; 7 | 8 | export const serializeKey = createUnrefFn((key: Key) => { 9 | let sanitizedKey: KeyArguments = key; 10 | 11 | if (isFunction(sanitizedKey)) { 12 | try { 13 | sanitizedKey = sanitizedKey(); 14 | } catch { 15 | sanitizedKey = ''; 16 | } 17 | } 18 | 19 | const isEmptyArray = Array.isArray(sanitizedKey) && sanitizedKey.length === 0; 20 | 21 | return { 22 | key: !isEmptyArray && !!sanitizedKey ? stableHash(sanitizedKey) : '', 23 | args: Array.isArray(sanitizedKey) ? sanitizedKey : [sanitizedKey], 24 | }; 25 | }); 26 | -------------------------------------------------------------------------------- /lib/utils/chain-fn/chain-fn.spec.ts: -------------------------------------------------------------------------------- 1 | import { chainFns } from '.'; 2 | 3 | describe('chainFn', () => { 4 | it('should call all cahined functions', () => { 5 | const functions = [vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn()]; 6 | const chainedFn = chainFns(...functions); 7 | 8 | chainedFn(); 9 | 10 | functions.forEach((fn) => expect(fn).toHaveBeenCalledOnce()); 11 | }); 12 | 13 | it('should call all chained functions with the same arguments', () => { 14 | const functions = [vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn()]; 15 | const chainedFn = chainFns(...functions); 16 | 17 | chainedFn('arg1', 'arg2', 3); 18 | 19 | functions.forEach((fn) => expect(fn).toHaveBeenCalledWith('arg1', 'arg2', 3)); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /lib/utils/with-setup/index.ts: -------------------------------------------------------------------------------- 1 | import { App, createApp } from 'vue'; 2 | 3 | /** 4 | * Use this function for test coposables that depend on lifecycles or provide/inject 5 | */ 6 | export function withSetup) => any>( 7 | composable: C, 8 | ): [ReturnType, App] { 9 | let result: ReturnType = null as any; 10 | 11 | const app = createApp({ 12 | name: 'TestApp', 13 | setup() { 14 | result = composable(app); 15 | 16 | // suppress missing template warning 17 | // eslint-disable-next-line @typescript-eslint/no-empty-function 18 | return () => {}; 19 | }, 20 | }); 21 | 22 | app.mount(document.createElement('div')); 23 | // return the result and the app instance 24 | // for testing provide / unmount 25 | return [result, app]; 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: PR-Checks 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | verify_pr: 7 | name: "Verify PR" 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: pnpm/action-setup@v2 13 | 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: '16' 18 | cache: 'pnpm' 19 | cache-dependency-path: '**/pnpm-lock.yaml' 20 | 21 | - name: Install dependencies 22 | run: pnpm i --frozen-lockfile 23 | 24 | - name: Check lint 25 | run: pnpm run lint:js 26 | 27 | - name: Check Tests 28 | run: pnpm run test 29 | 30 | - name: Build lib 31 | run: pnpm run build 32 | 33 | - name: Check Types 34 | run: pnpm run test:types 35 | -------------------------------------------------------------------------------- /lib/utils/test/test.spec.ts: -------------------------------------------------------------------------------- 1 | import { dispatchEvent } from '.'; 2 | 3 | describe('test utils', () => { 4 | describe('dispatchEvent', () => { 5 | it.each([ 6 | ['click', document.createElement('div')], 7 | ['click', window], 8 | ['focus', window], 9 | ['blur', window], 10 | ['click', document], 11 | ['focus', document], 12 | ['blur', document], 13 | ['click', document.body], 14 | ['click', document.createElement('span')], 15 | ['click', document.createElement('section')], 16 | ])('should dispatch event "%s" in the passed DOM element %s', (eventName, el) => { 17 | const onEvent = vi.fn(); 18 | 19 | el.addEventListener(eventName, onEvent); 20 | dispatchEvent(eventName, el); 21 | 22 | expect(onEvent).toHaveBeenCalledOnce(); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: SWR Vue 7 | text: Vue Stale While Revalidate 8 | tagline: Docs of SWR Vue 9 | actions: 10 | - theme: brand 11 | text: Get Started 12 | link: /getting-started 13 | - theme: alt 14 | text: API Examples 15 | link: /options 16 | - theme: alt 17 | text: View on Github 18 | link: https://github.com/edumudu/swr-vue 19 | 20 | features: 21 | - title: Looking for help? 22 | details: In case you need help, you can start a discussion in the GitHub Discussions. 23 | link: https://github.com/edumudu/swr-vue/discussions 24 | linkText: Start a discussion 25 | icon: 📝 26 | - title: Type Strong 27 | details: Written in TypeScript, with full TS docs 28 | icon: 🦾 29 | --- 30 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # SWR Vue 2 | 3 | > This project is inspired by the awesome work [vercel/swr](https://swr.vercel.app/pt-BR) 4 | 5 | SWR derives from `stale-while-revalidate`, a HTTP cache technique. This technique consists in first return the data cached(stale), after send a request(revalidate) and finaly return the newly data. 6 | 7 | > You can read more about this [here](https://web.dev/stale-while-revalidate/) 8 | 9 | ## Features 10 | 11 | - Fast and reusable data fetching 12 | - Typescript friendly 13 | - Protocol agnostic 14 | - Request lib agnostic 15 | - Integrated cache 16 | - Optimistic UI 17 | - Request deduplication 18 | - Revalidation on focus and network recovery 19 | - Polling 20 | - Configurable 21 | 22 | ## Comunity 23 | 24 | Feel free to join us to contribute or ask doubts in [Github discussions](https://github.com/edumudu/swr-vue/discussions) 25 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - '**/docs/**' 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: pnpm/action-setup@v2 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: '16' 21 | cache: 'pnpm' 22 | cache-dependency-path: '**/pnpm-lock.yaml' 23 | 24 | - name: Install dependencies 25 | run: pnpm i --frozen-lockfile 26 | 27 | - name: Build 28 | run: pnpm docs:build 29 | 30 | - name: Deploy 31 | uses: peaceiris/actions-gh-pages@v3 32 | with: 33 | github_token: ${{ secrets.GITHUB_TOKEN }} 34 | publish_dir: docs/.vitepress/dist 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "skipLibCheck": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["lib/*"], 20 | // https://github.com/microsoft/TypeScript/issues/29808#issuecomment-540292885 21 | "@vueuse/shared": ["node_modules/@vueuse/shared/"] 22 | }, 23 | "types": ["vite/client", "vitest/globals"], 24 | "declarationDir": "./dist/types", 25 | "declaration": true, 26 | }, 27 | "include": ["lib"] 28 | } 29 | -------------------------------------------------------------------------------- /docs/data-fetching.md: -------------------------------------------------------------------------------- 1 | # Data Fetching 2 | 3 | The basic use is of `useSWR` composable is: 4 | 5 | ```ts 6 | const { data, error } = useSWR(key, fetcher); 7 | ``` 8 | 9 | Here, the `fetcher` is a function and receives the key as the first argument, then return the data to be cached. 10 | 11 | The returned value will be avaiable in `data` and if fetcher throws, the error will be caught in `error`. 12 | 13 | ## Fetcher 14 | 15 | You can use any library to fetch the data. 16 | 17 | ### Native `fetch` 18 | 19 | ```ts 20 | import { useSWR } from 'swr-vue' 21 | 22 | const fetcher = url => fetch(url).then(response => response.json()) 23 | 24 | const { data, error } = useSWR('/api/todos', fetcher) 25 | ``` 26 | 27 | ### Axios 28 | 29 | ```ts 30 | import { useSWR } from 'swr-vue' 31 | import axios from 'axios' 32 | 33 | const fetcher = url => axios.get(url).then(res => res.data); 34 | 35 | const { data, error } = useSWR('/api/data', fetcher); 36 | ``` 37 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { resolve } from 'node:path'; 4 | 5 | import { defineConfig } from 'vite'; 6 | import ViteDts from 'vite-plugin-dts'; 7 | 8 | export default defineConfig({ 9 | plugins: [ViteDts({ outputDir: resolve(__dirname, 'dist', 'types') })], 10 | 11 | resolve: { 12 | alias: { 13 | '@': resolve(__dirname, 'lib'), 14 | }, 15 | }, 16 | 17 | build: { 18 | minify: false, 19 | 20 | lib: { 21 | entry: resolve(__dirname, 'lib/index.ts'), 22 | name: 'swr-vue', 23 | fileName: (format) => `swr-vue.${format}.js`, 24 | }, 25 | 26 | rollupOptions: { 27 | external: ['vue'], 28 | output: { 29 | globals: { 30 | vue: 'Vue', 31 | }, 32 | }, 33 | }, 34 | }, 35 | 36 | test: { 37 | globals: true, 38 | environment: 'jsdom', 39 | clearMocks: true, 40 | setupFiles: ['./vitest.setup.ts'], 41 | 42 | coverage: { 43 | provider: 'istanbul', 44 | }, 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /lib/composables/scope-state/index.ts: -------------------------------------------------------------------------------- 1 | import { computed, ComputedRef, toRefs, unref, watch } from 'vue'; 2 | import { MaybeRef, toReactive } from '@vueuse/core'; 3 | 4 | import { globalState } from '@/config'; 5 | import { CacheProvider, CacheState, ScopeState } from '@/types'; 6 | 7 | const initScopeState = (cacheProvider: CacheProvider) => { 8 | globalState.set(cacheProvider, { revalidateCache: new Map() }); 9 | }; 10 | 11 | export const useScopeState = (_cacheProvider: MaybeRef>) => { 12 | const cacheProvider = computed(() => unref(_cacheProvider)); 13 | const scopeState = computed(() => globalState.get(cacheProvider.value)); 14 | 15 | const onScopeStateChange = () => { 16 | if (!scopeState.value) initScopeState(cacheProvider.value); 17 | }; 18 | 19 | watch(scopeState, onScopeStateChange, { immediate: true }); 20 | 21 | return { 22 | scopeState: scopeState as ComputedRef, 23 | ...toRefs(toReactive(scopeState as ComputedRef)), 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /docs/global-configuration.md: -------------------------------------------------------------------------------- 1 | # Global Configuration 2 | 3 | You can use `configureGlobalSWR` function to create a configuration scope (this uses [provide/inject](https://vuejs.org/guide/components/provide-inject.html) under the hood) and provide a sharable configuration to all composables under this scope. 4 | 5 | ```ts 6 | import { configureGlobalSWR } from 'swr-vue'; 7 | 8 | configureGlobalSWR(options) 9 | ``` 10 | 11 | > [Available options](./options.md) 12 | 13 | ## Extra options 14 | 15 | ### Cache Provider 16 | 17 | `configureGlobalSWR` also accepts an optional cache provider. 18 | 19 | ```ts 20 | configureGlobalSWR({ cacheProvider: new Map() }); 21 | ``` 22 | 23 | ## Access Current Scope Configurations 24 | 25 | You can use the `useSWRConfig` composable to get the scope configurations, as well as mutate and cache. 26 | 27 | ```ts 28 | import { useSWRConfig } from 'swr-vue'; 29 | 30 | const { config, mutate } = useSWRConfig(); 31 | ``` 32 | 33 | Nested configurations will be extended. If no `configureGlobalSWR` is used, it will return the default ones. 34 | -------------------------------------------------------------------------------- /lib/composables/swr/swr-ssr.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment node 3 | */ 4 | 5 | import { SWRComposableConfig } from '@/types'; 6 | import { useSetupInServer } from '@/utils/test'; 7 | 8 | import { useSWR } from '.'; 9 | 10 | const defaultKey = 'defaultKey'; 11 | const defaultFetcher = vi.fn((key: string) => key); 12 | 13 | describe('useSWR - Cache', () => { 14 | beforeEach(() => { 15 | vi.useRealTimers(); 16 | }); 17 | 18 | it('should not throw when rendered in server with default options', async () => { 19 | await useSetupInServer(() => { 20 | expect(() => useSWR(defaultKey, defaultFetcher)).not.toThrow(); 21 | }); 22 | 23 | expect.assertions(1); 24 | }); 25 | 26 | it('should not throw when rendered in server with options using browser APIs', async () => { 27 | const config: SWRComposableConfig = { 28 | revalidateOnFocus: true, 29 | revalidateOnReconnect: true, 30 | }; 31 | 32 | await useSetupInServer(() => { 33 | expect(() => useSWR(defaultKey, defaultFetcher, config)).not.toThrow(); 34 | }); 35 | 36 | expect.assertions(1); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Eduardo Wesley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**/docs/**' 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | token: ${{ secrets.CI_GITHUB_TOKEN }} 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v2 23 | with: 24 | node-version: 16 25 | 26 | - name: Configure corepack 27 | run: corepack enable 28 | 29 | - name: Install dependencies 30 | run: pnpm i 31 | 32 | - name: Check lint 33 | run: pnpm run lint:js 34 | 35 | - name: Check Tests 36 | run: pnpm run test 37 | 38 | - name: Build lib 39 | run: pnpm run build 40 | 41 | - name: Check Types 42 | run: pnpm run test:types 43 | 44 | - name: Release 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 48 | run: pnpm run generate:release 49 | -------------------------------------------------------------------------------- /lib/composables/swr/swr-cache.spec.ts: -------------------------------------------------------------------------------- 1 | import { nextTick, ref } from 'vue'; 2 | 3 | import { SWRComposableConfig } from '@/types'; 4 | import { mockedCache, useInjectedSetup } from '@/utils/test'; 5 | import { serializeKey } from '@/utils'; 6 | 7 | import { useSWR } from '.'; 8 | import { configureGlobalSWR } from '../global-swr-config'; 9 | 10 | const cacheProvider = mockedCache; 11 | const defaultKey = 'defaultKey'; 12 | const defaultFetcher = vi.fn((key: string) => key); 13 | const defaultOptions: SWRComposableConfig = { dedupingInterval: 0 }; 14 | 15 | describe('useSWR - Cache', () => { 16 | beforeEach(() => { 17 | vi.useRealTimers(); 18 | cacheProvider.clear(); 19 | 20 | vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); 21 | vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); 22 | }); 23 | 24 | it('should set an cache instance to key if not exists', async () => { 25 | const key = ref(defaultKey); 26 | const keyTwo = 'key-two'; 27 | 28 | useInjectedSetup( 29 | () => configureGlobalSWR({ cacheProvider }), 30 | () => useSWR(key, defaultFetcher, defaultOptions), 31 | ); 32 | 33 | expect(cacheProvider.has(serializeKey(key.value).key)).toBeTruthy(); 34 | 35 | key.value = keyTwo; 36 | 37 | await nextTick(); 38 | expect(cacheProvider.has(serializeKey(key.value).key)).toBeTruthy(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /docs/conditional-fetching.md: -------------------------------------------------------------------------------- 1 | # Conditional Fetching 2 | 3 | ## Conditional 4 | 5 | Use any [falsy](https://developer.mozilla.org/docs/Glossary/Falsy) value or pass a function as key to conditionally fetch data. If the function throws or returns a [falsy](https://developer.mozilla.org/docs/Glossary/Falsy) value, the `useSWR` will not start the request. 6 | 7 | ```ts 8 | // conditionally fetch 9 | const { data } = useSWR(shouldFetch ? '/api/todos' : null, fetcher); 10 | 11 | // ...or return a falsy value 12 | const { data } = useSWR(() => shouldFetch ? '/api/todos' : null, fetcher); 13 | 14 | // ...or throw an error when todo.id is not defined 15 | const { data } = useSWR(() => `/api/todos/${todo.id}`, fetcher); 16 | ``` 17 | 18 | ## Dependent 19 | 20 | `useSWR` also allows you to fetch data that depends on other data. It allows serial fetching when a piece of dynamic data is required for the next data fetch to happen. 21 | 22 | ```vue 23 | 32 | 33 | 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/revalidation.md: -------------------------------------------------------------------------------- 1 | # Automatic Revalidation 2 | 3 | If you want to manually revalidate the data, you can disable revaldation with [options](./options.md) and use [mutation](./mutation.md). 4 | 5 | ## Revalidate on Focus 6 | 7 | When you re-focus a page or switch between tabs, `useSWR` automatically revalidates data. 8 | 9 | This can be useful to synchronize immediately with the latest state. This is useful for refreshing data in situations like stale mobile tabs or a laptop that has gone to sleep. 10 | 11 | > This feature is enabled by default but can be disabled via the [revalidateOnFocus](./options.md) option. 12 | 13 | ## Revalidate on Interval 14 | 15 | In many cases, data changes due to multiple devices, multiple users, multiple tabs. How can we update the data on the screen over time? 16 | 17 | `useSWR` will give you the option to automatically refetch data. It’s will only happen if the component associated with the hook is mounted. 18 | 19 | > This feature is disabled by default but can be enabled via the [refreshInterval](./options.md) option. 20 | 21 | > There are also options such as `refreshWhenHidden` and `refreshWhenOffline`. Both are disabled by default so `useSWR` will not fetch when the webpage is not on screen, or there is no network connection 22 | 23 | ## Revalidate on Reconnect 24 | 25 | It is also useful revalidate when the user is back online. This scenario happens a lot when the user unlocks their computer, but the internet is not yet connected at the same time. 26 | 27 | > This feature is enabled by default but can be disabled via the [revalidateOnReconnect](./options.md) option. 28 | -------------------------------------------------------------------------------- /lib/composables/scope-state/scope-state.spec.ts: -------------------------------------------------------------------------------- 1 | import { useInjectedSetup, mockedCache } from '@/utils/test'; 2 | import { globalState } from '@/config'; 3 | 4 | import { useScopeState } from '.'; 5 | 6 | const cacheProvider = mockedCache; 7 | 8 | const useScopeStateWrapped = () => 9 | useInjectedSetup( 10 | () => {}, 11 | () => useScopeState(cacheProvider), 12 | ); 13 | 14 | describe('useSWR - mutate', () => { 15 | beforeEach(() => { 16 | cacheProvider.clear(); 17 | }); 18 | 19 | it('should init scope in case that the scope does not have values', async () => { 20 | const { scopeState, revalidateCache } = useScopeStateWrapped(); 21 | 22 | expect(scopeState.value).not.toBeUndefined(); 23 | expect(scopeState.value.revalidateCache).toBeInstanceOf(Map); 24 | 25 | // toRefs values 26 | expect(revalidateCache.value).toBeInstanceOf(Map); 27 | }); 28 | 29 | it('should return values for current scope', async () => { 30 | const key = 'key1'; 31 | const revalidateCb = vi.fn(); 32 | 33 | globalState.set(cacheProvider, { 34 | revalidateCache: new Map([[key, [revalidateCb]]]), 35 | }); 36 | 37 | const { scopeState, revalidateCache } = useScopeStateWrapped(); 38 | 39 | expect(scopeState.value.revalidateCache).toBeInstanceOf(Map); 40 | expect(scopeState.value.revalidateCache.size).toBe(1); 41 | expect(scopeState.value.revalidateCache.get(key)).toHaveLength(1); 42 | expect(scopeState.value.revalidateCache.get(key)).toContain(revalidateCb); 43 | 44 | // toRefs values 45 | expect(revalidateCache.value).toBeInstanceOf(Map); 46 | expect(revalidateCache.value.size).toBe(1); 47 | expect(revalidateCache.value.get(key)).toHaveLength(1); 48 | expect(revalidateCache.value.get(key)).toContain(revalidateCb); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /lib/utils/serialize-key/serialize-key.spec.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | 3 | import { stableHash as hash } from '@/utils'; 4 | 5 | import { serializeKey } from '.'; 6 | 7 | describe('serializeKey', () => { 8 | it('should return the generated key', () => { 9 | const sourceKey = 'key-home'; 10 | const { key } = serializeKey(sourceKey); 11 | 12 | expect(key).toBe(hash(sourceKey)); 13 | }); 14 | 15 | it('should return empty key when fail the function key', () => { 16 | const sourceKey = () => { 17 | throw new Error('Failed'); 18 | }; 19 | const { key } = serializeKey(sourceKey); 20 | 21 | expect(key).toBe(''); 22 | }); 23 | 24 | it('should return the result of the function key', () => { 25 | const sourceKey = () => 'return'; 26 | const { key } = serializeKey(sourceKey); 27 | 28 | expect(key).toBe('"return"'); 29 | }); 30 | 31 | it.each([ 32 | '', 33 | false as const, 34 | null, 35 | undefined, 36 | [], 37 | () => '', 38 | () => false as const, 39 | () => null, 40 | () => undefined, 41 | () => [], 42 | ])( 43 | 'should return empty string if key resolves to a falsy value or empty array: "%s"', 44 | (sourceKey) => { 45 | const { key } = serializeKey(sourceKey); 46 | 47 | expect(key).toBe(''); 48 | }, 49 | ); 50 | 51 | it.each([ 52 | ref(''), 53 | ref(false as const), 54 | ref(null), 55 | ref(undefined), 56 | ref([]), 57 | ref(() => ''), 58 | ref(() => false as const), 59 | ref(() => null), 60 | ref(() => undefined), 61 | ref(() => []), 62 | ])( 63 | 'should return empty string if ref key resolves to a falsy value or empty array', 64 | (sourceKey) => { 65 | const { key } = serializeKey(sourceKey); 66 | 67 | expect(key).toBe(''); 68 | }, 69 | ); 70 | }); 71 | -------------------------------------------------------------------------------- /lib/composables/swr/swr-types.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from '@/utils/type-assertions'; 2 | 3 | import { useSWR } from '.'; 4 | 5 | type Person = { 6 | name: string; 7 | id: string; 8 | age: number; 9 | }; 10 | 11 | // Infered types 12 | { 13 | const { data: data1 } = useSWR('key', () => 'string'); 14 | const { data: data2 } = useSWR('key', () => 1); 15 | const { data: data3 } = useSWR('key', () => ['string']); 16 | const { data: data4 } = useSWR('key', () => false); 17 | const { data: data5 } = useSWR('key', () => ({} as Person)); 18 | 19 | expectType(data1.value); 20 | expectType(data2.value); 21 | expectType(data3.value); 22 | expectType(data4.value); 23 | expectType(data5.value); 24 | } 25 | 26 | // Generics 27 | { 28 | const { data: data1, error: error1 } = useSWR('key', () => 2); 29 | 30 | expectType(data1.value); 31 | expectType(error1.value); 32 | 33 | interface CustomError extends Error { 34 | name: 'custom-error'; 35 | } 36 | 37 | const { data: data2, error: error2 } = useSWR('key', () => 'return'); 38 | 39 | expectType(data2.value); 40 | expectType(error2.value); 41 | 42 | interface CustomError extends Error { 43 | name: 'custom-error'; 44 | } 45 | 46 | /// @ts-expect-error Generic type differ from return type 47 | useSWR('key', () => 2); 48 | /// @ts-expect-error Generic type differ from return type 49 | useSWR('key', () => ''); 50 | /// @ts-expect-error Generic type differ from return type 51 | useSWR('key', () => ''); 52 | // Gneric match return error - Expect no error 53 | useSWR('key', () => ({} as Person)); 54 | } 55 | -------------------------------------------------------------------------------- /lib/utils/check-types/check-types.spec.ts: -------------------------------------------------------------------------------- 1 | import { isFunction, isDate, isUndefined } from '.'; 2 | 3 | beforeEach(() => { 4 | vi.useRealTimers(); 5 | }); 6 | 7 | describe('isFunction', () => { 8 | it('should return true if value is a function', () => { 9 | expect(isFunction(() => ({}))).toBe(true); 10 | expect(isFunction(() => [])).toBe(true); 11 | expect(isFunction(() => true)).toBe(true); 12 | expect(isFunction(() => 'foo')).toBe(true); 13 | expect(isFunction(true)).toBe(false); 14 | expect(isFunction([])).toBe(false); 15 | expect(isFunction({})).toBe(false); 16 | expect(isFunction('')).toBe(false); 17 | expect(isFunction(1)).toBe(false); 18 | expect(isFunction(new Date())).toBe(false); 19 | }); 20 | }); 21 | 22 | describe('isDate', () => { 23 | it('should return true if value is a date', () => { 24 | expect(isDate(new Date())).toBe(true); 25 | expect(isDate(new Date('1970-01-01T00:00:00.000Z'))).toBe(true); 26 | expect(isDate('1970-01-01T00:00:00.000Z')).toBe(false); 27 | expect(isDate(() => ({}))).toBe(false); 28 | expect(isDate(true)).toBe(false); 29 | expect(isDate([])).toBe(false); 30 | expect(isDate({})).toBe(false); 31 | expect(isDate('')).toBe(false); 32 | expect(isDate(1)).toBe(false); 33 | }); 34 | }); 35 | 36 | describe('isUndefined', () => { 37 | it('should return true if value is undefined', () => { 38 | // Primitives 39 | expect(isUndefined('key')).toBeFalsy(); 40 | expect(isUndefined(1)).toBeFalsy(); 41 | expect(isUndefined('false')).toBeFalsy(); 42 | expect(isUndefined(false)).toBeFalsy(); 43 | expect(isUndefined(true)).toBeFalsy(); 44 | expect(isUndefined(null)).toBeFalsy(); 45 | expect(isUndefined('null')).toBeFalsy(); 46 | expect(isUndefined(undefined)).toBeTruthy(); 47 | expect(isUndefined(NaN)).toBeFalsy(); 48 | expect(isUndefined(Infinity)).toBeFalsy(); 49 | expect(isUndefined('')).toBeFalsy(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /docs/.vitepress/config.js: -------------------------------------------------------------------------------- 1 | import { version } from '../../package.json'; 2 | 3 | export default { 4 | lang: 'en-US', 5 | title: 'SWR Vue', 6 | lastUpdated: true, 7 | base: '/swr-vue/', 8 | 9 | themeConfig: { 10 | nav: nav(), 11 | 12 | sidebar: sidebar(), 13 | 14 | socialLinks: [ 15 | { icon: 'github', link: 'https://github.com/edumudu/swr-vue' } 16 | ], 17 | 18 | footer: { 19 | message: 'Released under the MIT License.', 20 | }, 21 | 22 | editLink: { 23 | pattern: 'https://github.com/edumudu/swr-vue/edit/main/docs/:path', 24 | text: 'Edit this page on GitHub' 25 | }, 26 | } 27 | } 28 | 29 | function nav() { 30 | return [ 31 | { 32 | text: version, 33 | items: [ 34 | { 35 | text: 'Release Notes', 36 | link: 'https://github.com/edumudu/swr-vue/releases' 37 | }, 38 | { 39 | text: 'Contributing', 40 | link: 'https://github.com/edumudu/swr-vue/blob/main/CONTRIBUTING.md' 41 | }, 42 | ], 43 | }, 44 | ] 45 | } 46 | 47 | function sidebar() { 48 | return [ 49 | { 50 | text: 'Introduction', 51 | items: [ 52 | { text: 'About', link: '/introduction' }, 53 | { text: 'Getting started', link: '/getting-started' }, 54 | ] 55 | }, 56 | { 57 | text: 'Usage', 58 | items: [ 59 | { text: 'Mutation', link: '/mutation' }, 60 | { text: 'Global Configuration', link: '/global-configuration' }, 61 | { text: 'Data Fetching', link: '/data-fetching' }, 62 | { text: 'Error Handling', link: '/error-handling' }, 63 | { text: 'Revalidation', link: '/revalidation' }, 64 | { text: 'Conditional Fetching', link: '/conditional-fetching' }, 65 | { text: 'Typescript', link: '/typescript' }, 66 | ] 67 | }, 68 | { 69 | text: 'API', 70 | items: [ 71 | { text: 'Options', link: '/options' }, 72 | ] 73 | }, 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /docs/options.md: -------------------------------------------------------------------------------- 1 | # API Options 2 | 3 | ```js 4 | const { data, error, isValidating, mutate } = useSWR(key, fetcher, options) 5 | ``` 6 | 7 | ## Parameters 8 | 9 | - `key` - A unique key for the request. Can be an `string | array | function | falsy value` 10 | - `fetcher` - A Promise that resolves to the data that you want to use 11 | - `options` - (optional) an object of options for this SWR composable 12 | 13 | ## Return Values 14 | 15 | - `data` - data for the given key resolved by fetcher (or undefined if not loaded) 16 | - `error` - error thrown by fetcher (or undefined if nothing threw) 17 | - `isValidating` - if there's the first request or revalidation going on 18 | - `mutate(updateFn, options?)` - function to mutate the cached data. [More details](./mutation.md) 19 | 20 | ## Options 21 | 22 | - `revalidateOnFocus = true` - Automatically revalidate when window gets focused 23 | - `revalidateOnReconnect = true` - Automatically revalidate when the browser regains a network connection 24 | - `revalidateIfStale = true` - Automatically revalidate if there is stale data 25 | - `dedupingInterval = 2000` - dedupe requests with the same key in this time span in milliseconds 26 | - `fallback` - a key-value object of multiple fallback data 27 | - `fallbackData` - initial data to be returned (this has priority over `fallback` option) 28 | - `focusThrottleInterval = 5000` - only revalidate on focus once during a time span in milliseconds 29 | - `refreshInterval = 0` - [(details)](./revalidation.md#revalidate-on-interval) 30 | - Disabled by default: `refreshInterval = 0` 31 | - If set to a number, polling interval in milliseconds 32 | - If set to a function, the function will receive the latest data and should return the interval in milliseconds 33 | - `refreshWhenHidden = false` - polling when the window is invisible (if refreshInterval is enabled), 34 | - `refreshWhenOffline = false` - polling when the browser is offline (determined by navigator.onLine), 35 | - `onSuccess(data, key, config)` - callback function when a request finishes successfully 36 | - `onError(err, key, config)` - callback function when a request returns an error 37 | -------------------------------------------------------------------------------- /lib/devtools.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable eslint-comments/disable-enable-pair */ 2 | /* eslint-disable no-param-reassign */ 3 | import { App, PluginDescriptor, setupDevtoolsPlugin, DevtoolsPluginApi } from '@vue/devtools-api'; 4 | import { watch } from 'vue'; 5 | 6 | import { defaultConfig } from '@/config'; 7 | 8 | type AnyFunction = (...args: any[]) => any; 9 | 10 | type HookType< 11 | TApi extends DevtoolsPluginApi, 12 | THookName extends keyof TApi['on'], 13 | T = TApi['on'][THookName], 14 | > = T extends AnyFunction ? Parameters[0] : never; 15 | 16 | const inspectorId = 'swr-vue-inspector'; 17 | 18 | export function setupDevtools(app: App) { 19 | const setupPluginSettings: PluginDescriptor = { 20 | id: 'swr-vue-devtools-plugin', 21 | label: 'Stale-While-Revalidate Vue', 22 | packageName: 'swr-vue', 23 | homepage: 'https://edumudu.github.io/swr-vue/', 24 | app, 25 | }; 26 | 27 | const isSwrInspector = (id: string) => id === inspectorId; 28 | 29 | setupDevtoolsPlugin(setupPluginSettings, (api) => { 30 | const handleGetInspectorTree: HookType = (payload) => { 31 | if (!isSwrInspector(inspectorId)) return; 32 | 33 | payload.rootNodes = [ 34 | { 35 | id: 'root', 36 | label: 'Global scope', 37 | }, 38 | ]; 39 | }; 40 | 41 | const handleGetInspectorState: HookType = (payload) => { 42 | if (!isSwrInspector(inspectorId) || payload.nodeId !== 'root') return; 43 | 44 | const entries = Array.from(defaultConfig.cacheProvider.entries(), ([key, value]) => ({ 45 | key, 46 | value, 47 | })); 48 | 49 | payload.state = { 50 | 'Global cache': entries, 51 | }; 52 | }; 53 | 54 | api.addInspector({ 55 | id: inspectorId, 56 | label: 'SWR', 57 | icon: 'archive', 58 | }); 59 | 60 | api.on.getInspectorTree(handleGetInspectorTree); 61 | api.on.getInspectorState(handleGetInspectorState); 62 | 63 | watch(defaultConfig.cacheProvider, () => api.sendInspectorState(inspectorId), { deep: true }); 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Installation 4 | 5 | Inside your vue project directory, run the following: 6 | 7 | With pnpm 8 | ```bash 9 | pnpm add swr-vue 10 | ``` 11 | 12 | With yarn 13 | ```bash 14 | yarn add swr-vue 15 | ``` 16 | 17 | With npm 18 | ```bash 19 | npm install swr-vue 20 | ``` 21 | 22 | ## Quick Start 23 | 24 | First, you need to create a `fetcher` function, for normal RESTful APIs with JSON data, you can just create a wrapper of the native `fetch` function: 25 | 26 | ```ts 27 | const fetcher = (...args) => fetch(...args).then(res => res.json()); 28 | ``` 29 | 30 | then you import you import `useSWR` and can start using inside components 31 | 32 | 33 | 34 | ```vue 35 | 38 | 39 | 44 | ``` 45 | 46 | Normally, there're 3 possible states of a request: "pending", "ready", or "error". You can use the variables `data` and `error` to determine the current state of the request. 47 | 48 | ## Reusability 49 | 50 | When building an app, you may need to reuse the same data in diferent places. You can create a reusable composable on top of `useSWR` as following: 51 | 52 | ```js 53 | const useArticle = (id) => { 54 | const { data, error } = useSWR(`/api/article/${id}`, fetcher); 55 | 56 | return { 57 | error, 58 | article: data, 59 | hasError: computed(() => !!error.value), 60 | isLoading: computed(() => !error.value && !data.value), 61 | } 62 | } 63 | ``` 64 | 65 | and use it in the components 66 | 67 | ```vue 68 | 72 | 73 | 78 | ``` 79 | 80 | By doing this you can fetch data in a more declarative way. Forget about start the request, update loading, and return the final result. 81 | You just need to specify the data used by the component 82 | -------------------------------------------------------------------------------- /docs/error-handling.md: -------------------------------------------------------------------------------- 1 | # Error Handling 2 | 3 | If an error is thrown inside [fetcher](./data-fetching), it will be returned as `error` by the composable 4 | 5 | ```ts 6 | // ... 7 | const { data, error } = useSWR('/api/user', fetcher); 8 | ``` 9 | 10 | The `error` object will be defined if the fetch promise is rejected or `fetcher` contains an syntax error. 11 | 12 | > Note that `data` and `error` can exist at the same time. So the UI can display the existing data, while knowing the upcoming request has failed. 13 | 14 | ## Status Code and Error Object 15 | 16 | Sometimes we want the API to return an error along with the status code. Both are useful for the client. 17 | 18 | We may arrange for our `fetcher` to return additional information. If the status code is not 2xx, we treat it as an error even though it can be parsed as JSON: 19 | 20 | ```ts 21 | const fetcher = async url => { 22 | const res = await fetch(url) 23 | 24 | // If the status code is not in the range 200-299, 25 | // we still try to parse and throw it. 26 | if (!res.ok) { 27 | const error = new Error('An error occurred while fetching the data.') 28 | 29 | // Attach extra info to the error object. 30 | error.info = await res.json() 31 | error.status = res.status 32 | 33 | throw error 34 | } 35 | 36 | return res.json() 37 | } 38 | 39 | // ... 40 | const { error } = useSWR('/api/user', fetcher) 41 | // error.value.info === { 42 | // message: "You are not authorized to access this resource.", 43 | // documentation_url: "..." 44 | // } 45 | // error.value.status === 403 46 | ``` 47 | 48 | ## Global Error Report 49 | 50 | You can always get the `error` object inside the component reactively. But in case you want to handle the error globally, to notify the UI to show a toast or a snackbar, or report it somewhere such as [Sentry](https://sentry.io/), there's an `onError` event: 51 | 52 | ```ts 53 | configureGlobalSWR({ 54 | onError: (error, key) => { 55 | // We can send the error to Sentry, 56 | 57 | if (error.status !== 403 && error.status !== 404) { 58 | // or show a notification UI. 59 | } 60 | } 61 | }) 62 | ``` 63 | 64 | This is also available in the composable options. 65 | 66 | In case that you pass an global `onError` and in a composable inside the same context also pass a `onError` the two of them will be called. First the local one followed by the global 67 | -------------------------------------------------------------------------------- /lib/utils/merge-config/merge-config.spec.ts: -------------------------------------------------------------------------------- 1 | import { mergeConfig } from '.'; 2 | 3 | describe('mergeConfig', () => { 4 | it.each([ 5 | [{}, { revalidateIfStale: true }, { revalidateIfStale: true }], 6 | [{ revalidateIfStale: true }, {}, { revalidateIfStale: true }], 7 | [{ revalidateIfStale: true }, { revalidateIfStale: true }, { revalidateIfStale: true }], 8 | [{ revalidateIfStale: false }, { revalidateIfStale: true }, { revalidateIfStale: true }], 9 | [{ revalidateIfStale: true }, { revalidateIfStale: false }, { revalidateIfStale: false }], 10 | [ 11 | { revalidateIfStale: false, revalidateOnFocus: true }, 12 | { revalidateIfStale: false }, 13 | { revalidateIfStale: false, revalidateOnFocus: true }, 14 | ], 15 | [ 16 | { revalidateIfStale: true }, 17 | { revalidateIfStale: true, revalidateOnFocus: true }, 18 | { revalidateIfStale: true, revalidateOnFocus: true }, 19 | ], 20 | ])('should merge two configs: %s', (obj1, obj2, expectedResult) => { 21 | expect(mergeConfig(obj1, obj2)).toEqual(expectedResult); 22 | }); 23 | 24 | it('should also merge callbacks', () => { 25 | const globalConfig = Object.freeze({ 26 | onError: vi.fn(), 27 | onSuccess: vi.fn(), 28 | }); 29 | 30 | const localConfig = Object.freeze({ 31 | onError: vi.fn(), 32 | onSuccess: vi.fn(), 33 | }); 34 | 35 | const { onError, onSuccess } = mergeConfig(globalConfig, localConfig); 36 | 37 | onError(); 38 | onSuccess(); 39 | 40 | expect(globalConfig.onSuccess).toHaveBeenCalledOnce(); 41 | expect(globalConfig.onError).toHaveBeenCalledOnce(); 42 | expect(localConfig.onSuccess).toHaveBeenCalledOnce(); 43 | expect(localConfig.onError).toHaveBeenCalledOnce(); 44 | }); 45 | 46 | it('should not throw when one of the callbacks is missing', () => { 47 | const globalConfig = Object.freeze({ onSuccess: vi.fn() }); 48 | const localConfig = Object.freeze({ onError: vi.fn() }); 49 | 50 | const { onError, onSuccess } = mergeConfig(globalConfig, localConfig); 51 | 52 | onError(); 53 | onSuccess(); 54 | 55 | expect(globalConfig.onSuccess).toHaveBeenCalledOnce(); 56 | expect(localConfig.onError).toHaveBeenCalledOnce(); 57 | }); 58 | 59 | it('should return undefined when callback is missing in either configs', () => { 60 | const { onError, onSuccess } = mergeConfig({}, {}); 61 | 62 | expect(onError).toBeUndefined(); 63 | expect(onSuccess).toBeUndefined(); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Vue Swr 3 |

4 | 5 |

6 | 7 | 8 | 9 | 10 | 11 | 12 |

13 | 14 | ## Introduction 15 | 16 | `swr-vue` is a Vue composables library for data fetching. 17 | 18 | The name “**SWR**” is derived from `stale-while-revalidate`, a cache invalidation strategy popularized by [HTTP RFC 5861](https://tools.ietf.org/html/rfc5861). 19 | **SWR** first returns the data from cache (stale), then sends the request (revalidate), and finally comes with the up-to-date data again. 20 | 21 | #### Features 22 | 23 | - Fast and reusable data fetching 24 | - Transport and protocol agnostic 25 | - Built-in cache and request dedulication 26 | - Revalidation on focus 27 | - Revalidation on network recovery 28 | - Polling 29 | - Local mutation (Optimistic UI) 30 | - Type safe 31 | - Configurable 32 | 33 | For a more details you can visit the [documentation](https://edumudu.github.io/swr-vue/) 34 | 35 | If you are looking to contribute, please check [CONTRIBUTING.md](./CONTRIBUTING.md) 36 | 37 | ## Quick Start 38 | 39 | ```vue 40 | 51 | 52 | 57 | ``` 58 | 59 | In this example, the composable `useSWR` accepts a `key` and a `fetcher` function. 60 | The `key` is a unique identifier of the request, normally the URL of the API. And the `fetcher` accepts 61 | `key` as its parameter and returns the data asynchronously. 62 | 63 | `useSWR` returns 2 values: `data` and `error`. When the request is not yet finished, 64 | `data` and `error` will be `undefined`. And when we get a response, it sets `data` and `error` based on the result 65 | of `fetcher`. 66 | 67 | `fetcher` can be any asynchronous function, you can use your favourite data-fetching library to handle that part. 68 | 69 |
70 | 71 | ## Thanks 72 | `swr-vue` is inspired by these great works: 73 | 74 | - [vueuse](https://github.com/antfu/vueuse) 75 | - [vercel/swr](https://github.com/vercel/swr) 76 | 77 | ## License 78 | 79 | The [MIT](LICENSE) License. 80 | -------------------------------------------------------------------------------- /lib/utils/stable-hash/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Thanks to vercel/swr 3 | * https://github.com/vercel/swr/blob/main/_internal/utils/hash.ts 4 | */ 5 | import { createUnrefFn } from '@vueuse/core'; 6 | 7 | import { isUndefined } from '@/utils/check-types'; 8 | 9 | // use WeakMap to store the object->key mapping 10 | // so the objects can be garbage collected. 11 | // WeakMap uses a hashtable under the hood, so the lookup 12 | // complexity is almost O(1). 13 | const table = new WeakMap(); 14 | 15 | // counter of the key 16 | let counter = 0; 17 | 18 | // A stable hash implementation that supports: 19 | // - Fast and ensures unique hash properties 20 | // - Handles unserializable values 21 | // - Handles object key ordering 22 | // - Generates short results 23 | // 24 | // This is not a serialization function, and the result is not guaranteed to be 25 | // parsable. 26 | export const stableHash = createUnrefFn((arg: any): string => { 27 | const type = typeof arg; 28 | const constructor = arg && arg.constructor; 29 | const isDate = constructor === Date; 30 | 31 | let result: any; 32 | let index: any; 33 | 34 | if (Object(arg) === arg && !isDate && constructor !== RegExp) { 35 | // Object/function, not null/date/regexp. Use WeakMap to store the id first. 36 | // If it's already hashed, directly return the result. 37 | result = table.get(arg); 38 | if (result) return result; 39 | 40 | // Store the hash first for circular reference detection before entering the 41 | // recursive `stableHash` calls. 42 | // For other objects like set and map, we use this id directly as the hash. 43 | counter += 1; 44 | result = `${counter}~`; 45 | table.set(arg, result); 46 | 47 | if (constructor === Array) { 48 | // Array. 49 | result = '@'; 50 | 51 | for (index = 0; index < arg.length; index += 1) { 52 | result += `${stableHash(arg[index])},`; 53 | } 54 | 55 | table.set(arg, result); 56 | } 57 | 58 | if (constructor === Object) { 59 | // Object, sort keys. 60 | const keys = Object.keys(arg).sort(); 61 | 62 | result = '#'; 63 | index = keys.pop(); 64 | 65 | while (!isUndefined(index)) { 66 | if (!isUndefined(arg[index])) { 67 | result += `${index}:${stableHash(arg[index])},`; 68 | } 69 | 70 | index = keys.pop(); 71 | } 72 | 73 | table.set(arg, result); 74 | } 75 | } else { 76 | /* eslint-disable no-nested-ternary */ 77 | result = isDate 78 | ? arg.toJSON() 79 | : type === 'symbol' 80 | ? arg.toString() 81 | : type === 'string' 82 | ? JSON.stringify(arg) 83 | : `${arg}`; 84 | /* eslint-enable no-nested-ternary */ 85 | } 86 | 87 | return result; 88 | }); 89 | -------------------------------------------------------------------------------- /vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, beforeEach, vi } from 'vitest'; 2 | 3 | // Cleanup DOM between each test case 4 | // https://stackoverflow.com/a/72984394/12734929 5 | const isJsdomEnviroment = typeof document !== 'undefined'; 6 | 7 | const sideEffects = isJsdomEnviroment && { 8 | document: { 9 | addEventListener: { 10 | fn: document.addEventListener, 11 | refs: [], 12 | }, 13 | keys: Object.keys(document), 14 | }, 15 | window: { 16 | addEventListener: { 17 | fn: window.addEventListener, 18 | refs: [], 19 | }, 20 | keys: Object.keys(window), 21 | }, 22 | }; 23 | 24 | // Lifecycle Hooks 25 | // ----------------------------------------------------------------------------- 26 | beforeAll(async () => { 27 | if (!isJsdomEnviroment) return; 28 | 29 | // Spy addEventListener 30 | ['document', 'window'].forEach((obj) => { 31 | const { fn } = sideEffects[obj].addEventListener; 32 | const { refs } = sideEffects[obj].addEventListener; 33 | 34 | function addEventListenerSpy(type, listener, options) { 35 | // Store listener reference so it can be removed during reset 36 | refs.push({ type, listener, options }); 37 | // Call original window.addEventListener 38 | fn(type, listener, options); 39 | } 40 | 41 | // Add to default key array to prevent removal during reset 42 | sideEffects[obj].keys.push('addEventListener'); 43 | 44 | // Replace addEventListener with mock 45 | global[obj].addEventListener = addEventListenerSpy; 46 | }); 47 | }); 48 | 49 | // Reset JSDOM. This attempts to remove side effects from tests, however it does 50 | // not reset all changes made to globals like the window and document 51 | // objects. Tests requiring a full JSDOM reset should be stored in separate 52 | // files, which is only way to do a complete JSDOM reset with Jest. 53 | beforeEach(async () => { 54 | if (!isJsdomEnviroment) return; 55 | 56 | const rootElm = document.documentElement; 57 | 58 | // Remove attributes on root element 59 | [...rootElm.attributes].forEach((attr) => rootElm.removeAttribute(attr.name)); 60 | 61 | // Remove elements (faster than setting innerHTML) 62 | while (rootElm.firstChild) { 63 | rootElm.removeChild(rootElm.firstChild); 64 | } 65 | 66 | // Remove global listeners and keys 67 | ['document', 'window'].forEach((obj) => { 68 | const { refs } = sideEffects[obj].addEventListener; 69 | 70 | // Listeners 71 | while (refs.length) { 72 | const { type, listener, options } = refs.pop(); 73 | global[obj].removeEventListener(type, listener, options); 74 | } 75 | 76 | // Keys 77 | Object.keys(global[obj]) 78 | .filter((key) => !sideEffects[obj].keys.includes(key)) 79 | .forEach((key) => { 80 | delete global[obj][key]; 81 | }); 82 | }); 83 | 84 | // Restore base elements 85 | rootElm.innerHTML = ''; 86 | }); 87 | 88 | // Always use fake times by default 89 | beforeEach(() => { 90 | vi.useFakeTimers(); 91 | 92 | return () => vi.useRealTimers(); 93 | }); 94 | -------------------------------------------------------------------------------- /lib/composables/swr/swr-fetcher.spec.ts: -------------------------------------------------------------------------------- 1 | import { nextTick } from 'vue'; 2 | import flushPromises from 'flush-promises'; 3 | 4 | import { SWRComposableConfig, SWRFetcher } from '@/types'; 5 | import { useInjectedSetup, mockedCache } from '@/utils/test'; 6 | import { withSetup } from '@/utils/with-setup'; 7 | 8 | import { useSWR } from '.'; 9 | import { configureGlobalSWR } from '../global-swr-config'; 10 | 11 | const cacheProvider = mockedCache; 12 | const defaultKey = 'defaultKey'; 13 | const defaultOptions: SWRComposableConfig = { dedupingInterval: 0 }; 14 | 15 | describe('useSWR - Fetcher', () => { 16 | beforeEach(() => { 17 | vi.useRealTimers(); 18 | cacheProvider.clear(); 19 | 20 | vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); 21 | vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); 22 | }); 23 | 24 | it.each([ 25 | [() => 'returnedData'], 26 | [() => 1], 27 | [() => ({ id: 2 })], 28 | [() => Promise.resolve('returnedData')], 29 | ])( 30 | 'should set data returned from fetcher to data variable', 31 | async (fetcher: SWRFetcher) => { 32 | const { data, isValidating } = useInjectedSetup( 33 | () => configureGlobalSWR({ cacheProvider }), 34 | () => useSWR(defaultKey, fetcher, defaultOptions), 35 | ); 36 | 37 | expect(data.value).toEqual(undefined); 38 | 39 | await nextTick(); 40 | expect(data.value).toEqual(await fetcher()); 41 | expect(isValidating.value).toBeFalsy(); 42 | }, 43 | ); 44 | 45 | it.each([ 46 | // eslint-disable-next-line prefer-promise-reject-errors 47 | [() => Promise.reject('Error in fetcher 1'), 'Error in fetcher 1'], 48 | [() => Promise.reject(new Error('Error in fetcher 2')), new Error('Error in fetcher 2')], 49 | [ 50 | () => { 51 | throw new Error('Error in fetcher 3'); 52 | }, 53 | new Error('Error in fetcher 3'), 54 | ], 55 | ])('should set error when throw error in fetcher', async (fetcher, expectedError) => { 56 | const { data, isValidating, error } = useInjectedSetup( 57 | () => configureGlobalSWR({ cacheProvider }), 58 | () => useSWR(defaultKey, fetcher, defaultOptions), 59 | ); 60 | 61 | await flushPromises(); 62 | expect(data.value).toEqual(undefined); 63 | expect(error.value).toEqual(expectedError); 64 | expect(isValidating.value).toBeFalsy(); 65 | }); 66 | 67 | it.each([ 68 | '/user/me', 69 | 'https://google.com', 70 | ['/api/user', 4, 'authKey'], 71 | () => '/user/me', 72 | () => 'https://google.com', 73 | () => ['/api/user', 4, 'authKey'], 74 | ])('should call fetcher function passing keys as arguments: %#', async (key) => { 75 | const fetcher = vi.fn(); 76 | let expectedArgs = typeof key === 'function' ? key() : key; 77 | 78 | expectedArgs = Array.isArray(expectedArgs) ? expectedArgs : [expectedArgs]; 79 | 80 | withSetup(() => useSWR(key, fetcher, defaultOptions)); 81 | 82 | expect(fetcher).toHaveBeenCalled(); 83 | expect(fetcher).toHaveBeenCalledWith(...expectedArgs); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /lib/utils/subscribe-key/subscribe-key.spec.ts: -------------------------------------------------------------------------------- 1 | import { Key } from '@/types'; 2 | 3 | import { subscribeCallback, unsubscribeCallback, SubscribeCallbackCache } from '.'; 4 | 5 | describe('subscribe-key', () => { 6 | const defaultKey: Key = 'key'; 7 | const defaultCbCache: SubscribeCallbackCache = new Map(); 8 | 9 | beforeEach(() => { 10 | defaultCbCache.clear(); 11 | }); 12 | 13 | describe('subscribeCallback', () => { 14 | it('should subscribe callback to passed key', () => { 15 | const cb = vi.fn(); 16 | 17 | subscribeCallback(defaultKey, cb, defaultCbCache); 18 | 19 | expect(defaultCbCache.get(defaultKey)).toHaveLength(1); 20 | expect(defaultCbCache.get(defaultKey)).toContain(cb); 21 | }); 22 | 23 | it('should subscribe callback isolated by keys', () => { 24 | const keyA = 'key1'; 25 | const keyB = 'test'; 26 | 27 | subscribeCallback(keyA, vi.fn(), defaultCbCache); 28 | subscribeCallback(keyA, vi.fn(), defaultCbCache); 29 | subscribeCallback(keyA, vi.fn(), defaultCbCache); 30 | subscribeCallback(keyB, vi.fn(), defaultCbCache); 31 | 32 | expect(defaultCbCache.get(keyA)).toHaveLength(3); 33 | expect(defaultCbCache.get(keyB)).toHaveLength(1); 34 | }); 35 | 36 | it('should return an unsubscribe function', () => { 37 | const keyA = 'key1'; 38 | const keyB = 'test'; 39 | 40 | const cbA = vi.fn(); 41 | const cbB = vi.fn(); 42 | 43 | const unsubA = subscribeCallback(keyA, cbA, defaultCbCache); 44 | const unsubB = subscribeCallback(keyB, cbB, defaultCbCache); 45 | 46 | subscribeCallback(keyA, vi.fn(), defaultCbCache); 47 | subscribeCallback(keyA, vi.fn(), defaultCbCache); 48 | subscribeCallback(keyB, vi.fn(), defaultCbCache); 49 | 50 | unsubA(); 51 | expect(defaultCbCache.get(keyA)).toHaveLength(2); 52 | expect(defaultCbCache.get(keyA)).not.toContain(cbA); 53 | 54 | unsubB(); 55 | expect(defaultCbCache.get(keyB)).toHaveLength(1); 56 | expect(defaultCbCache.get(keyB)).not.toContain(cbB); 57 | }); 58 | }); 59 | 60 | describe('unsubscribeCallback', () => { 61 | it('should unsubscribe callback to passed key', () => { 62 | const cb = vi.fn(); 63 | 64 | subscribeCallback(defaultKey, cb, defaultCbCache); 65 | unsubscribeCallback(defaultKey, cb, defaultCbCache); 66 | 67 | expect(defaultCbCache.get(defaultKey)).not.toContain(cb); 68 | }); 69 | 70 | it('should unsubscribe isolated callbacks by key', () => { 71 | const keyA = 'key1'; 72 | const keyB = 'test'; 73 | const cb = vi.fn(); 74 | 75 | defaultCbCache.set(keyA, [vi.fn()]); 76 | defaultCbCache.set(keyB, [vi.fn(), cb, vi.fn()]); 77 | 78 | unsubscribeCallback(keyB, cb, defaultCbCache); 79 | 80 | expect(defaultCbCache.get(keyA)).toHaveLength(1); 81 | expect(defaultCbCache.get(keyB)).toHaveLength(2); 82 | }); 83 | 84 | it('should not throw when called upon empty key', () => { 85 | defaultCbCache.delete(defaultKey); 86 | 87 | expect(() => unsubscribeCallback(defaultKey, vi.fn(), defaultCbCache)).not.toThrow(); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /lib/composables/global-swr-config/index.ts: -------------------------------------------------------------------------------- 1 | import { computed, inject, provide, unref, shallowReadonly, toRefs, ref } from 'vue'; 2 | import { MaybeRef } from '@vueuse/core'; 3 | 4 | import { defaultConfig, globalConfigKey } from '@/config'; 5 | import { AnyFunction, CacheState, Key, SWRConfig } from '@/types'; 6 | import { isUndefined, mergeConfig, serializeKey, isFunction } from '@/utils'; 7 | import { useScopeState } from '@/composables/scope-state'; 8 | 9 | export type MutateOptions = { 10 | optimisticData?: unknown; 11 | rollbackOnError?: boolean; 12 | revalidate?: boolean; 13 | }; 14 | 15 | const createCacheState = (data: unknown): CacheState => ({ 16 | data, 17 | error: undefined, 18 | isValidating: false, 19 | fetchedIn: new Date(), 20 | }); 21 | 22 | export const useSWRConfig = () => { 23 | const contextConfig = inject( 24 | globalConfigKey, 25 | computed(() => defaultConfig), 26 | ); 27 | 28 | const cacheProvider = computed(() => contextConfig.value.cacheProvider); 29 | 30 | const { revalidateCache } = useScopeState(cacheProvider); 31 | 32 | const mutate = async | AnyFunction>( 33 | _key: Key, 34 | updateFnOrPromise?: U, 35 | options: MutateOptions = {}, 36 | ) => { 37 | const { key } = serializeKey(_key); 38 | const cache = cacheProvider.value; 39 | const cacheState = cache.get(key); 40 | const hasCache = !isUndefined(cacheState); 41 | const { optimisticData, rollbackOnError, revalidate = true } = options; 42 | 43 | const { data } = hasCache ? toRefs(cacheState) : { data: ref() }; 44 | const dataInCache = data.value; 45 | 46 | const resultPromise: unknown | Promise = isFunction(updateFnOrPromise) 47 | ? updateFnOrPromise(dataInCache) 48 | : updateFnOrPromise; 49 | 50 | if (optimisticData) { 51 | data.value = optimisticData; 52 | } 53 | 54 | try { 55 | data.value = isUndefined(resultPromise) ? data.value : await resultPromise; 56 | } catch (error) { 57 | if (rollbackOnError) { 58 | data.value = dataInCache; 59 | } 60 | 61 | throw error; 62 | } 63 | 64 | cache.set(key, hasCache ? cacheState : createCacheState(data)); 65 | 66 | const revalidationCallbackcs = revalidateCache.value.get(key) || []; 67 | 68 | if (revalidate && revalidationCallbackcs.length) { 69 | const [firstRevalidateCallback] = revalidationCallbackcs; 70 | 71 | await firstRevalidateCallback(); 72 | } 73 | 74 | return data.value; 75 | }; 76 | 77 | return { 78 | config: contextConfig, 79 | mutate, 80 | }; 81 | }; 82 | 83 | export const configureGlobalSWR = (config: MaybeRef>) => { 84 | const { config: contextConfig } = useSWRConfig(); 85 | const mergedConfig = computed(() => mergeConfig(contextConfig.value, unref(config))); 86 | 87 | provide(globalConfigKey, shallowReadonly(mergedConfig)); 88 | }; 89 | 90 | /** 91 | * @deprecated use `useSWRConfig` instead 92 | */ 93 | export const useGlobalSWRConfig = () => { 94 | const { config, ...rest } = useSWRConfig(); 95 | 96 | return { 97 | ...rest, 98 | globalConfig: config, 99 | }; 100 | }; 101 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Welcome to SWR Vue docs contributing guide 2 | 3 | Thank you for investing time in contributing to this project! 4 | 5 | In this guide you will get an overview of the contribution workflow from opening an issue, creating a PR, reviewing, and merging the PR. 6 | 7 | ## New contributor guide 8 | 9 | To get an overview of the project, read the [README](./README.md). Here are some resources to help you get started with open source contributions: 10 | 11 | - [Finding ways to contribute to open source on GitHub](https://docs.github.com/en/get-started/exploring-projects-on-github/finding-ways-to-contribute-to-open-source-on-github) 12 | - [Set up Git](https://docs.github.com/en/get-started/quickstart/set-up-git) 13 | - [GitHub flow](https://docs.github.com/en/get-started/quickstart/github-flow) 14 | - [Collaborating with pull requests](https://docs.github.com/en/github/collaborating-with-pull-requests) 15 | 16 | ## Getting started 17 | 18 | ### Issues 19 | 20 | #### Create a new issue 21 | 22 | If you spot a problem with the docs, a bug in the package search if an issue already exists. If a related issue doesn't exist, you can open a new issue. 23 | 24 | #### Solve an issue 25 | 26 | Scan through our existing issues to find one that interests you. You can narrow down the search using labels as filters. As a general rule, we don’t assign issues. If you find an issue to work on, you are welcome to open a PR with a fix. But please reference the issue in the PR description with `closes #` to link the PR to the issue and automatically resolves the issue when the PR were merged 27 | 28 | ### Make Changes 29 | 30 | #### Make changes in the docs 31 | 32 | In the end of all docs page, has a button with the text `Edit this page on GitHub`, you can click on this button to go directlty to the respective .md file and open a PR to fix or improve the page 33 | 34 | #### Make changes locally 35 | 36 | 1. Fork the repository 37 | 2. Clone your fork in your workspace 38 | 3. Install or update to Node v16. 39 | 4. Install pnpm or use [nodejs corepack](https://nodejs.org/docs/latest-v16.x/api/corepack.html) 40 | 5. Install the dependencies with `pnpm i` 41 | 42 | ### Commit your update 43 | Commit the changes once you are happy with them, and push them to your forked repository 44 | 45 | Once your changes are ready, don't forget to self-review to speed up the review process. 46 | 47 | ### Pull Request 48 | When you're finished with the changes, create a pull request from your forked repository to this repository's main branch. 49 | 50 | - Describe the changes that you made in the PR description, this way the reviewr can understand better the PR. 51 | - Don't forget to link PR to issue if you are solving one. 52 | - Enable the checkbox to allow maintainer edits so the branch can be updated for a merge. We may ask questions or request for additional information. 53 | - We may ask for changes to be made before a PR can be merged, either using suggested changes or pull request comments. You can make any other changes in your fork, then commit them to your branch. 54 | - As you update your PR and apply changes, mark each conversation as resolved. 55 | 56 | ### Your PR is merged! 57 | 58 | Congratulations 🎉🎉 The GitHub team thanks you ✨. 59 | 60 | Once your PR is merged, your contributions will be published in the next version of the package. 61 | -------------------------------------------------------------------------------- /lib/types/lib.ts: -------------------------------------------------------------------------------- 1 | import type { DeepMaybeRef, MaybeRef } from '@/types/generics'; 2 | 3 | export type KeyArguments = 4 | | string 5 | | ([any, ...unknown[]] | readonly [any, ...unknown[]]) 6 | | Record 7 | | null 8 | | undefined 9 | | false; 10 | 11 | export type Key = KeyArguments | (() => KeyArguments); 12 | export type SWRKey = MaybeRef; 13 | 14 | export type FetcherResponse = Data | Promise; 15 | 16 | export type SWRFetcher = 17 | | ((...args: any[]) => FetcherResponse) 18 | | (() => FetcherResponse); 19 | 20 | export interface CacheProvider { 21 | entries(): IterableIterator<[string, Data]>; 22 | keys(): IterableIterator; 23 | has(key: Key): boolean; 24 | get(key: Key): Data | undefined; 25 | set(key: Key, value: DeepMaybeRef): void; 26 | delete(key: Key): void; 27 | clear(): void; 28 | } 29 | 30 | export type CacheState = { 31 | data: any | undefined; 32 | error: any | undefined; 33 | isValidating: boolean; 34 | fetchedIn: Date; 35 | }; 36 | 37 | export type ScopeState = { 38 | revalidateCache: Map void | Promise>>; // callbacks to revalidate when key changes 39 | }; 40 | 41 | export type RevalidatorOpts = { 42 | dedup?: boolean; 43 | }; 44 | 45 | export type SWRConfig = { 46 | /** 47 | * stores the cached values 48 | * @default new Map() 49 | */ 50 | cacheProvider: CacheProvider; 51 | 52 | /** 53 | * automatically revalidate when window gets focused 54 | * @default true 55 | */ 56 | revalidateOnFocus: boolean; 57 | 58 | /** 59 | * automatically revalidate when the browser regains a network connection (via `navigator.onLine`) 60 | * @default true 61 | */ 62 | revalidateOnReconnect: boolean; 63 | 64 | /** 65 | * automatically revalidate even if there is stale data 66 | * @default true 67 | */ 68 | revalidateIfStale: boolean; 69 | 70 | /** 71 | * dedupe requests with the same key in this time span in miliseconds 72 | * @default 2000 73 | */ 74 | dedupingInterval: number; 75 | 76 | /** 77 | * Disabled by default 78 | * polling interval in milliseconds 79 | * @default 0 80 | */ 81 | refreshInterval: number; 82 | 83 | /** 84 | * polling when the window is invisible (if `refreshInterval` is enabled) 85 | * @default false 86 | */ 87 | refreshWhenHidden: boolean; 88 | 89 | /** 90 | * polling when the browser is offline (determined by `navigator.onLine`) 91 | * @default false 92 | */ 93 | refreshWhenOffline: boolean; 94 | 95 | /** 96 | * initial data to be returned 97 | * only revalidate on focus once during a time span in milliseconds 98 | * @default 5000 99 | */ 100 | focusThrottleInterval: number; 101 | 102 | /** 103 | * a key-value object of multiple fallback data 104 | */ 105 | fallback?: Record; 106 | 107 | /** 108 | * initial data to be returned 109 | */ 110 | fallbackData?: Data; 111 | 112 | /** 113 | * called when a request finishes successfully 114 | */ 115 | onSuccess?: (data: Data, key: string, config: SWRConfig) => void; 116 | 117 | /** 118 | * called when a request returns an error 119 | */ 120 | onError?: (err: Err, key: string, config: SWRConfig) => void; 121 | }; 122 | 123 | export type SWRComposableConfig = Omit, 'cacheProvider'>; 124 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swr-vue", 3 | "version": "1.9.1", 4 | "author": "Eduardo Wesley ", 5 | "description": "Vue composables for Data fetching", 6 | "license": "MIT", 7 | "packageManager": "pnpm@7.9.5", 8 | "homepage": "https://github.com/edumudu/swr-vue#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/edumudu/swr-vue.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/edumudu/swr-vue/issues" 15 | }, 16 | "files": [ 17 | "dist" 18 | ], 19 | "types": "./dist/types/index.d.ts", 20 | "main": "./dist/swr-vue.umd.js", 21 | "module": "./dist/swr-vue.es.js", 22 | "exports": { 23 | ".": { 24 | "import": "./dist/swr-vue.es.js", 25 | "require": "./dist/swr-vue.umd.js" 26 | } 27 | }, 28 | "scripts": { 29 | "dev": "vite", 30 | "build": "vite build && vue-tsc --noEmit", 31 | "prepare": "husky install", 32 | "generate:release": "semantic-release", 33 | "preview": "vite preview", 34 | "lint:js": "eslint . --ext js,ts,jsx,tsx,vue --ignore-path .gitignore", 35 | "test": "vitest run", 36 | "test:watch": "vitest watch", 37 | "test:coverage": "vitest run --coverage", 38 | "test:types": "tsd", 39 | "docs:dev": "vitepress dev docs", 40 | "docs:build": "vitepress build docs", 41 | "docs:serve": "vitepress serve docs" 42 | }, 43 | "devDependencies": { 44 | "@commitlint/cli": "^17.0.3", 45 | "@commitlint/config-conventional": "^17.0.3", 46 | "@edumudu/eslint-config": "^1.1.2", 47 | "@semantic-release/git": "^10.0.1", 48 | "@types/node": "^17.0.31", 49 | "@typescript-eslint/eslint-plugin": "^5.30.3", 50 | "@typescript-eslint/parser": "^5.30.3", 51 | "@vitest/coverage-istanbul": "^0.22.1", 52 | "eslint": "^8.19.0", 53 | "eslint-config-airbnb-base": "^15.0.0", 54 | "eslint-config-airbnb-typescript": "^17.0.0", 55 | "eslint-config-prettier": "^8.5.0", 56 | "eslint-import-resolver-typescript": "^3.1.4", 57 | "eslint-plugin-eslint-comments": "^3.2.0", 58 | "eslint-plugin-import": "^2.26.0", 59 | "eslint-plugin-jest-dom": "^4.0.2", 60 | "eslint-plugin-prettier": "^4.2.1", 61 | "eslint-plugin-testing-library": "^5.5.1", 62 | "eslint-plugin-vue": "^9.1.1", 63 | "flush-promises": "^1.0.2", 64 | "husky": "^8.0.1", 65 | "jsdom": "^20.0.0", 66 | "lint-staged": "^13.0.3", 67 | "prettier": "^2.7.1", 68 | "semantic-release": "^19.0.3", 69 | "tsd": "^0.23.0", 70 | "typescript": "^4.7.4", 71 | "vite": "^3.1.0", 72 | "vite-plugin-dts": "^1.4.1", 73 | "vitepress": "1.0.0-alpha.65", 74 | "vitest": "^0.24.3", 75 | "vue": "^3.2.41", 76 | "vue-tsc": "^0.38.2" 77 | }, 78 | "peerDependencies": { 79 | "vue": "^3.2.33" 80 | }, 81 | "dependencies": { 82 | "@vue/devtools-api": "^6.4.3", 83 | "@vueuse/core": "^9.1.1" 84 | }, 85 | "lint-staged": { 86 | "*.{js,ts}": "eslint --cache --fix" 87 | }, 88 | "publishConfig": { 89 | "access": "public" 90 | }, 91 | "keywords": [ 92 | "swr", 93 | "vue", 94 | "composables", 95 | "cache", 96 | "fetch", 97 | "request", 98 | "stale-while-revalidate" 99 | ], 100 | "engines": { 101 | "node": ">=16" 102 | }, 103 | "pnpm": { 104 | "peerDependencyRules": { 105 | "ignoreMissing": [ 106 | "@types/react", 107 | "react", 108 | "react-dom" 109 | ] 110 | } 111 | }, 112 | "tsd": { 113 | "directory": "lib" 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /lib/utils/test/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line eslint-comments/disable-enable-pair 2 | /* eslint-disable vue/one-component-per-file */ 3 | import { createApp, defineComponent, h, reactive, UnwrapRef, createSSRApp, Component } from 'vue'; 4 | import { renderToString } from 'vue/server-renderer'; 5 | 6 | import type { CacheProvider, CacheState, Key } from '@/types'; 7 | 8 | import { serializeKey } from '../serialize-key'; 9 | 10 | // Thanks https://github.com/vueuse/vueuse/blob/main/packages/.test/mount.ts 11 | 12 | type InstanceType = V extends { new (...arg: any[]): infer X } ? X : never; 13 | type VM = InstanceType & { unmount(): void }; 14 | 15 | const mount = (componentToMount: V) => { 16 | const el = document.createElement('div'); 17 | const app = createApp(componentToMount); 18 | 19 | const unmount = () => app.unmount(); 20 | const component = app.mount(el) as any as VM; 21 | 22 | component.unmount = unmount; 23 | 24 | return component; 25 | }; 26 | 27 | const mountInServer = async (componentToMount: V) => { 28 | const app = createSSRApp(componentToMount); 29 | 30 | return { 31 | app, 32 | renderString: await renderToString(app), 33 | }; 34 | }; 35 | 36 | /** 37 | * Function to replace vue docs `withSetup`. 38 | * Meant to be used only in tests. 39 | */ 40 | export function useSetup(setup: () => V) { 41 | const Comp = defineComponent({ 42 | name: 'TestWrapperComponent', 43 | setup, 44 | render: () => h('div', []), 45 | }); 46 | 47 | return mount(Comp); 48 | } 49 | 50 | /** 51 | * Function to replace vue docs `withSetup` and allow the setup of providers. 52 | * Meant to be used only in tests. 53 | */ 54 | export function useInjectedSetup(providerSetup: () => void, setup: () => V) { 55 | let setupResult: V; 56 | 57 | const Comp = defineComponent({ 58 | name: 'TestWrapperComponent', 59 | setup() { 60 | setupResult = setup(); 61 | 62 | return setupResult; 63 | }, 64 | 65 | render: () => h('div', []), 66 | }); 67 | 68 | const Provider = defineComponent({ 69 | name: 'TestWrapperComponentProvider', 70 | components: Comp, 71 | setup: providerSetup, 72 | render: () => h('div', [h(Comp)]), 73 | }); 74 | 75 | mount(Provider); 76 | 77 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 78 | // @ts-ignore 79 | return setupResult; 80 | } 81 | 82 | /** 83 | * Function to test features in ssr. 84 | * Meant to be used only in tests. 85 | */ 86 | export const useSetupInServer = (setup: () => V) => { 87 | const Comp = defineComponent({ 88 | name: 'TestWrapperComponent', 89 | setup, 90 | render: () => h('div', []), 91 | }); 92 | 93 | return mountInServer(Comp); 94 | }; 95 | 96 | export const mockedCache = reactive(new Map()); 97 | 98 | export const setDataToMockedCache = ( 99 | key: Key, 100 | data: UnwrapRef>, 101 | cache = mockedCache, 102 | ) => { 103 | const { key: serializedKey } = serializeKey(key); 104 | 105 | cache.set(serializedKey, { 106 | error: data.error, 107 | data: data.data, 108 | isValidating: data.isValidating || false, 109 | fetchedIn: data.fetchedIn || new Date(), 110 | }); 111 | }; 112 | 113 | export const getDataFromMockedCache = (key: Key, cache = mockedCache) => { 114 | const { key: serializedKey } = serializeKey(key); 115 | 116 | return cache.get(serializedKey); 117 | }; 118 | 119 | export const dispatchEvent = (eventName: string, target: Element | Window | Document) => { 120 | const event = new Event(eventName, { bubbles: true }); 121 | 122 | target.dispatchEvent(event); 123 | }; 124 | -------------------------------------------------------------------------------- /docs/mutation.md: -------------------------------------------------------------------------------- 1 | # Mutation 2 | 3 | You can get the `mutate` function from `useSWRConfig` composable, and emit an global revalidation to all composables using the same key calling `mutate(key, () => newData)` 4 | 5 | > Note: any change made by the function `mutate` will be limited to the current scope's cache provider, if the function is not inside a scope, the default cache provider will be used. 6 | 7 | ### Options Available 8 | 9 | - `optimisticData` - The data to be immediately written in the cache, commonly used for optimistic UI 10 | - `rollbackOnError` - If the cache should revert if the mutation fails 11 | 12 | ## Optimistic Updates 13 | 14 | In many cases you may guess that the remote update will success, and you want to apply the optimistic UI. 15 | 16 | With `mutate`, you can update you client data programatically, while revalidating, and finally replace it with the latest data if the remote update success. 17 | 18 | ```vue 19 | 39 | 40 | 48 | ``` 49 | 50 | ## Mutation based on current data 51 | 52 | You can pass an function, async function or promise to the second argument, and the value resolved from this, will be writed in the cache. In case that the value passed is a function, it will receive the current data in the cache as the first parameter. 53 | 54 | ```ts 55 | mutate('/api/todos', async todos => { 56 | // Mark the todo with the id `1` as completed 57 | const updatedTodo = await fetch('/api/todos/1', { 58 | method: 'PATCH', 59 | body: JSON.stringify({ completed: true }) 60 | }) 61 | 62 | // Remove the todo with the id 1; 63 | const filteredTodos = todos.filter(todo => todo.id !== '1'); 64 | // Returns the todos updated 65 | return [...filteredTodos, updatedTodo] 66 | }) 67 | ``` 68 | 69 | ## Returned Data from Mutate 70 | 71 | In case that you need to use the updated data resolved from your update function or promise, the `mutate` will return the resolved value. 72 | 73 | The `mutate` function also will throw an error if the passed promise rejects or the passed function throws an error, this way you can deal with the error properly 74 | 75 | ```ts 76 | try { 77 | const todo = await mutate('/api/todos', updateTodo(newTodo)) 78 | } catch (error) { 79 | // Handle an error while updating the todo here 80 | } 81 | ``` 82 | 83 | ## Bound mutate 84 | 85 | The object returned from `useSWR` composable also has a `mutate` function that is bounded to the SWR's key 86 | 87 | ```vue 88 | 106 | 107 | 115 | ``` 116 | -------------------------------------------------------------------------------- /lib/utils/stable-hash/stable-hash.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Thanks to vercel/swr 3 | * https://github.com/vercel/swr/blob/main/test/unit/utils.test.tsx 4 | */ 5 | 6 | import { ref } from 'vue'; 7 | 8 | import { stableHash as hash } from '.'; 9 | 10 | beforeEach(() => { 11 | vi.useRealTimers(); 12 | }); 13 | 14 | describe('stableHash', () => { 15 | it('should hash arguments correctly', async () => { 16 | // Primitives 17 | expect(hash(['key'])).toEqual('@"key",'); 18 | expect(hash([1])).toEqual('@1,'); 19 | expect(hash(['false'])).toEqual('@"false",'); 20 | expect(hash([false])).toEqual('@false,'); 21 | expect(hash([true])).toEqual('@true,'); 22 | expect(hash([null])).toEqual('@null,'); 23 | expect(hash(['null'])).toEqual('@"null",'); 24 | expect(hash([undefined])).toEqual('@undefined,'); 25 | expect(hash([NaN])).toEqual('@NaN,'); 26 | expect(hash([Infinity])).toEqual('@Infinity,'); 27 | expect(hash([''])).toEqual('@"",'); 28 | 29 | // Encodes `"` 30 | expect(hash(['","', 1])).not.toEqual(hash(['', '', 1])); 31 | 32 | // BigInt 33 | expect(hash([BigInt(1)])).toEqual('@1,'); 34 | 35 | // Date 36 | const date = new Date(); 37 | expect(hash([date])).toEqual(`@${date.toJSON()},`); 38 | expect(hash([new Date(1234)])).toEqual(hash([new Date(1234)])); 39 | 40 | // Regex 41 | expect(hash([/regex/])).toEqual('@/regex/,'); 42 | 43 | // Symbol 44 | expect(hash([Symbol('key')])).toMatch('@Symbol(key),'); 45 | const symbol = Symbol('foo'); 46 | expect(hash([symbol])).toMatch(hash([symbol])); 47 | 48 | // Due to serialization, those three are equivalent 49 | expect(hash([Symbol.for('key')])).toMatch(hash([Symbol.for('key')])); 50 | expect(hash([Symbol('key')])).toMatch(hash([Symbol('key')])); 51 | expect(hash([Symbol('key')])).toMatch(hash([Symbol.for('key')])); 52 | 53 | // Set, Map, Buffer... 54 | const set = new Set(); 55 | expect(hash([set])).not.toMatch(hash([new Set()])); 56 | expect(hash([set])).toMatch(hash([set])); 57 | const map = new Map(); 58 | expect(hash([map])).not.toMatch(hash([new Map()])); 59 | expect(hash([map])).toMatch(hash([map])); 60 | const buffer = new ArrayBuffer(0); 61 | expect(hash([buffer])).not.toMatch(hash([new ArrayBuffer(0)])); 62 | expect(hash([buffer])).toMatch(hash([buffer])); 63 | 64 | // Serializable objects 65 | expect(hash([{ x: 1 }])).toEqual('@#x:1,,'); 66 | expect(hash([{ '': 1 }])).toEqual('@#:1,,'); 67 | expect(hash([{ x: { y: 2 } }])).toEqual('@#x:#y:2,,,'); 68 | expect(hash([[]])).toEqual('@@,'); 69 | expect(hash([[[]]])).not.toMatch(hash([[], []])); 70 | 71 | // Circular 72 | const o: any = {}; 73 | o.o = o; 74 | expect(hash([o])).toEqual(hash([o])); 75 | expect(hash([o])).not.toEqual(hash([{}])); 76 | const a: any = []; 77 | a.push(a); 78 | expect(hash([a])).toEqual(hash([a])); 79 | expect(hash([a])).not.toEqual(hash([[]])); 80 | const o2: any = {}; 81 | const a2: any = [o2]; 82 | o2.a = a2; 83 | expect(hash([o2])).toEqual(hash([o2])); 84 | 85 | // Unserializable objects 86 | expect(hash([() => {}])).toMatch(/@\d+~,/); 87 | expect(hash([class {}])).toMatch(/@\d+~,/); 88 | }); 89 | 90 | it('should hash arguments correctly with refs', async () => { 91 | // Primitives 92 | expect(hash(ref(['key']))).toEqual('@"key",'); 93 | expect(hash(ref([1]))).toEqual('@1,'); 94 | expect(hash(ref(['false']))).toEqual('@"false",'); 95 | expect(hash(ref([false]))).toEqual('@false,'); 96 | expect(hash(ref([true]))).toEqual('@true,'); 97 | expect(hash(ref([null]))).toEqual('@null,'); 98 | expect(hash(ref(['null']))).toEqual('@"null",'); 99 | expect(hash(ref([undefined]))).toEqual('@undefined,'); 100 | expect(hash(ref([NaN]))).toEqual('@NaN,'); 101 | expect(hash(ref([Infinity]))).toEqual('@Infinity,'); 102 | expect(hash(ref(['']))).toEqual('@"",'); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /lib/composables/swr/swr-deduping.spec.ts: -------------------------------------------------------------------------------- 1 | import { ref, nextTick } from 'vue'; 2 | 3 | import { SWRComposableConfig } from '@/types'; 4 | import { useInjectedSetup, mockedCache, setDataToMockedCache } from '@/utils/test'; 5 | 6 | import { useSWR } from '.'; 7 | import { configureGlobalSWR } from '../global-swr-config'; 8 | 9 | const cacheProvider = mockedCache; 10 | const defaultOptions: SWRComposableConfig = { dedupingInterval: 0 }; 11 | 12 | describe('useSWR - Deduping', () => { 13 | beforeEach(() => { 14 | cacheProvider.clear(); 15 | 16 | vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); 17 | vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); 18 | }); 19 | 20 | it('should call the fetcher once if composables are called close of each other', () => { 21 | const fetcher = vi.fn(); 22 | const interval = 2000; 23 | const key = 'key-1'; 24 | 25 | const options: SWRComposableConfig = { 26 | ...defaultOptions, 27 | dedupingInterval: interval, 28 | }; 29 | 30 | useInjectedSetup( 31 | () => configureGlobalSWR({ cacheProvider }), 32 | () => { 33 | useSWR(key, fetcher, options); 34 | useSWR(key, fetcher, options); 35 | useSWR(key, fetcher, options); 36 | useSWR(key, fetcher, options); 37 | }, 38 | ); 39 | 40 | expect(fetcher).toBeCalledTimes(1); 41 | }); 42 | 43 | it('should dedup also when already has cache', async () => { 44 | const interval = 2000; 45 | const key = ref('key-1'); 46 | const fetcher = vi.fn(); 47 | 48 | setDataToMockedCache(key.value, { data: 'cachedData', fetchedIn: new Date(Date.now() - 4000) }); 49 | 50 | const options: SWRComposableConfig = { 51 | ...defaultOptions, 52 | dedupingInterval: interval, 53 | }; 54 | 55 | useInjectedSetup( 56 | () => configureGlobalSWR({ cacheProvider }), 57 | () => { 58 | useSWR(key, fetcher, options); 59 | useSWR(key, fetcher, options); 60 | useSWR(key, fetcher, options); 61 | useSWR(key, fetcher, options); 62 | }, 63 | ); 64 | 65 | expect(fetcher).toHaveBeenCalledTimes(1); 66 | 67 | key.value = 'key-2'; 68 | await nextTick(); 69 | expect(fetcher).toHaveBeenCalledTimes(1); 70 | }); 71 | 72 | it('should return the same value when called inside deduping interval', async () => { 73 | const interval = 2000; 74 | const key = 'key-13434erdre'; 75 | 76 | const options: SWRComposableConfig = { 77 | ...defaultOptions, 78 | dedupingInterval: interval, 79 | }; 80 | 81 | const result = useInjectedSetup( 82 | () => configureGlobalSWR({ cacheProvider }), 83 | () => { 84 | const { data: data1 } = useSWR(key, () => 'result1', options); 85 | const { data: data2 } = useSWR(key, () => 'result2', options); 86 | const { data: data3 } = useSWR(key, () => 'result3', options); 87 | const { data: data4 } = useSWR(key, () => 'result4', options); 88 | 89 | return [data1, data2, data3, data4]; 90 | }, 91 | ); 92 | 93 | await nextTick(); 94 | expect(result.map((data) => data.value)).toEqual(['result1', 'result1', 'result1', 'result1']); 95 | }); 96 | 97 | it('should call the fetcher function again when outside deduping interval', async () => { 98 | const interval = 2000; 99 | const key = 'key-1'; 100 | const fetcher = vi.fn(); 101 | 102 | setDataToMockedCache(key, { data: 'cachedData' }); 103 | vi.advanceTimersByTime(interval); 104 | 105 | useInjectedSetup( 106 | () => configureGlobalSWR({ cacheProvider }), 107 | () => { 108 | useSWR(key, fetcher, { 109 | ...defaultOptions, 110 | dedupingInterval: interval, 111 | }); 112 | }, 113 | ); 114 | 115 | expect(fetcher).toHaveBeenCalledTimes(1); 116 | }); 117 | 118 | it('should disable deduping if `dedupingInterval` equals 0', () => { 119 | const fetcher = vi.fn(); 120 | const key = 'key-1'; 121 | 122 | const options: SWRComposableConfig = { 123 | ...defaultOptions, 124 | dedupingInterval: 0, 125 | }; 126 | 127 | useInjectedSetup( 128 | () => configureGlobalSWR({ cacheProvider }), 129 | () => { 130 | useSWR(key, fetcher, options); 131 | useSWR(key, fetcher, options); 132 | useSWR(key, fetcher, options); 133 | useSWR(key, fetcher, options); 134 | }, 135 | ); 136 | 137 | expect(fetcher).toBeCalledTimes(4); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /lib/composables/swr/swr-mutate.spec.ts: -------------------------------------------------------------------------------- 1 | import { nextTick } from 'vue'; 2 | 3 | import { useInjectedSetup, setDataToMockedCache, mockedCache } from '@/utils/test'; 4 | import { globalState } from '@/config'; 5 | 6 | import { useSWR } from '.'; 7 | import { configureGlobalSWR, useSWRConfig } from '../global-swr-config'; 8 | 9 | const cacheProvider = mockedCache; 10 | const defaultKey = 'defaultKey'; 11 | const defaultFetcher = vi.fn((key: string) => key); 12 | 13 | const setTimeoutPromise = (ms: number, resolveTo: unknown) => 14 | new Promise((resolve) => { 15 | setTimeout(() => resolve(resolveTo), ms); 16 | }); 17 | 18 | const useSWRWrapped: typeof useSWR = (...params) => { 19 | return useInjectedSetup( 20 | () => configureGlobalSWR({ cacheProvider }), 21 | () => useSWR(...params), 22 | ); 23 | }; 24 | 25 | describe('useSWR - mutate', () => { 26 | beforeEach(() => { 27 | cacheProvider.clear(); 28 | globalState.delete(cacheProvider); 29 | 30 | vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); 31 | vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); 32 | }); 33 | 34 | it('should change local data variable value when binded mutate resolves', async () => { 35 | const { mutate, data } = useSWRWrapped(defaultKey, () => 'FetcherResult'); 36 | 37 | await nextTick(); 38 | expect(data.value).toEqual('FetcherResult'); 39 | 40 | await mutate(() => 'newValue', { revalidate: false }); 41 | expect(data.value).toEqual('newValue'); 42 | 43 | await mutate(Promise.resolve('promised value'), { revalidate: false }); 44 | expect(data.value).toEqual('promised value'); 45 | 46 | await mutate(['raw value'], { revalidate: false }); 47 | expect(data.value).toEqual(['raw value']); 48 | }); 49 | 50 | it('should change local data variable value when binded mutate is called with `optimistcData`', async () => { 51 | setDataToMockedCache(defaultKey, { data: 'cachedData' }); 52 | 53 | const { mutate, data } = useInjectedSetup( 54 | () => configureGlobalSWR({ cacheProvider }), 55 | () => useSWR(defaultKey, () => 'FetcherResult'), 56 | ); 57 | 58 | expect(data.value).toEqual('cachedData'); 59 | 60 | mutate(() => setTimeoutPromise(1000, 'newValue'), { 61 | optimisticData: 'optimistcData', 62 | revalidate: false, 63 | }); 64 | await nextTick(); 65 | expect(data.value).toEqual('optimistcData'); 66 | }); 67 | 68 | it('should update all hooks with the same key when call binded mutate', async () => { 69 | setDataToMockedCache(defaultKey, { data: 'cachedData' }); 70 | 71 | const { datas, mutate, differentData } = useInjectedSetup( 72 | () => configureGlobalSWR({ cacheProvider }), 73 | () => { 74 | const { data: data1, mutate: localMutate } = useSWR(defaultKey, defaultFetcher); 75 | const { data: data2 } = useSWR(defaultKey, defaultFetcher); 76 | const { data: data3 } = useSWR(defaultKey, defaultFetcher); 77 | const { data: data4 } = useSWR(defaultKey, defaultFetcher); 78 | const { data: differentData1 } = useSWR('key-2', () => 'should not change'); 79 | 80 | return { 81 | differentData: differentData1, 82 | datas: [data1, data2, data3, data4], 83 | mutate: localMutate, 84 | }; 85 | }, 86 | ); 87 | 88 | expect(datas.map((data) => data.value)).toEqual([ 89 | 'cachedData', 90 | 'cachedData', 91 | 'cachedData', 92 | 'cachedData', 93 | ]); 94 | 95 | await mutate(() => 'mutated value', { revalidate: false }); 96 | await nextTick(); 97 | expect(datas.map((data) => data.value)).toEqual([ 98 | 'mutated value', 99 | 'mutated value', 100 | 'mutated value', 101 | 'mutated value', 102 | ]); 103 | 104 | expect(differentData.value).toEqual('should not change'); 105 | }); 106 | 107 | it('should trigger revalidation programmatically', async () => { 108 | let value = 0; 109 | 110 | const { mutate, data, globalMutate } = useInjectedSetup( 111 | () => configureGlobalSWR({ cacheProvider }), 112 | () => { 113 | const { mutate: localGlobalMutate } = useSWRConfig(); 114 | // eslint-disable-next-line no-plusplus 115 | const swrResult = useSWR(defaultKey, () => value++); 116 | 117 | return { 118 | globalMutate: localGlobalMutate, 119 | ...swrResult, 120 | }; 121 | }, 122 | ); 123 | 124 | await nextTick(); 125 | expect(data.value).toEqual(0); 126 | 127 | await mutate(); 128 | 129 | await nextTick(); 130 | expect(data.value).toEqual(1); 131 | 132 | await globalMutate(defaultKey); 133 | 134 | await nextTick(); 135 | expect(data.value).toEqual(2); 136 | }); 137 | 138 | it('should ignore dedup interval when call binded mutate', async () => { 139 | const fetcher = defaultFetcher; 140 | const { mutate } = useSWRWrapped(defaultKey, fetcher, { dedupingInterval: 50000000 }); 141 | 142 | await nextTick(); 143 | fetcher.mockReset(); 144 | 145 | await mutate(); 146 | expect(fetcher).toHaveBeenCalledTimes(1); 147 | 148 | await mutate(); 149 | expect(fetcher).toHaveBeenCalledTimes(2); 150 | }); 151 | 152 | it('should revalidate when call binded mutate', async () => { 153 | const fetcher = defaultFetcher; 154 | const { mutate } = useSWRWrapped(defaultKey, fetcher, { dedupingInterval: 50000000 }); 155 | 156 | await nextTick(); 157 | fetcher.mockReset(); 158 | 159 | await mutate(); 160 | expect(fetcher).toHaveBeenCalledOnce(); 161 | 162 | await mutate(['new vakye']); 163 | expect(fetcher).toHaveBeenCalledTimes(2); 164 | 165 | await mutate(() => 'new vakye'); 166 | expect(fetcher).toHaveBeenCalledTimes(3); 167 | 168 | await mutate(Promise.resolve('promised value')); 169 | expect(fetcher).toHaveBeenCalledTimes(4); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /lib/composables/swr/swr-callbacks.spec.ts: -------------------------------------------------------------------------------- 1 | import { nextTick, ref } from 'vue'; 2 | 3 | import { SWRComposableConfig } from '@/types'; 4 | import { useInjectedSetup, mockedCache } from '@/utils/test'; 5 | import { serializeKey } from '@/utils'; 6 | 7 | import { useSWR } from '.'; 8 | import { configureGlobalSWR } from '../global-swr-config'; 9 | 10 | const cacheProvider = mockedCache; 11 | const defaultKey = 'defaultKey'; 12 | const { key: defaultKeySerialized } = serializeKey(defaultKey); 13 | 14 | describe('useSWR', () => { 15 | beforeEach(() => { 16 | cacheProvider.clear(); 17 | 18 | vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); 19 | vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); 20 | }); 21 | 22 | it('should call local and global onSuccess if fetcher successes', async () => { 23 | const onSuccess = vi.fn(); 24 | const globalOnSuccess = vi.fn(); 25 | const fetcherResult = 'result'; 26 | 27 | useInjectedSetup( 28 | () => configureGlobalSWR({ cacheProvider, onSuccess: globalOnSuccess }), 29 | () => useSWR(defaultKey, () => fetcherResult, { onSuccess }), 30 | ); 31 | 32 | await nextTick(); 33 | expect(onSuccess).toHaveBeenCalledOnce(); 34 | expect(onSuccess).toHaveBeenCalledWith(fetcherResult, defaultKeySerialized, expect.anything()); 35 | expect(globalOnSuccess).toHaveBeenCalledOnce(); 36 | expect(globalOnSuccess).toHaveBeenCalledWith( 37 | fetcherResult, 38 | defaultKeySerialized, 39 | expect.anything(), 40 | ); 41 | }); 42 | 43 | it('should call local and global onError if fetcher throws', async () => { 44 | const onError = vi.fn(); 45 | const globalOnError = vi.fn(); 46 | const error = new Error(); 47 | 48 | useInjectedSetup( 49 | () => configureGlobalSWR({ cacheProvider, onError: globalOnError }), 50 | () => useSWR(defaultKey, () => Promise.reject(error), { onError }), 51 | ); 52 | 53 | await nextTick(); 54 | expect(onError).toHaveBeenCalledOnce(); 55 | expect(onError).toHaveBeenCalledWith(error, defaultKeySerialized, expect.anything()); 56 | expect(globalOnError).toHaveBeenCalledOnce(); 57 | expect(globalOnError).toHaveBeenCalledWith(error, defaultKeySerialized, expect.anything()); 58 | }); 59 | 60 | it('should call local and global onError with local and global configs merged', async () => { 61 | const onError = vi.fn(); 62 | const globalOnError = vi.fn(); 63 | const error = new Error(); 64 | 65 | const localConfig: SWRComposableConfig = { dedupingInterval: 1 }; 66 | const globalConfig: SWRComposableConfig = { revalidateOnFocus: false }; 67 | const mergedConfig = { ...localConfig, ...globalConfig }; 68 | 69 | useInjectedSetup( 70 | () => configureGlobalSWR({ ...globalConfig, cacheProvider, onError: globalOnError }), 71 | () => useSWR(defaultKey, () => Promise.reject(error), { ...localConfig, onError }), 72 | ); 73 | 74 | await nextTick(); 75 | expect(onError).toHaveBeenCalledWith( 76 | expect.anything(), 77 | expect.anything(), 78 | expect.objectContaining(mergedConfig), 79 | ); 80 | expect(globalOnError).toHaveBeenCalledWith( 81 | expect.anything(), 82 | expect.anything(), 83 | expect.objectContaining(mergedConfig), 84 | ); 85 | }); 86 | 87 | it('should call local and global onSuccess with local and global configs merged', async () => { 88 | const onSuccess = vi.fn(); 89 | const globalOnSuccess = vi.fn(); 90 | 91 | const localConfig: SWRComposableConfig = { dedupingInterval: 1 }; 92 | const globalConfig: SWRComposableConfig = { revalidateOnFocus: false }; 93 | const mergedConfig = { ...localConfig, ...globalConfig }; 94 | 95 | useInjectedSetup( 96 | () => configureGlobalSWR({ ...globalConfig, cacheProvider, onSuccess: globalOnSuccess }), 97 | () => useSWR(defaultKey, () => Promise.resolve('resolved'), { ...localConfig, onSuccess }), 98 | ); 99 | 100 | await nextTick(); 101 | expect(onSuccess).toHaveBeenCalledWith( 102 | expect.anything(), 103 | expect.anything(), 104 | expect.objectContaining(mergedConfig), 105 | ); 106 | expect(globalOnSuccess).toHaveBeenCalledWith( 107 | expect.anything(), 108 | expect.anything(), 109 | expect.objectContaining(mergedConfig), 110 | ); 111 | }); 112 | 113 | it.each(['key-ref', 'key-string'])( 114 | 'should call local and global onSuccess with right key "%s" and data', 115 | async (keyStr) => { 116 | const key = keyStr === 'key-ref' ? ref(keyStr) : keyStr; 117 | const { key: serializedKey } = serializeKey(key); 118 | const onSuccess = vi.fn(); 119 | const globalOnSuccess = vi.fn(); 120 | const resolvedData = 'resolved :)'; 121 | 122 | useInjectedSetup( 123 | () => configureGlobalSWR({ cacheProvider, onSuccess: globalOnSuccess }), 124 | () => useSWR(key, () => Promise.resolve(resolvedData), { onSuccess }), 125 | ); 126 | 127 | await nextTick(); 128 | expect(onSuccess).toHaveBeenCalledWith(resolvedData, serializedKey, expect.anything()); 129 | expect(globalOnSuccess).toHaveBeenCalledWith(resolvedData, serializedKey, expect.anything()); 130 | }, 131 | ); 132 | 133 | it.each(['key-ref', 'key-string'])( 134 | 'should call local and global onError with right key "%s" and error', 135 | async (keyStr) => { 136 | const key = keyStr === 'key-ref' ? ref(keyStr) : keyStr; 137 | const { key: serializedKey } = serializeKey(key); 138 | const onError = vi.fn(); 139 | const globalOnError = vi.fn(); 140 | const error = new Error('fetch failed :('); 141 | 142 | useInjectedSetup( 143 | () => configureGlobalSWR({ cacheProvider, onError: globalOnError }), 144 | () => useSWR(key, () => Promise.reject(error), { onError }), 145 | ); 146 | 147 | await nextTick(); 148 | expect(onError).toHaveBeenCalledWith(error, serializedKey, expect.anything()); 149 | expect(globalOnError).toHaveBeenCalledWith(error, serializedKey, expect.anything()); 150 | }, 151 | ); 152 | }); 153 | -------------------------------------------------------------------------------- /lib/composables/swr/swr-revalidate.spec.ts: -------------------------------------------------------------------------------- 1 | import flushPromises from 'flush-promises'; 2 | import { nextTick } from 'vue'; 3 | 4 | import { SWRComposableConfig } from '@/types'; 5 | import { 6 | useInjectedSetup, 7 | mockedCache, 8 | setDataToMockedCache, 9 | dispatchEvent, 10 | useSetup, 11 | } from '@/utils/test'; 12 | import { globalState } from '@/config'; 13 | 14 | import { useSWR } from '.'; 15 | import { configureGlobalSWR } from '../global-swr-config'; 16 | 17 | const cacheProvider = mockedCache; 18 | const defaultKey = 'defaultKey'; 19 | const defaultFetcher = vi.fn((key: string) => key); 20 | const defaultOptions: SWRComposableConfig = { dedupingInterval: 0 }; 21 | 22 | const useSWRWrapped: typeof useSWR = (...params) => { 23 | return useInjectedSetup( 24 | () => configureGlobalSWR({ cacheProvider }), 25 | () => useSWR(...params), 26 | ); 27 | }; 28 | 29 | describe('useSWR - Revalidate', () => { 30 | beforeEach(() => { 31 | cacheProvider.clear(); 32 | globalState.delete(cacheProvider); 33 | 34 | vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); 35 | vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); 36 | }); 37 | 38 | it('should return cached value first then revalidate', async () => { 39 | setDataToMockedCache(defaultKey, { data: 'cachedData' }); 40 | 41 | const fetcher = vi.fn( 42 | () => 43 | new Promise((resolve) => { 44 | setTimeout(() => resolve('FetcherResult'), 1000); 45 | }), 46 | ); 47 | 48 | const { data: swrData } = useSWRWrapped(defaultKey, fetcher, defaultOptions); 49 | 50 | expect(swrData.value).toBe('cachedData'); 51 | 52 | vi.advanceTimersByTime(1000); 53 | await flushPromises(); 54 | expect(fetcher).toHaveBeenCalledTimes(1); 55 | expect(swrData.value).toBe('FetcherResult'); 56 | }); 57 | 58 | describe('focus', () => { 59 | const dispatchWindowFocus = async () => { 60 | dispatchEvent('blur', window); 61 | await nextTick(); 62 | 63 | dispatchEvent('focus', window); 64 | await nextTick(); 65 | }; 66 | 67 | it('should revalidate when focus page', async () => { 68 | setDataToMockedCache(defaultKey, { data: 'cachedData' }); 69 | 70 | const fetcher = vi.fn().mockResolvedValue('FetcherResult'); 71 | const { data } = useSWRWrapped(defaultKey, fetcher, { 72 | dedupingInterval: 0, 73 | focusThrottleInterval: 0, 74 | }); 75 | 76 | fetcher.mockClear(); 77 | 78 | await dispatchWindowFocus(); 79 | await flushPromises(); 80 | 81 | expect(fetcher).toBeCalledTimes(1); 82 | expect(data.value).toBe('FetcherResult'); 83 | }); 84 | 85 | it('should revalidate on focus just once inside focusThrottleInterval time span', async () => { 86 | setDataToMockedCache(defaultKey, { data: 'cachedData' }); 87 | 88 | const focusThrottleInterval = 4000; 89 | const fetcher = vi.fn(defaultFetcher); 90 | 91 | useSWRWrapped(defaultKey, fetcher, { 92 | focusThrottleInterval, 93 | revalidateOnFocus: true, 94 | }); 95 | 96 | await nextTick(); 97 | fetcher.mockClear(); 98 | 99 | await dispatchWindowFocus(); 100 | expect(fetcher).toBeCalledTimes(0); 101 | 102 | vi.advanceTimersByTime(focusThrottleInterval - 1); 103 | await dispatchWindowFocus(); 104 | expect(fetcher).toBeCalledTimes(0); 105 | 106 | vi.advanceTimersByTime(2); 107 | await dispatchWindowFocus(); 108 | expect(fetcher).toBeCalledTimes(1); 109 | 110 | await flushPromises(); 111 | vi.advanceTimersByTime(focusThrottleInterval - 1); 112 | await dispatchWindowFocus(); 113 | expect(fetcher).toBeCalledTimes(1); 114 | }); 115 | 116 | it('should not revalidate when focus if config revalidateOnFocus is false', async () => { 117 | setDataToMockedCache(defaultKey, { data: 'cachedData' }); 118 | 119 | const fetcher = vi.fn().mockResolvedValue('FetcherResult'); 120 | const { data } = useSWRWrapped(defaultKey, fetcher, { 121 | ...defaultOptions, 122 | revalidateOnFocus: false, 123 | }); 124 | 125 | await dispatchWindowFocus(); 126 | await flushPromises(); 127 | 128 | expect(fetcher).toBeCalledTimes(1); 129 | expect(data.value).toBe('FetcherResult'); 130 | }); 131 | 132 | it('should remove focus listeners when unmount component', async () => { 133 | const fetcher = vi.fn(defaultFetcher); 134 | const instance = useSetup(() => useSWR(defaultKey, fetcher, { revalidateOnFocus: true })); 135 | 136 | await nextTick(); 137 | fetcher.mockClear(); 138 | 139 | vi.advanceTimersByTime(10_000); 140 | 141 | instance.unmount(); 142 | await dispatchWindowFocus(); 143 | 144 | expect(fetcher).not.toHaveBeenCalled(); 145 | }); 146 | }); 147 | 148 | it('should not revalidate if revalidateIfStale is false', async () => { 149 | setDataToMockedCache(defaultKey, { data: 'cachedData' }); 150 | 151 | const fetcher = vi.fn().mockResolvedValue('FetcherResult'); 152 | const { data } = useSWRWrapped(defaultKey, fetcher, { 153 | ...defaultOptions, 154 | revalidateIfStale: false, 155 | }); 156 | 157 | await flushPromises(); 158 | expect(fetcher).toBeCalledTimes(0); 159 | expect(data.value).toBe('cachedData'); 160 | }); 161 | 162 | it('should revalidate when back online', async () => { 163 | let value = 1; 164 | 165 | // eslint-disable-next-line no-plusplus 166 | const fetcher = vi.fn(() => Promise.resolve(value++)); 167 | 168 | const { data } = useSWRWrapped(defaultKey, fetcher, defaultOptions); 169 | 170 | await flushPromises(); 171 | fetcher.mockClear(); 172 | expect(data.value).toBe(1); 173 | 174 | // Go offline 175 | vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false); 176 | dispatchEvent('offline', window); 177 | 178 | // Go online 179 | await nextTick(); 180 | vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); 181 | dispatchEvent('online', window); 182 | 183 | await flushPromises(); 184 | expect(fetcher).toBeCalledTimes(1); 185 | expect(data.value).toBe(2); 186 | }); 187 | 188 | it('should not revalidate when back online if config revalidateOnReconnect is false', async () => { 189 | setDataToMockedCache(defaultKey, { data: 'cachedData' }); 190 | 191 | const fetcher = vi.fn().mockResolvedValue('FetcherResult'); 192 | const { data } = useSWRWrapped(defaultKey, fetcher, { 193 | ...defaultOptions, 194 | revalidateOnReconnect: false, 195 | }); 196 | 197 | dispatchEvent('online', document); 198 | 199 | await flushPromises(); 200 | expect(fetcher).toBeCalledTimes(1); 201 | expect(data.value).toBe('FetcherResult'); 202 | }); 203 | }); 204 | -------------------------------------------------------------------------------- /lib/composables/swr/index.ts: -------------------------------------------------------------------------------- 1 | import { computed, shallowReadonly, watch, toRefs, unref, customRef, onUnmounted } from 'vue'; 2 | import { 3 | createUnrefFn, 4 | toReactive, 5 | useIntervalFn, 6 | useNetwork, 7 | whenever, 8 | useWindowFocus, 9 | } from '@vueuse/core'; 10 | 11 | import type { 12 | CacheProvider, 13 | CacheState, 14 | MaybeRef, 15 | OmitFirstArrayIndex, 16 | RevalidatorOpts, 17 | SWRComposableConfig, 18 | SWRConfig, 19 | SWRFetcher, 20 | SWRKey, 21 | } from '@/types'; 22 | import { isUndefined, serializeKey, subscribeCallback } from '@/utils'; 23 | import { mergeConfig } from '@/utils/merge-config'; 24 | import { useSWRConfig } from '@/composables/global-swr-config'; 25 | import { useScopeState } from '@/composables/scope-state'; 26 | 27 | type RefCachedOptions = { 28 | cacheProvider: CacheProvider; 29 | key: MaybeRef; 30 | stateKey: keyof CacheState; 31 | }; 32 | 33 | const setStateToCache = (key: string, cache: CacheProvider, state: Partial) => { 34 | cache.set(key, { 35 | data: undefined, 36 | error: undefined, 37 | fetchedIn: new Date(), 38 | isValidating: false, 39 | ...state, 40 | }); 41 | }; 42 | 43 | const refCached = (initialValue: T, { cacheProvider, stateKey, key }: RefCachedOptions) => { 44 | const cacheState = computed(() => cacheProvider.get(unref(key))); 45 | 46 | return customRef((track, trigger) => ({ 47 | get() { 48 | track(); 49 | return cacheState.value?.[stateKey] ?? initialValue; 50 | }, 51 | set(newValue) { 52 | // This also will create a state to new keys 53 | setStateToCache(unref(key), cacheProvider, { 54 | ...cacheState.value, 55 | [stateKey]: newValue, 56 | }); 57 | 58 | trigger(); 59 | }, 60 | })); 61 | }; 62 | 63 | const getFromFallback = createUnrefFn((key: string, fallback: SWRConfig['fallback']) => { 64 | if (!fallback) return undefined; 65 | 66 | const findedKey = Object.keys(fallback).find((_key) => serializeKey(_key).key === key); 67 | 68 | return findedKey && fallback[findedKey]; 69 | }); 70 | 71 | export const useSWR = ( 72 | _key: SWRKey, 73 | fetcher: SWRFetcher, 74 | config: SWRComposableConfig = {}, 75 | ) => { 76 | const { config: contextConfig, mutate } = useSWRConfig(); 77 | const { revalidateCache } = useScopeState(contextConfig.value.cacheProvider); 78 | const { isOnline } = useNetwork(); 79 | const isWindowFocused = useWindowFocus(); 80 | 81 | const mergedConfig = mergeConfig(contextConfig.value, config); 82 | 83 | const { 84 | cacheProvider, 85 | revalidateOnFocus, 86 | revalidateOnReconnect, 87 | revalidateIfStale, 88 | dedupingInterval, 89 | fallback, 90 | fallbackData, 91 | focusThrottleInterval, 92 | refreshInterval, 93 | refreshWhenHidden, 94 | refreshWhenOffline, 95 | onSuccess, 96 | onError, 97 | } = mergedConfig; 98 | 99 | const { key, args: fetcherArgs } = toRefs(toReactive(computed(() => serializeKey(_key)))); 100 | const fallbackValue = isUndefined(fallbackData) ? getFromFallback(key, fallback) : fallbackData; 101 | 102 | const valueInCache = computed(() => cacheProvider.get(key.value)); 103 | const hasCachedValue = computed(() => !!valueInCache.value); 104 | 105 | const data = refCached(fallbackValue, { cacheProvider, stateKey: 'data', key }); 106 | const error = refCached(undefined, { cacheProvider, stateKey: 'error', key }); 107 | const isValidating = refCached(true, { cacheProvider, stateKey: 'isValidating', key }); 108 | const fetchedIn = refCached(new Date(), { cacheProvider, stateKey: 'fetchedIn', key }); 109 | 110 | const fetchData = async (opts: RevalidatorOpts = { dedup: true }) => { 111 | const timestampToDedupExpire = (fetchedIn.value?.getTime() || 0) + dedupingInterval; 112 | const hasNotExpired = timestampToDedupExpire > Date.now(); 113 | 114 | // Dedup requets 115 | if ( 116 | opts.dedup && 117 | hasCachedValue.value && 118 | (hasNotExpired || (isValidating.value && dedupingInterval !== 0)) 119 | ) 120 | return; 121 | 122 | isValidating.value = true; 123 | 124 | try { 125 | const fetcherResponse = await fetcher.apply(fetcher, fetcherArgs.value); 126 | 127 | data.value = fetcherResponse; 128 | fetchedIn.value = new Date(); 129 | 130 | if (onSuccess) onSuccess(data.value, key.value, mergedConfig); 131 | } catch (err: any) { 132 | error.value = err; 133 | 134 | if (onError) onError(err, key.value, mergedConfig); 135 | } finally { 136 | isValidating.value = false; 137 | } 138 | }; 139 | 140 | let unsubRevalidateCb: ReturnType | undefined; 141 | 142 | const onRefresh = () => { 143 | const shouldSkipRefreshOffline = !refreshWhenOffline && !isOnline.value; 144 | const shouldSkipRefreshHidden = !refreshWhenHidden && document.visibilityState === 'hidden'; 145 | 146 | if (shouldSkipRefreshOffline || shouldSkipRefreshHidden) return; 147 | 148 | fetchData(); 149 | }; 150 | 151 | const onWindowFocus = () => { 152 | const fetchedInTimestamp = fetchedIn.value?.getTime() || 0; 153 | 154 | if (fetchedInTimestamp + focusThrottleInterval > Date.now()) return; 155 | 156 | fetchData(); 157 | }; 158 | 159 | const onRevalidate = async () => { 160 | if (!key.value) { 161 | return; 162 | } 163 | 164 | // Skip dedup when trigger by mutate 165 | await fetchData({ dedup: false }); 166 | }; 167 | 168 | const onKeyChange = (newKey: string, oldKey?: string) => { 169 | if (!!newKey && newKey !== oldKey && (revalidateIfStale || !data.value)) { 170 | fetchData(); 171 | } 172 | 173 | unsubRevalidateCb?.(); 174 | 175 | subscribeCallback(newKey, onRevalidate, revalidateCache.value); 176 | }; 177 | 178 | if (refreshInterval) { 179 | useIntervalFn(onRefresh, refreshInterval); 180 | } 181 | 182 | whenever( 183 | () => revalidateOnFocus && (revalidateIfStale || !data.value) && isWindowFocused.value, 184 | () => onWindowFocus(), 185 | ); 186 | 187 | whenever( 188 | () => revalidateOnReconnect && (revalidateIfStale || !data.value) && isOnline.value, 189 | () => fetchData(), 190 | ); 191 | 192 | watch(key, onKeyChange, { immediate: true }); 193 | onUnmounted(() => unsubRevalidateCb?.()); 194 | 195 | if (!hasCachedValue.value) { 196 | setStateToCache(key.value, cacheProvider, { 197 | error: error.value, 198 | data: data.value, 199 | isValidating: isValidating.value, 200 | fetchedIn: fetchedIn.value, 201 | }); 202 | } 203 | 204 | return { 205 | data: shallowReadonly(data), 206 | error: shallowReadonly(error), 207 | isValidating: shallowReadonly(isValidating), 208 | mutate: (...params: OmitFirstArrayIndex>) => 209 | mutate(unref(_key), ...params), 210 | }; 211 | }; 212 | -------------------------------------------------------------------------------- /lib/composables/swr/swr-stale.spec.ts: -------------------------------------------------------------------------------- 1 | import { nextTick, ref, watch } from 'vue'; 2 | import flushPromises from 'flush-promises'; 3 | 4 | import { SWRComposableConfig } from '@/types'; 5 | import { useInjectedSetup, mockedCache, setDataToMockedCache } from '@/utils/test'; 6 | 7 | import { useSWR } from '.'; 8 | import { configureGlobalSWR } from '../global-swr-config'; 9 | 10 | const cacheProvider = mockedCache; 11 | const defaultKey = 'defaultKey'; 12 | const defaultFetcher = vi.fn((key: string) => key); 13 | const defaultOptions: SWRComposableConfig = { dedupingInterval: 0 }; 14 | 15 | const setTimeoutPromise = (ms: number, resolveTo: unknown) => 16 | new Promise((resolve) => { 17 | setTimeout(() => resolve(resolveTo), ms); 18 | }); 19 | 20 | describe('useSWR - Stale', () => { 21 | beforeEach(() => { 22 | cacheProvider.clear(); 23 | 24 | vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); 25 | vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); 26 | }); 27 | 28 | it('should return cached value before fullfill fetcher', async () => { 29 | setDataToMockedCache(defaultKey, { data: 'cachedData' }); 30 | 31 | const fetcher = vi.fn(() => setTimeoutPromise(1000, 'FetcherResult')); 32 | 33 | const { data } = useInjectedSetup( 34 | () => configureGlobalSWR({ cacheProvider }), 35 | () => useSWR(defaultKey, fetcher, defaultOptions), 36 | ); 37 | 38 | expect(data.value).toBe('cachedData'); 39 | await flushPromises(); 40 | expect(data.value).toBe('cachedData'); 41 | }); 42 | 43 | it('should return new key cached value', async () => { 44 | const key = ref('key'); 45 | const keyTwo = 'key-two'; 46 | 47 | setDataToMockedCache(key.value, { data: 'cachedData' }); 48 | setDataToMockedCache(keyTwo, { data: 'cachedDataKeyTwo' }); 49 | 50 | const fetcher = vi.fn(() => setTimeoutPromise(1000, 'FetcherResult')); 51 | 52 | const { data } = useInjectedSetup( 53 | () => configureGlobalSWR({ cacheProvider }), 54 | () => useSWR(() => key.value, fetcher, defaultOptions), 55 | ); 56 | 57 | expect(data.value).toBe('cachedData'); 58 | 59 | key.value = keyTwo; 60 | await nextTick(); 61 | expect(data.value).toBe('cachedDataKeyTwo'); 62 | }); 63 | 64 | it('should return default value and return stale data when key changes', async () => { 65 | const key = ref('key'); 66 | const keyTwo = 'key-two'; 67 | 68 | setDataToMockedCache(keyTwo, { data: 'cachedData' }); 69 | 70 | const fetcher = vi.fn(() => setTimeoutPromise(1000, 'FetcherResult')); 71 | 72 | const { data } = useInjectedSetup( 73 | () => configureGlobalSWR({ cacheProvider }), 74 | () => useSWR(key, fetcher, defaultOptions), 75 | ); 76 | 77 | expect(data.value).toBeUndefined(); 78 | 79 | key.value = keyTwo; 80 | await nextTick(); 81 | expect(data.value).toBe('cachedData'); 82 | }); 83 | 84 | it('should change variables values when key change in an rastreable way', async () => { 85 | const key = ref(''); 86 | const keyTwo = 'key-two'; 87 | 88 | const newData = 'test'; 89 | const newErrr = new Error(); 90 | 91 | setDataToMockedCache(keyTwo, { data: newData, error: newErrr }); 92 | 93 | const { data, error } = useInjectedSetup( 94 | () => configureGlobalSWR({ cacheProvider }), 95 | () => useSWR(key, () => new Promise(() => {}), defaultOptions), 96 | ); 97 | 98 | const onDataChange = vi.fn(); 99 | const onErrorChange = vi.fn(); 100 | 101 | expect(cacheProvider.has(key.value)).toBeTruthy(); 102 | expect(data.value).toBeUndefined(); 103 | 104 | watch(data, onDataChange); 105 | watch(error, onErrorChange); 106 | 107 | key.value = keyTwo; 108 | 109 | await nextTick(); 110 | expect(onDataChange).toHaveBeenCalledTimes(1); 111 | expect(onDataChange).toHaveBeenCalledWith(newData, undefined, expect.anything()); 112 | expect(onErrorChange).toHaveBeenCalledTimes(1); 113 | expect(onErrorChange).toHaveBeenCalledWith(newErrr, undefined, expect.anything()); 114 | }); 115 | 116 | it.each([ 117 | 'fallback', 118 | 'Lorem ispum dolor sit amet', 119 | 100, 120 | ['item1', 'item2'], 121 | { a: 1, b: '' }, 122 | null, 123 | ])('should return fallbackData "%s" as initial value', (fallbackData) => { 124 | const { data } = useInjectedSetup( 125 | () => configureGlobalSWR({ cacheProvider }), 126 | () => useSWR(defaultKey, defaultFetcher, { fallbackData }), 127 | ); 128 | 129 | expect(data.value).toEqual(fallbackData); 130 | }); 131 | 132 | it('should return global fallbackData as initial value', () => { 133 | const fallbackData = 'fallback'; 134 | 135 | const { data } = useInjectedSetup( 136 | () => configureGlobalSWR({ cacheProvider, fallbackData }), 137 | () => useSWR(defaultKey, defaultFetcher), 138 | ); 139 | 140 | expect(data.value).toBe(fallbackData); 141 | }); 142 | 143 | it('should return stale data if fallbackData and stale data are present', async () => { 144 | const fallbackData = 'fallback'; 145 | const cachedData = 'cached value'; 146 | 147 | setDataToMockedCache(defaultKey, { data: cachedData }); 148 | 149 | const { data } = useInjectedSetup( 150 | () => configureGlobalSWR({ cacheProvider, fallbackData }), 151 | () => useSWR(defaultKey, defaultFetcher), 152 | ); 153 | 154 | expect(data.value).toBe(cachedData); 155 | }); 156 | 157 | it.each([ 158 | 'fallback', 159 | 'Lorem ispum dolor sit amet', 160 | 100, 161 | ['item1', 'item2'], 162 | { a: 1, b: '' }, 163 | null, 164 | ])('should return data "%s" in fallback as initial value', (fallbackData) => { 165 | const fallback = { [defaultKey]: fallbackData }; 166 | 167 | const { data } = useInjectedSetup( 168 | () => configureGlobalSWR({ cacheProvider }), 169 | () => useSWR(defaultKey, defaultFetcher, { fallback }), 170 | ); 171 | 172 | expect(data.value).toEqual(fallbackData); 173 | }); 174 | 175 | it('should return data in global fallback as initial value', () => { 176 | const fallback = { [defaultKey]: 'fallback' }; 177 | 178 | const { data } = useInjectedSetup( 179 | () => configureGlobalSWR({ cacheProvider, fallback }), 180 | () => useSWR(defaultKey, defaultFetcher), 181 | ); 182 | 183 | expect(data.value).toBe('fallback'); 184 | }); 185 | 186 | it('should return stale data if fallback and stale data are present', async () => { 187 | const fallback = { [defaultKey]: 'fallback' }; 188 | const cachedValue = 'cached value'; 189 | 190 | setDataToMockedCache(defaultKey, { data: cachedValue }); 191 | 192 | const { data } = useInjectedSetup( 193 | () => configureGlobalSWR({ cacheProvider, fallback }), 194 | () => useSWR(defaultKey, defaultFetcher), 195 | ); 196 | 197 | await flushPromises(); 198 | expect(data.value).toBe(cachedValue); 199 | }); 200 | 201 | it('should give priority to fallbackData over fallback as initial value', () => { 202 | const fallback = { [defaultKey]: 'fallback' }; 203 | const fallbackData = 'fallbackData'; 204 | 205 | const { data } = useInjectedSetup( 206 | () => configureGlobalSWR({ cacheProvider, fallback, fallbackData }), 207 | () => useSWR(defaultKey, defaultFetcher), 208 | ); 209 | 210 | expect(data.value).toBe(fallbackData); 211 | }); 212 | }); 213 | -------------------------------------------------------------------------------- /lib/composables/global-swr-config/global-swr-config.spec.ts: -------------------------------------------------------------------------------- 1 | import { provide, ref } from 'vue'; 2 | import type { Mock } from 'vitest'; 3 | 4 | import { defaultConfig, globalConfigKey } from '@/config'; 5 | import { AnyFunction, SWRConfig } from '@/types'; 6 | import { 7 | getDataFromMockedCache, 8 | mockedCache, 9 | setDataToMockedCache, 10 | useInjectedSetup, 11 | useSetup, 12 | } from '@/utils/test'; 13 | 14 | import { useSWRConfig, configureGlobalSWR } from '.'; 15 | 16 | const throwError = (error: Error) => { 17 | throw error; 18 | }; 19 | 20 | vi.mock('vue', async () => { 21 | const original = (await vi.importActual('vue')) as Record; 22 | 23 | return { 24 | ...original, 25 | provide: vi.fn(original.provide as AnyFunction), 26 | }; 27 | }); 28 | 29 | describe('useSWRConfig', () => { 30 | it.each([ 31 | [{}], 32 | [{ revalidateIfStale: true }], 33 | [{ revalidateIfStale: false }], 34 | [{ revalidateIfStale: false, revalidateOnFocus: true }], 35 | [{ revalidateIfStale: true, revalidateOnFocus: false }], 36 | ])('should get configs from global configuration: "%s"', (objToProvide) => { 37 | const { config } = useInjectedSetup( 38 | () => configureGlobalSWR(objToProvide), 39 | () => useSWRConfig(), 40 | ); 41 | 42 | expect(config.value).toContain(objToProvide); 43 | }); 44 | 45 | it('should return default config if not have an provided one', () => { 46 | const instance = useSetup(useSWRConfig); 47 | 48 | expect(instance.config).toEqual(defaultConfig); 49 | }); 50 | }); 51 | 52 | describe('configureGlobalSWR', () => { 53 | const provideMock = provide as Mock; 54 | 55 | it('should provide the default config if none is provided', () => { 56 | useSetup(() => configureGlobalSWR({})); 57 | 58 | expect(provideMock).toHaveBeenCalled(); 59 | expect(provideMock.mock.calls[0][0]).toEqual(globalConfigKey); 60 | expect(provideMock.mock.calls[0][1].value).toEqual(defaultConfig); 61 | }); 62 | 63 | it('should merge context config and the passed by argument', () => { 64 | const injectedConfig: Partial = Object.freeze({ 65 | ...defaultConfig, 66 | revalidateIfStale: false, 67 | revalidateOnFocus: false, 68 | revalidateOnReconnect: false, 69 | }); 70 | 71 | useInjectedSetup( 72 | () => provide(globalConfigKey, ref(injectedConfig)), 73 | () => configureGlobalSWR({ revalidateIfStale: true, revalidateOnFocus: false }), 74 | ); 75 | 76 | expect(provideMock).toHaveBeenCalled(); 77 | expect(provideMock.mock.calls[1][1].value).toEqual({ 78 | ...injectedConfig, 79 | revalidateOnFocus: false, 80 | revalidateIfStale: true, 81 | }); 82 | }); 83 | }); 84 | 85 | // eslint-disable-next-line prettier/prettier 86 | describe.each([ 87 | 'default', 88 | 'injected' 89 | ])('mutate - %s', (approach) => { 90 | const isDefaultApproach = approach === 'default'; 91 | const cacheProvider = isDefaultApproach ? defaultConfig.cacheProvider : mockedCache; 92 | const defaultKey = 'Default key'; 93 | 94 | const useSWRConfigWrapped = () => 95 | isDefaultApproach 96 | ? useSetup(() => useSWRConfig()) 97 | : useInjectedSetup( 98 | () => configureGlobalSWR({ cacheProvider }), 99 | () => useSWRConfig(), 100 | ); 101 | 102 | beforeEach(() => { 103 | cacheProvider.clear(); 104 | setDataToMockedCache(defaultKey, { data: 'cached data' }, cacheProvider); 105 | }); 106 | 107 | it.each([ 108 | ['sync value', 'sync value'], 109 | [Promise.resolve('resolved value'), 'resolved value'], 110 | [{ data: 'sync obj' }, { data: 'sync obj' }], 111 | [() => 'returned value', 'returned value'], 112 | [() => Promise.resolve('returned promise'), 'returned promise'], 113 | [() => ({ data: 'returned obj' }), { data: 'returned obj' }], 114 | ])( 115 | 'should write in the cache the value returned from function passed to mutate when that key does not exists in the cache yet: #%#', 116 | async (mutateVal, expected) => { 117 | cacheProvider.clear(); 118 | 119 | const { mutate } = useSWRConfigWrapped(); 120 | const key = 'key-1'; 121 | 122 | await mutate(key, mutateVal); 123 | expect(getDataFromMockedCache(key, cacheProvider)).toBeDefined(); 124 | expect(getDataFromMockedCache(key, cacheProvider)?.data).toEqual(expected); 125 | }, 126 | ); 127 | 128 | it('should write in the cache the value returned from function passed to mutate', async () => { 129 | const { mutate } = useSWRConfigWrapped(); 130 | 131 | await mutate(defaultKey, () => 'sync resolved value'); 132 | expect(getDataFromMockedCache(defaultKey, cacheProvider)?.data).toEqual('sync resolved value'); 133 | 134 | await mutate(defaultKey, () => Promise.resolve('async resolved value')); 135 | expect(getDataFromMockedCache(defaultKey, cacheProvider)?.data).toEqual('async resolved value'); 136 | }); 137 | 138 | it.each([ 139 | 'cached value', 140 | 1000, 141 | { id: 1, name: 'John', email: 'john@example.com' }, 142 | ['orange', 'apple', 'banana'], 143 | ])( 144 | 'should call update function passing the current cached data to first argument', 145 | (cachedData) => { 146 | const updateFn = vi.fn(); 147 | 148 | setDataToMockedCache(defaultKey, { data: cachedData }, cacheProvider); 149 | 150 | const { mutate } = useSWRConfigWrapped(); 151 | 152 | mutate(defaultKey, updateFn); 153 | 154 | expect(updateFn).toBeCalled(); 155 | expect(updateFn).toBeCalledWith(cachedData); 156 | }, 157 | ); 158 | 159 | it('should use the value resolved from updateFn for mutate`s return value', async () => { 160 | const { mutate } = useSWRConfigWrapped(); 161 | const expected = 'resolved value'; 162 | 163 | expect(await mutate(defaultKey, expected)).toEqual(expected); 164 | expect(await mutate(defaultKey, () => expected)).toEqual(expected); 165 | expect(await mutate(defaultKey, () => Promise.resolve(expected))).toEqual(expected); 166 | expect.assertions(3); 167 | }); 168 | 169 | it.each([ 170 | [new Error('sync error'), () => throwError(new Error('sync error'))], 171 | [new Error('async error'), () => Promise.reject(new Error('async error'))], 172 | ])('should re-throw if an error ocours inside updateFn: #%#', async (error, updateFn) => { 173 | const { mutate } = useSWRConfigWrapped(); 174 | 175 | await expect(mutate(defaultKey, updateFn)).rejects.toThrowError(error); 176 | 177 | expect.assertions(1); 178 | }); 179 | 180 | it('should re-throw if promise passed to mutate rejects', async () => { 181 | const { mutate } = useSWRConfigWrapped(); 182 | 183 | const promiseError = new Error('promise error'); 184 | const promise = Promise.reject(promiseError); 185 | 186 | await expect(mutate(defaultKey, promise)).rejects.toThrowError(promiseError); 187 | expect.assertions(1); 188 | }); 189 | 190 | it('should write `optimisticData` to cache right away and set to resolved value from updateFn after', async () => { 191 | const { mutate } = useSWRConfigWrapped(); 192 | 193 | const promise = mutate(defaultKey, Promise.resolve('resolved data'), { 194 | optimisticData: 'optimistic data', 195 | }); 196 | 197 | expect(getDataFromMockedCache(defaultKey, cacheProvider)?.data).toEqual('optimistic data'); 198 | 199 | await promise; 200 | expect(getDataFromMockedCache(defaultKey, cacheProvider)?.data).toEqual('resolved data'); 201 | }); 202 | 203 | it.each([ 204 | { optimisticData: 'optimistic data', updateFn: () => Promise.reject() }, 205 | { optimisticData: 'optimistic data', updateFn: () => throwError(new Error()) }, 206 | { optimisticData: undefined, updateFn: () => Promise.reject() }, 207 | { optimisticData: undefined, updateFn: () => throwError(new Error()) }, 208 | ])( 209 | 'should rollback data writed in cache when `optimisticData = $optimisticData` and `rollbackOnError = true`: #%#', 210 | async ({ optimisticData, updateFn }) => { 211 | const { mutate } = useSWRConfigWrapped(); 212 | 213 | try { 214 | await mutate(defaultKey, updateFn, { 215 | optimisticData, 216 | rollbackOnError: true, 217 | }); 218 | } catch (error) { 219 | expect(getDataFromMockedCache(defaultKey, cacheProvider)?.data).toEqual('cached data'); 220 | } 221 | 222 | expect.assertions(1); 223 | }, 224 | ); 225 | }); 226 | -------------------------------------------------------------------------------- /lib/composables/swr/swr.spec.ts: -------------------------------------------------------------------------------- 1 | import { isRef, ref } from 'vue'; 2 | import flushPromises from 'flush-promises'; 3 | 4 | import { withSetup } from '@/utils/with-setup'; 5 | import { SWRComposableConfig } from '@/types'; 6 | import { useInjectedSetup, setDataToMockedCache, mockedCache, dispatchEvent } from '@/utils/test'; 7 | 8 | import { useSWR } from '.'; 9 | import { configureGlobalSWR } from '../global-swr-config'; 10 | 11 | const cacheProvider = mockedCache; 12 | const defaultKey = 'defaultKey'; 13 | const defaultFetcher = vi.fn((key: string) => key); 14 | const defaultOptions: SWRComposableConfig = { dedupingInterval: 0 }; 15 | 16 | describe('useSWR', () => { 17 | beforeEach(() => { 18 | cacheProvider.clear(); 19 | 20 | vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); 21 | vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); 22 | }); 23 | 24 | it('should return a ref to data, error and isValidating', () => { 25 | const { data, error, isValidating } = useInjectedSetup( 26 | () => configureGlobalSWR({ cacheProvider }), 27 | () => useSWR(defaultKey, defaultFetcher), 28 | ); 29 | 30 | expect(isRef(data)).toBeTruthy(); 31 | expect(isRef(error)).toBeTruthy(); 32 | expect(isRef(isValidating)).toBeTruthy(); 33 | }); 34 | 35 | it('should start isValidating as true', () => { 36 | const { isValidating } = useInjectedSetup( 37 | () => configureGlobalSWR({ cacheProvider }), 38 | () => useSWR(defaultKey, defaultFetcher), 39 | ); 40 | 41 | expect(isValidating.value).toBeTruthy(); 42 | }); 43 | 44 | it('should set the same error in different `useSWR` calls with the same key', async () => { 45 | const [error1, error2] = useInjectedSetup( 46 | () => configureGlobalSWR({ cacheProvider }), 47 | () => { 48 | const error = new Error('Error in fetcher'); 49 | 50 | const { error: errorA } = useSWR(defaultKey, () => 'fulfilled', defaultOptions); 51 | const { error: errorB } = useSWR(defaultKey, () => Promise.reject(error), defaultOptions); 52 | 53 | return [errorA, errorB]; 54 | }, 55 | ); 56 | 57 | await flushPromises(); 58 | expect(error2.value).toBeInstanceOf(Error); 59 | expect(error1.value).toBeInstanceOf(Error); 60 | expect(error1.value).toBe(error2.value); 61 | }); 62 | 63 | it('should not revalidate when focus if config revalidateOnFocus is false', async () => { 64 | setDataToMockedCache(defaultKey, { data: 'cachedData' }); 65 | 66 | const fetcher = vi.fn().mockResolvedValue('FetcherResult'); 67 | const { data } = useInjectedSetup( 68 | () => configureGlobalSWR({ cacheProvider }), 69 | () => 70 | useSWR(defaultKey, fetcher, { 71 | ...defaultOptions, 72 | revalidateOnFocus: false, 73 | }), 74 | ); 75 | 76 | dispatchEvent('blur', document); 77 | dispatchEvent('focus', document); 78 | 79 | await flushPromises(); 80 | expect(fetcher).toBeCalledTimes(1); 81 | expect(data.value).toBe('FetcherResult'); 82 | }); 83 | 84 | it('should not revalidate when back online if config revalidateOnReconnect is false', async () => { 85 | setDataToMockedCache(defaultKey, { data: 'cachedData' }); 86 | 87 | const fetcher = vi.fn().mockResolvedValue('FetcherResult'); 88 | const { data } = useInjectedSetup( 89 | () => configureGlobalSWR({ cacheProvider }), 90 | () => 91 | useSWR(defaultKey, fetcher, { 92 | ...defaultOptions, 93 | revalidateOnReconnect: false, 94 | }), 95 | ); 96 | 97 | dispatchEvent('online', document); 98 | 99 | await flushPromises(); 100 | expect(fetcher).toBeCalledTimes(1); 101 | expect(data.value).toBe('FetcherResult'); 102 | }); 103 | 104 | it('should be reactive to the key as function with ref', async () => { 105 | const key = ref('initialKey'); 106 | const fetcher = vi.fn(); 107 | 108 | useInjectedSetup( 109 | () => configureGlobalSWR({ cacheProvider }), 110 | () => useSWR(() => `testing-key/${key.value}`, fetcher, defaultOptions), 111 | ); 112 | 113 | expect(fetcher).toBeCalledTimes(1); 114 | 115 | key.value = 'newKey'; 116 | 117 | await flushPromises(); 118 | expect(fetcher).toBeCalledTimes(2); 119 | }); 120 | 121 | it('should recall fetcher if key threw and change to valid value', async () => { 122 | const key = ref(); 123 | const fetcher = vi.fn(); 124 | 125 | useInjectedSetup( 126 | () => configureGlobalSWR({ cacheProvider }), 127 | () => useSWR(() => `testing-key/${key.value.id}`, fetcher, defaultOptions), 128 | ); 129 | 130 | expect(fetcher).toBeCalledTimes(0); 131 | 132 | key.value = { id: 1 }; 133 | 134 | await flushPromises(); 135 | expect(fetcher).toBeCalledTimes(1); 136 | }); 137 | 138 | it.each([ 139 | undefined, 140 | false as const, 141 | null, 142 | '', 143 | () => undefined, 144 | () => false as const, 145 | () => null, 146 | () => '', 147 | () => { 148 | throw new Error('Mock Error'); 149 | }, 150 | ])('should not call fetcher if key is/return falsy value or throw: %#', async (key) => { 151 | const fetcher = vi.fn(); 152 | 153 | withSetup(() => useSWR(key, fetcher, defaultOptions)); 154 | 155 | expect(fetcher).not.toHaveBeenCalled(); 156 | }); 157 | 158 | it('should not refresh if refreshInterval = 0', async () => { 159 | const fetcher = vi.fn(defaultFetcher); 160 | 161 | useInjectedSetup( 162 | () => configureGlobalSWR({ cacheProvider }), 163 | () => useSWR(defaultKey, fetcher, { ...defaultOptions, refreshInterval: 0 }), 164 | ); 165 | 166 | await flushPromises(); 167 | expect(fetcher).toHaveBeenCalledOnce(); 168 | 169 | vi.advanceTimersByTime(10000); 170 | expect(fetcher).toHaveBeenCalledOnce(); 171 | }); 172 | 173 | it('should refresh in refreshInterval time span', async () => { 174 | const fetcher = vi.fn(defaultFetcher); 175 | const refreshInterval = 2000; 176 | 177 | useInjectedSetup( 178 | () => configureGlobalSWR({ cacheProvider }), 179 | () => useSWR(defaultKey, fetcher, { ...defaultOptions, refreshInterval }), 180 | ); 181 | 182 | await flushPromises(); 183 | expect(fetcher).toHaveBeenCalledOnce(); 184 | 185 | vi.advanceTimersByTime(refreshInterval / 2); 186 | expect(fetcher).toHaveBeenCalledOnce(); 187 | 188 | vi.advanceTimersByTime(refreshInterval / 2); 189 | expect(fetcher).toHaveBeenCalledTimes(2); 190 | 191 | vi.advanceTimersByTime(refreshInterval * 3); 192 | expect(fetcher).toHaveBeenCalledTimes(5); 193 | }); 194 | 195 | it('should not refresh when offline and refreshWhenOffline = false', async () => { 196 | const fetcher = vi.fn(defaultFetcher); 197 | const refreshInterval = 2000; 198 | 199 | useInjectedSetup( 200 | () => configureGlobalSWR({ cacheProvider }), 201 | () => 202 | useSWR(defaultKey, fetcher, { 203 | ...defaultOptions, 204 | refreshInterval, 205 | refreshWhenOffline: false, 206 | }), 207 | ); 208 | 209 | await flushPromises(); 210 | expect(fetcher).toHaveBeenCalledOnce(); 211 | 212 | vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false); 213 | dispatchEvent('offline', window); 214 | 215 | vi.advanceTimersByTime(refreshInterval * 3); 216 | expect(fetcher).toHaveBeenCalledOnce(); 217 | }); 218 | 219 | it('should refresh when offline and refreshWhenOffline = true', async () => { 220 | const fetcher = vi.fn(defaultFetcher); 221 | const refreshInterval = 2000; 222 | 223 | useInjectedSetup( 224 | () => configureGlobalSWR({ cacheProvider }), 225 | () => 226 | useSWR(defaultKey, fetcher, { 227 | ...defaultOptions, 228 | refreshInterval, 229 | refreshWhenOffline: true, 230 | }), 231 | ); 232 | 233 | await flushPromises(); 234 | expect(fetcher).toHaveBeenCalledOnce(); 235 | 236 | vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false); 237 | vi.advanceTimersByTime(refreshInterval * 3); 238 | expect(fetcher).toHaveBeenCalledTimes(4); 239 | }); 240 | 241 | it('should not refresh when window is hidden and refreshWhenHidden = false', async () => { 242 | const fetcher = vi.fn(defaultFetcher); 243 | const refreshInterval = 2000; 244 | 245 | useInjectedSetup( 246 | () => configureGlobalSWR({ cacheProvider }), 247 | () => 248 | useSWR(defaultKey, fetcher, { 249 | ...defaultOptions, 250 | refreshInterval, 251 | refreshWhenHidden: false, 252 | }), 253 | ); 254 | 255 | await flushPromises(); 256 | expect(fetcher).toHaveBeenCalledOnce(); 257 | 258 | vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); 259 | vi.advanceTimersByTime(refreshInterval * 3); 260 | expect(fetcher).toHaveBeenCalledOnce(); 261 | }); 262 | 263 | it('should refresh when window is hidden and refreshWhenHidden = true', async () => { 264 | const fetcher = vi.fn(defaultFetcher); 265 | const refreshInterval = 2000; 266 | 267 | useInjectedSetup( 268 | () => configureGlobalSWR({ cacheProvider }), 269 | () => 270 | useSWR(defaultKey, fetcher, { 271 | ...defaultOptions, 272 | refreshInterval, 273 | refreshWhenHidden: true, 274 | }), 275 | ); 276 | 277 | await flushPromises(); 278 | expect(fetcher).toHaveBeenCalledOnce(); 279 | 280 | vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); 281 | vi.advanceTimersByTime(refreshInterval * 3); 282 | expect(fetcher).toHaveBeenCalledTimes(4); 283 | }); 284 | }); 285 | --------------------------------------------------------------------------------