Repository: Kong/swrv (ts)

Issue:
cache.ts API divergent from localStoreCache example in pwa and README
LocalStoreCache class in pwa example has very different interface than the memory version in cache.ts (different params, methods missing).  Also, the README has an even more out of date example of that class.  Ideally LocalStoreCache is an alternative option that is maintained and available in the library itself.

Pull request:
feat(cache) add localStorage adapter
Adds localStorage adapter to code as optional cache adapter.

Example usage:
```ts
import useSWRV from 'swrv'
import LocalStorageCache from 'swrv/cache/adapters/localStorage'

function useTodos (root, path) {
  const { data, error } = useSWRV(path, path => root.$api(`${path}`), {
    cache: new LocalStorageCache(),
    shouldRetryOnError: false
  })

  return {
    data,
    error
  }
}
```

- [x] add tests

Fixes #119 

Patch:
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..31fef27
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,2 @@
+dist
+esm
\ No newline at end of file
diff --git a/examples/pwa/package.json b/examples/pwa/package.json
index 9ac737c..ec52cbe 100644
--- a/examples/pwa/package.json
+++ b/examples/pwa/package.json
@@ -8,10 +8,10 @@
     "lint": "vue-cli-service lint"
   },
   "dependencies": {
-    "@vue/composition-api": "^0.4.0",
+    "@vue/composition-api": "1.0.0-beta.17",
     "core-js": "^3.6.4",
     "register-service-worker": "^1.6.2",
-    "swrv": "^0.1.1",
+    "swrv": "^0.8.2",
     "vue": "^2.6.11"
   },
   "devDependencies": {
diff --git a/examples/pwa/src/components/Todo.vue b/examples/pwa/src/components/Todo.vue
index af0af8a..86350a3 100644
--- a/examples/pwa/src/components/Todo.vue
+++ b/examples/pwa/src/components/Todo.vue
@@ -1,15 +1,16 @@
 <template>
   <div>
     <div v-if="todo === undefined && !error">Loading...</div>
-    <div v-if="error">{{error}}</div>
     {{ todo }}
+    <div v-if="error">{{error}}</div>
   </div>
 </template>
 
 <script>
-import useSWRV from 'swrv'
+import { defineComponent } from '@vue/composition-api'
+import useTodos from '../useTodos'
 
-export default {
+export default defineComponent({
   props: {
     id: {
       type: Number,
@@ -17,17 +18,12 @@ export default {
     }
   },
   setup ({ id }, { root }) {
-    const { data: todo, error } = useSWRV(`/todos/${id}`, path => root.$api(`${path}`), {
-      cache: root.$swrvCache
-    })
+    const { data, error } = useTodos(root, `/todos/${id}`)
 
     return {
-      todo,
+      todo: data,
       error
     }
   }
-}
+})
 </script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/examples/pwa/src/components/Todos.vue b/examples/pwa/src/components/Todos.vue
index ca10c62..8e8615b 100644
--- a/examples/pwa/src/components/Todos.vue
+++ b/examples/pwa/src/components/Todos.vue
@@ -24,13 +24,12 @@
 </template>
 
 <script>
-import useSWRV from 'swrv'
+import { defineComponent } from '@vue/composition-api'
+import useTodos from '../useTodos'
 
-export default {
+export default defineComponent({
   setup (props, { root, emit }) {
-    const { data: todos, error } = useSWRV(`/todos`, path => root.$api(`${path}`), {
-      cache: root.$swrvCache
-    })
+    const { data: todos, error } = useTodos(root, `/todos`)
 
     function viewTodo (id) {
       emit('view', id)
@@ -42,7 +41,7 @@ export default {
       viewTodo
     }
   }
-}
+})
 </script>
 
 <!-- Add "scoped" attribute to limit CSS to this component only -->
diff --git a/examples/pwa/src/localStorageCache.js b/examples/pwa/src/localStorageCache.js
deleted file mode 100644
index 9f8818f..0000000
--- a/examples/pwa/src/localStorageCache.js
+++ /dev/null
@@ -1,27 +0,0 @@
-export default class LocalStorageCache {
-  constructor () {
-    this.STORAGE_KEY = 'swrv'
-  }
-  encode (storage) { return btoa(JSON.stringify(storage)) }
-  decode (storage) { return JSON.parse(atob(storage)) }
-
-  get (k) {
-    const item = localStorage.getItem(this.STORAGE_KEY)
-    if (item) {
-      return JSON.parse(atob(item))[k]
-    }
-  }
-
-  set (k, v) {
-    let payload = {}
-    const storage = localStorage.getItem(this.STORAGE_KEY)
-    if (storage) {
-      payload = this.decode(storage)
-      payload[k] = { data: v, ttl: Date.now() }
-    } else {
-      payload = { [k]: { data: v, ttl: Date.now() } }
-    }
-
-    localStorage.setItem(this.STORAGE_KEY, this.encode(payload))
-  }
-}
diff --git a/examples/pwa/src/main.js b/examples/pwa/src/main.js
index 3c96632..680f83f 100644
--- a/examples/pwa/src/main.js
+++ b/examples/pwa/src/main.js
@@ -1,14 +1,12 @@
 import Vue from 'vue'
 import App from './App.vue'
 import VueCompositionApi from '@vue/composition-api'
-import LocalStorageCache from './localStorageCache'
 import './registerServiceWorker'
 
 Vue.use(VueCompositionApi)
 Vue.config.productionTip = false
 
 Vue.prototype.$api = path => fetch(`https://jsonplaceholder.typicode.com${path}`).then(res => res.json())
-Vue.prototype.$swrvCache = new LocalStorageCache()
 
 new Vue({
   render: h => h(App)
diff --git a/examples/pwa/src/useTodos.js b/examples/pwa/src/useTodos.js
new file mode 100644
index 0000000..8334d0a
--- /dev/null
+++ b/examples/pwa/src/useTodos.js
@@ -0,0 +1,14 @@
+import useSWRV from '../../../esm'
+import LocalStorageCache from '../../../esm/cache/adapters/localStorage'
+export default function useTodos (root, path) {
+  const { data, error } = useSWRV(path, path => root.$api(`${path}`), {
+    cache: new LocalStorageCache('swrv'),
+    shouldRetryOnError: false
+  })
+
+  return {
+    data,
+    error
+  }
+}
+
diff --git a/examples/pwa/yarn.lock b/examples/pwa/yarn.lock
index 0ad97a3..0ff2ff6 100644
--- a/examples/pwa/yarn.lock
+++ b/examples/pwa/yarn.lock
@@ -1097,12 +1097,12 @@
     source-map "~0.6.1"
     vue-template-es2015-compiler "^1.9.0"
 
-"@vue/composition-api@^0.4.0":
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/@vue/composition-api/-/composition-api-0.4.0.tgz#04e600106687de719d587c704296aa4a5b37ca28"
-  integrity sha512-8QxZK3BBbn29vIoiso+UthT6JUbeS0gPBWAxDUK2EComWNHluaGVWXC9fAnUG+L6PM+ApkFE2dOlv5Oi/o+cSQ==
+"@vue/composition-api@1.0.0-beta.17":
+  version "1.0.0-beta.17"
+  resolved "https://registry.npmjs.org/@vue/composition-api/-/composition-api-1.0.0-beta.17.tgz#63b65b3ed7cab649c53e4459fb0c5d3d1c545a96"
+  integrity sha512-TXa3162xQXeEoT8ar2aLPdSVGUy4phD8b3bytRORJQMvplQxi++QKXKAgYUTH0lwP0t0Dw2ddWtIyTTrOhta5w==
   dependencies:
-    tslib "^1.9.3"
+    tslib "^2.0.1"
 
 "@vue/preload-webpack-plugin@^1.1.0":
   version "1.1.1"
@@ -7668,11 +7668,16 @@ ts-pnp@^1.1.6:
   resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.1.6.tgz#389a24396d425a0d3162e96d2b4638900fdc289a"
   integrity sha512-CrG5GqAAzMT7144Cl+UIFP7mz/iIhiy+xQ6GGcnjTezhALT02uPMRw7tgDSESgB5MsfKt55+GPWw4ir1kVtMIQ==
 
-tslib@^1.9.0, tslib@^1.9.3:
+tslib@^1.9.0:
   version "1.10.0"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
   integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
 
+tslib@^2.0.1:
+  version "2.0.3"
+  resolved "https://registry.npmjs.org/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c"
+  integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ==
+
 tty-browserify@0.0.0:
   version "0.0.0"
   resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
diff --git a/src/cache/adapters/localStorage.ts b/src/cache/adapters/localStorage.ts
new file mode 100644
index 0000000..a9093c3
--- /dev/null
+++ b/src/cache/adapters/localStorage.ts
@@ -0,0 +1,76 @@
+import SWRVCache, { ICacheItem } from '..'
+
+/**
+ * LocalStorage cache adapter for swrv data cache.
+ * https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
+ */
+export default class LocalStorageCache extends SWRVCache<any> {
+  private STORAGE_KEY
+
+  constructor (key = 'swrv', ttl = 0) {
+    super(ttl)
+    this.STORAGE_KEY = key
+  }
+
+  private encode (storage) { return btoa(JSON.stringify(storage)) }
+  private decode (storage) { return JSON.parse(atob(storage)) }
+
+  get (k) {
+    const item = localStorage.getItem(this.STORAGE_KEY)
+    if (item) {
+      const _key = this.serializeKey(k)
+      const itemParsed: ICacheItem<any> = JSON.parse(atob(item))[_key]
+
+      if (itemParsed.expiresAt === null) {
+        itemParsed.expiresAt = Infinity // localStorage sets Infinity to 'null'
+      }
+
+      return itemParsed
+    }
+
+    return undefined
+  }
+
+  set (k: string, v: any, ttl: number) {
+    let payload = {}
+    const _key = this.serializeKey(k)
+    const timeToLive = ttl || this.ttl
+    const storage = localStorage.getItem(this.STORAGE_KEY)
+    const now = Date.now()
+    const item = {
+      data: v,
+      createdAt: now,
+      expiresAt: timeToLive ? now + timeToLive : Infinity
+    }
+
+    if (storage) {
+      payload = this.decode(storage)
+      payload[_key] = item
+    } else {
+      payload = { [_key]: item }
+    }
+
+    this.dispatchExpire(timeToLive, item, _key)
+    localStorage.setItem(this.STORAGE_KEY, this.encode(payload))
+  }
+
+  dispatchExpire (ttl, item, serializedKey) {
+    ttl && setTimeout(() => {
+      const current = Date.now()
+      const hasExpired = current >= item.expiresAt
+      if (hasExpired) this.delete(serializedKey)
+    }, ttl)
+  }
+
+  delete (serializedKey: string) {
+    const storage = localStorage.getItem(this.STORAGE_KEY)
+    let payload = {}
+
+    if (storage) {
+      payload = this.decode(storage)
+      delete payload[serializedKey]
+    }
+
+    localStorage.setItem(this.STORAGE_KEY, this.encode(payload))
+  }
+}
diff --git a/src/lib/cache.ts b/src/cache/index.ts
similarity index 66%
rename from src/lib/cache.ts
rename to src/cache/index.ts
index 5067ed1..ff06e24 100644
--- a/src/lib/cache.ts
+++ b/src/cache/index.ts
@@ -1,8 +1,7 @@
 import { IKey } from '../types'
-import hash from './hash'
-
-interface ICacheItem {
-  data: any,
+import hash from '../lib/hash'
+export interface ICacheItem<Data> {
+  data: Data,
   createdAt: number,
   expiresAt: number
 }
@@ -27,9 +26,9 @@ function serializeKeyDefault (key: IKey): string {
   return key
 }
 
-export default class SWRVCache {
-  private ttl: number
-  private items: Map<string, ICacheItem>
+export default class SWRVCache<CacheData> {
+  protected ttl: number
+  private items?: Map<string, ICacheItem<CacheData>>
 
   constructor (ttl = 0) {
     this.items = new Map()
@@ -40,7 +39,7 @@ export default class SWRVCache {
     return serializeKeyDefault(key)
   }
 
-  get (k: string): ICacheItem {
+  get (k: string): ICacheItem<CacheData> {
     const _key = this.serializeKey(k)
     return this.items.get(_key)
   }
@@ -55,17 +54,19 @@ export default class SWRVCache {
       expiresAt: timeToLive ? now + timeToLive : Infinity
     }
 
-    timeToLive && setTimeout(() => {
+    this.dispatchExpire(timeToLive, item, _key)
+    this.items.set(_key, item)
+  }
+
+  dispatchExpire (ttl, item, serializedKey) {
+    ttl && setTimeout(() => {
       const current = Date.now()
       const hasExpired = current >= item.expiresAt
-      if (hasExpired) this.delete(_key)
-    }, timeToLive)
-
-    this.items.set(_key, item)
+      if (hasExpired) this.delete(serializedKey)
+    }, ttl)
   }
 
-  delete (k: string) {
-    const _key = this.serializeKey(k)
-    this.items.delete(_key)
+  delete (serializedKey: string) {
+    this.items.delete(serializedKey)
   }
 }
diff --git a/src/index.ts b/src/index.ts
index d1a8727..24e187d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,4 +1,4 @@
-import SWRVCache from './lib/cache'
+import SWRVCache from './cache'
 import useSWRV, { mutate } from './use-swrv'
 
 export {
diff --git a/src/types.ts b/src/types.ts
index e6d89dd..0bdf2ef 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,5 +1,6 @@
 import { Ref } from '@vue/composition-api'
-import SWRVCache from './lib/cache'
+import SWRVCache from './cache'
+import LocalStorageCache from './cache/adapters/localStorage'
 
 export type fetcherFn<Data> = (...args: any) => Data | Promise<Data>
 
@@ -8,7 +9,7 @@ export interface IConfig<
   Fn extends fetcherFn<Data> = fetcherFn<Data>
 > {
   refreshInterval?: number
-  cache?: SWRVCache
+  cache?: LocalStorageCache | SWRVCache<any>
   dedupingInterval?: number
   ttl?: number
   serverTTL?: number
diff --git a/src/use-swrv.ts b/src/use-swrv.ts
index 63eed1f..aa3531a 100644
--- a/src/use-swrv.ts
+++ b/src/use-swrv.ts
@@ -31,12 +31,14 @@ import {
   getCurrentInstance
 } from '@vue/composition-api'
 import webPreset from './lib/web-preset'
-import SWRVCache from './lib/cache'
+import SWRVCache from './cache'
 import { IConfig, IKey, IResponse, fetcherFn, revalidateOptions } from './types'
 
-const DATA_CACHE = new SWRVCache()
-const REF_CACHE = new SWRVCache()
-const PROMISES_CACHE = new SWRVCache()
+type StateRef<Data, Error> = { data: Data, error: Error, isValidating: boolean, revalidate: Function, key: any };
+
+const DATA_CACHE = new SWRVCache<Omit<IResponse, 'mutate'>>()
+const REF_CACHE = new SWRVCache<StateRef<any, any>[]>()
+const PROMISES_CACHE = new SWRVCache<Omit<IResponse, 'mutate'>>()
 
 const defaultConfig: IConfig = {
   cache: DATA_CACHE,
@@ -138,8 +140,6 @@ const mutate = async <Data>(key: string, res: Promise<Data> | Data, cache = DATA
   return newData
 }
 
-type StateRef<Data, Error> = { data: Data, error: Error, isValidating: boolean, revalidate: Function, key: any };
-
 /* Stale-While-Revalidate hook to handle fetching, caching, validation, and more... */
 function useSWRV<Data = any, Error = any>(
   key: IKey


Test patch:
diff --git a/tests/cache.spec.tsx b/tests/cache.spec.tsx
new file mode 100644
index 0000000..2f5c0fb
--- /dev/null
+++ b/tests/cache.spec.tsx
@@ -0,0 +1,127 @@
+import Vue from 'vue/dist/vue.common.js'
+import VueCompositionApi from '@vue/composition-api'
+import timeout from './utils/jest-timeout'
+import useSWRV from '../src/use-swrv'
+import LocalStorageAdapter from '../src/cache/adapters/localStorage'
+import { ICacheItem } from '../src/cache'
+import tick from './utils/tick'
+import { advanceBy, advanceTo } from 'jest-date-mock'
+
+Vue.use(VueCompositionApi)
+
+jest.useFakeTimers()
+
+describe('cache - adapters', () => {
+  beforeEach(() => {
+    localStorage.clear()
+  })
+
+  describe('localStorage', () => {
+    const fetch = url => new Promise((resolve) => {
+      setTimeout(() => { resolve(`I am a response from ${url}`) }, 100)
+    })
+
+    it('saves data cache', async () => {
+      new Vue({
+        template: `<div>hello, {{ data }}</div>`,
+        setup () {
+          return useSWRV('/api/users', fetch, {
+            cache: new LocalStorageAdapter()
+          })
+        }
+      }).$mount()
+
+      expect(localStorage.getItem('swrv')).toBeNull()
+
+      await timeout(100)
+
+      const localStorageData: Record<string, ICacheItem<any>> = JSON.parse(atob(localStorage.getItem('swrv')))
+
+      expect(localStorage.getItem('swrv')).toBeDefined()
+      expect(localStorageData).toHaveProperty('/api/users')
+      expect(localStorageData['/api/users'].expiresAt).toBeNull() // Infinity shows up as 'null'
+      expect(localStorageData['/api/users'].createdAt).toBeLessThanOrEqual(Date.now())
+      expect(localStorageData['/api/users'].data.data).toBe('I am a response from /api/users')
+    })
+
+    it('updates data cache', async () => {
+      let count = 0
+      new Vue({
+        template: `<div>hello, {{ data }}</div>`,
+        setup () {
+          return useSWRV('/api/consumers', () => ++count, {
+            cache: new LocalStorageAdapter(),
+            refreshInterval: 100,
+            dedupingInterval: 0
+          })
+        }
+      }).$mount()
+
+      expect(localStorage.getItem('swrv')).toBeDefined()
+      const checkStorage = (key): Record<string, ICacheItem<any>> => {
+        return JSON.parse(atob(localStorage.getItem('swrv')))[key]
+      }
+
+      await timeout(100)
+      await tick()
+      expect(checkStorage('/api/consumers').data.data).toBe(1)
+
+      await timeout(100)
+      await tick()
+      expect(checkStorage('/api/consumers').data.data).toBe(2)
+
+      await timeout(100)
+      await tick()
+      expect(checkStorage('/api/consumers').data.data).toBe(3)
+    })
+
+    it('deletes cache item after expiry', async () => {
+      advanceTo(new Date())
+      new Vue({
+        template: `<div>hello, {{ data }}</div>`,
+        setup () {
+          return useSWRV('/api/services', fetch, {
+            cache: new LocalStorageAdapter('swrv', 200)
+          })
+        }
+      }).$mount()
+
+      expect(localStorage.getItem('swrv')).toBeDefined()
+      const checkStorage = (key): Record<string, ICacheItem<any>> => {
+        const db = localStorage.getItem('swrv')
+        if (!db) {
+          return undefined
+        }
+        return JSON.parse(atob(db))[key]
+      }
+
+      await timeout(100)
+      await tick(4)
+      expect(checkStorage('/api/services').data.data).toBe('I am a response from /api/services')
+
+      // TODO: not sure why only running both these methods works
+      await advanceBy(200)
+      await timeout(200)
+
+      await tick()
+      expect(checkStorage('/api/services')).toBeUndefined()
+    })
+
+    it('accepts custom localStorage key', async () => {
+      new Vue({
+        template: `<div>hello, {{ data }}</div>`,
+        setup () {
+          return useSWRV('/api/some-data', fetch, {
+            cache: new LocalStorageAdapter('myAppData')
+          })
+        }
+      }).$mount()
+
+      expect(localStorage.getItem('myAppData')).toBeNull()
+      expect(localStorage.getItem('swrv')).toBeNull()
+
+      await timeout(100)
+      expect(localStorage.getItem('myAppData')).toBeDefined()
+    })
+  })
+})
diff --git a/tests/test-compat-all.sh b/tests/test-compat-all.sh
index aae146e..bd049c7 100755
--- a/tests/test-compat-all.sh
+++ b/tests/test-compat-all.sh
@@ -2,7 +2,6 @@
 
 set -e
 
-tests/test-compat.sh "~0.5.0"
 tests/test-compat.sh "~0.6.0"
 tests/test-compat.sh "1.0.0-beta.17"
 tests/test-compat.sh "1.0.0-beta.22"
diff --git a/tests/use-swrv.spec.tsx b/tests/use-swrv.spec.tsx
index e873cf4..cdf8369 100644
--- a/tests/use-swrv.spec.tsx
+++ b/tests/use-swrv.spec.tsx
@@ -1,17 +1,13 @@
 import Vue from 'vue/dist/vue.common.js'
 import VueCompositionApi, { watch, defineComponent, ref } from '@vue/composition-api'
 import useSWRV, { mutate } from '../src/use-swrv'
+import tick from './utils/tick'
+import timeout from './utils/jest-timeout'
 import { advanceBy, advanceTo, clear } from 'jest-date-mock'
 
 Vue.use(VueCompositionApi)
 
 jest.useFakeTimers()
-const timeout: Function = milliseconds => jest.advanceTimersByTime(milliseconds)
-const tick: Function = async (vm, times) => {
-  for (let _ in [...Array(times).keys()]) {
-    await vm.$nextTick()
-  }
-}
 
 describe('useSWRV', () => {
   it('should return data on hydration when fetch is not a promise', async done => {
@@ -48,7 +44,7 @@ describe('useSWRV', () => {
       }
     }).$mount()
 
-    await tick(vm, 4)
+    await tick(4)
 
     expect(vm.$el.textContent).toBe('hello, SWR')
     done()
@@ -64,7 +60,7 @@ describe('useSWRV', () => {
 
     expect(vm.$el.textContent).toBe('hello, ')
 
-    await tick(vm, 2)
+    await tick(2)
 
     expect(vm.$el.textContent).toEqual('hello, SWR')
     done()
@@ -122,7 +118,7 @@ describe('useSWRV', () => {
     expect(vm.$el.textContent).toBe('hello, ')
 
     timeout(200)
-    await tick(vm, 2)
+    await tick(2)
 
     expect(vm.$el.textContent).toBe('hello, SWR')
     done()
@@ -147,7 +143,7 @@ describe('useSWRV', () => {
     expect(vm.$el.textContent).toBe(', , yes yes')
 
     timeout(200)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('SWR, SWR, no no')
 
     // only fetches once
@@ -176,15 +172,15 @@ describe('useSWRV', () => {
     expect(vm.$el.textContent).toBe(', , yes yes')
 
     timeout(200)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('SWR, SWR, no no')
 
     timeout(100)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('SWR, SWR, no no')
 
     timeout(100)
-    await tick(vm, 4)
+    await tick(4)
     expect(vm.$el.textContent).toBe('SWR, SWR, no no')
 
     expect(count).toEqual(1)
@@ -223,17 +219,17 @@ describe('useSWRV', () => {
 
     expect(vm.$el.textContent).toBe('d1: d2:')
     timeout(100)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('d1: d2:')
     expect(count).toEqual(0) // Promise still in flight
 
     timeout(900)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('d1:123 d2:')
     expect(count).toEqual(1) // now that the first promise resolved, second one will fire
 
     timeout(200)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('d1:123 d2:123')
     expect(count).toEqual(2)
     done()
@@ -260,17 +256,17 @@ describe('useSWRV', () => {
     expect(vm.$el.textContent).toBe(',,')
 
     timeout(100)
-    await tick(vm, 2)
+    await tick(2)
     expect(count).toBe(2)
     expect(vm.$el.textContent).toBe('d1,,')
 
     timeout(100)
-    await tick(vm, 2)
+    await tick(2)
     expect(count).toBe(3)
     expect(vm.$el.textContent).toBe('d1,d2,')
 
     timeout(100)
-    await tick(vm, 3)
+    await tick(3)
     expect(vm.$el.textContent).toBe('d1,d2,d3')
     done()
   })
@@ -297,19 +293,19 @@ describe('useSWRV', () => {
     expect(count).toBe(0)
     expect(vm.$el.textContent).toBe('')
     timeout(100)
-    await tick(vm, 2)
+    await tick(2)
     expect(count).toBe(0)
     expect(vm.$el.textContent).toBe('')
 
     // Does not revalidate even after some time passes
     timeout(100)
-    await tick(vm, 2)
+    await tick(2)
     expect(count).toBe(0)
     expect(vm.$el.textContent).toBe('')
 
     // does not revalidate on refresh interval
     timeout(1000)
-    await tick(vm, 2)
+    await tick(2)
     expect(count).toBe(0)
     expect(vm.$el.textContent).toBe('')
 
@@ -317,7 +313,7 @@ describe('useSWRV', () => {
     let evt = new Event('visibilitychange')
     document.dispatchEvent(evt)
     timeout(100)
-    await tick(vm, 2)
+    await tick(2)
     expect(count).toBe(0)
     expect(vm.$el.textContent).toBe('')
 
@@ -348,14 +344,14 @@ describe('useSWRV', () => {
 
     // initially page is empty, but fetcher has fired with page=1
     expect(vm.$el.textContent).toBe('Page: ')
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$data.page).toBe('1')
     expect(vm.$el.textContent).toBe('Page: ')
 
     // page has now updated to page=2, fetcher1 has not yet resolved, fetcher
     // for page=2 has now fired
     timeout(500)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$data.page).toBe('2')
     expect(vm.$el.textContent).toBe('Page: ')
 
@@ -363,14 +359,14 @@ describe('useSWRV', () => {
     // current page, so the data ref does not update. fetcher for page=3 has
     // now fired
     timeout(500)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$data.page).toBe('3')
     expect(vm.$el.textContent).toBe('Page: ')
 
     // cache key is no longer updating and the fetcher for page=3 has resolved
     // so the data ref now updates.
     timeout(1000)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$data.page).toBe('3')
     expect(vm.$el.textContent).toBe('Page: 3')
 
@@ -398,7 +394,7 @@ describe('useSWRV', () => {
     expect(vm.$el.textContent).toBe('d: cache:')
     expect(invoked).toBe(1)
     timeout(200)
-    await tick(vm, 2)
+    await tick(2)
 
     expect(vm.$el.textContent).toBe('d:SWR cache:SWR')
     expect(invoked).toBe(1) // empty fetcher is OK
@@ -437,7 +433,7 @@ describe('useSWRV', () => {
     expect(vm.$el.textContent).toBe('data: ')
     expect(invoked).toBe(1)
     timeout(200)
-    await tick(vm, 2)
+    await tick(2)
 
     timeout(200)
     expect(vm.$el.textContent).toBe('data:SWR hello SWR')
@@ -467,24 +463,24 @@ describe('useSWRV', () => {
     }).$mount()
 
     timeout(75)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('hello, , loading')
 
     timeout(25)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('hello, data, ready')
 
     mutate()
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('hello, data, loading')
     timeout(25)
     mutate()
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('hello, data, loading')
 
     mutate()
     timeout(100)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('hello, data, ready')
     done()
   })
@@ -504,7 +500,7 @@ describe('useSWRV', () => {
     const vm1 = new Vue({
       template: `<div>{{ data1 }}</div>`,
       setup  () {
-        const { data: data1 } = useSWRV('ttlData1', undefined, { ttl })
+        const { data: data1 } = useSWRV('ttlData1', undefined, { ttl, fetcher: undefined })
 
         return { data1 }
       }
@@ -512,14 +508,14 @@ describe('useSWRV', () => {
     const component = {
       template: `<div>{{ data2 }}</div>`,
       setup  () {
-        const { data: data2 } = useSWRV('ttlData1', undefined, { ttl })
+        const { data: data2 } = useSWRV('ttlData1', undefined, { ttl, fetcher: undefined })
 
         return { data2 }
       }
     }
 
     let vm2
-    await tick(vm1, 2)
+    await tick(2)
 
     // first time
     expect(count).toBe(1)
@@ -531,7 +527,7 @@ describe('useSWRV', () => {
     advanceBy(6000)
     timeout(6000)
     mutate('ttlData1', fetch(), undefined, ttl)
-    await tick(vm1, 2)
+    await tick(2)
 
     expect(count).toBe(2)
     expect(vm1.$el.textContent).toBe('2')
@@ -541,7 +537,7 @@ describe('useSWRV', () => {
     // after a long time
     advanceBy(100000)
     timeout(100000)
-    await tick(vm1, 2)
+    await tick(2)
 
     expect(count).toBe(2)
     expect(vm1.$el.textContent).toBe('2')
@@ -568,7 +564,7 @@ describe('useSWRV', () => {
     const vm1 = new Vue({
       template: `<div>{{ data1 }}</div>`,
       setup  () {
-        const { data: data1 } = useSWRV('ttlData2', undefined, { ttl })
+        const { data: data1 } = useSWRV('ttlData2', undefined, { ttl, fetcher: undefined })
 
         return { data1 }
       }
@@ -576,14 +572,14 @@ describe('useSWRV', () => {
     const component = {
       template: `<div>{{ data2 }}</div>`,
       setup  () {
-        const { data: data2 } = useSWRV('ttlData2', undefined, { ttl })
+        const { data: data2 } = useSWRV('ttlData2', undefined, { ttl, fetcher: undefined })
 
         return { data2 }
       }
     }
 
     let vm2
-    await tick(vm1, 2)
+    await tick(2)
 
     // first time
     expect(count).toBe(1)
@@ -595,7 +591,7 @@ describe('useSWRV', () => {
     advanceBy(6000)
     timeout(6000)
     mutate('ttlData2', fetch(), undefined, ttl)
-    await tick(vm1, 2)
+    await tick(2)
 
     expect(count).toBe(2)
     expect(vm1.$el.textContent).toBe('1')
@@ -605,7 +601,7 @@ describe('useSWRV', () => {
     // after a long time
     advanceBy(100000)
     timeout(100000)
-    await tick(vm1, 2)
+    await tick(2)
 
     expect(count).toBe(2)
     expect(vm1.$el.textContent).toBe('1')
@@ -630,7 +626,7 @@ describe('useSWRV', () => {
       }
     }).$mount()
 
-    await tick(vm, 4)
+    await tick(4)
 
     expect(vm.$el.textContent).toBe('hello, bob and sue')
     delete global['fetch']
@@ -656,7 +652,7 @@ describe('useSWRV - loading', () => {
     expect(vm.$el.textContent).toBe('hello, loading')
     timeout(100)
 
-    await tick(vm, 2)
+    await tick(2)
 
     expect(vm.$el.textContent).toBe('hello, data')
     expect(renderCount).toEqual(2)
@@ -677,12 +673,12 @@ describe('useSWRV - loading', () => {
     expect(vm.$el.textContent).toBe('hello, , loading')
 
     timeout(100)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('hello, data, ready')
 
     // Reactive to future refreshes
     timeout(900)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('hello, data, loading')
 
     done()
@@ -744,11 +740,11 @@ describe('useSWRV - mutate', () => {
     expect(vm.$el.textContent).toBe('hello, ')
 
     timeout(100)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('hello, 1')
 
     timeout(200)
-    await tick(vm, 4)
+    await tick(4)
     expect(vm.$el.textContent).toBe('hello, 2')
 
     done()
@@ -793,7 +789,7 @@ describe('useSWRV - listeners', () => {
 
   it('events trigger revalidate - switching windows/tabs', async () => {
     let revalidations = 0
-    const vm = new Vue({
+    new Vue({
       template: `<div>hello, {{ data }}</div>`,
       setup  () {
         const refs = useSWRV('cache-key-listeners-2', () => {
@@ -804,19 +800,19 @@ describe('useSWRV - listeners', () => {
       }
     }).$mount()
 
-    await tick(vm, 2)
+    await tick(2)
     expect(revalidations).toBe(1)
 
     let evt = new Event('visibilitychange')
     document.dispatchEvent(evt)
 
-    await tick(vm, 2)
+    await tick(2)
     expect(revalidations).toBe(2)
   })
 
   it('events trigger revalidate - focusing back on a window/tab', async () => {
     let revalidations = 0
-    const vm = new Vue({
+    new Vue({
       template: `<div>hello, {{ data }}</div>`,
       setup  () {
         const refs = useSWRV('cache-key-listeners-3', () => {
@@ -827,13 +823,13 @@ describe('useSWRV - listeners', () => {
       }
     }).$mount()
 
-    await tick(vm, 2)
+    await tick(2)
     expect(revalidations).toBe(1)
 
     let evt = new Event('focus')
     window.dispatchEvent(evt)
 
-    await tick(vm, 2)
+    await tick(2)
     expect(revalidations).toBe(2)
   })
 })
@@ -851,16 +847,16 @@ describe('useSWRV - refresh', () => {
       }
     }).$mount()
 
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toEqual('count: 0')
     timeout(210)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toEqual('count: 1')
     timeout(50)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toEqual('count: 1')
     timeout(150)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toEqual('count: 2')
     done()
   })
@@ -889,7 +885,7 @@ describe('useSWRV - refresh', () => {
     expect(vm.$el.textContent).toBe('count: ')
     advanceBy(100)
     timeout(100)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('count: 1') // first resolve
     /**
      * check inside promises cache within deduping interval so even though
@@ -898,22 +894,22 @@ describe('useSWRV - refresh', () => {
      */
     advanceBy(100)
     timeout(100) // first fetcher fire
-    await tick(vm, 1)
+    await tick(1)
     expect(vm.$el.textContent).toBe('count: 1')
 
     advanceBy(100)
     timeout(100) // deduped
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('count: 1')
 
     advanceBy(100)
     timeout(100) // second fetcher fire
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('count: 1')
 
     advanceBy(200)
     timeout(200)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('count: 2')
 
     clear()
@@ -935,7 +931,7 @@ describe('useSWRV - error', () => {
       }
     }).$mount()
 
-    await tick(vm, 2)
+    await tick(2)
 
     expect(vm.$el.textContent.trim()).toBe('error!')
     done()
@@ -965,7 +961,7 @@ describe('useSWRV - error', () => {
 
     expect(vm.$el.textContent).toBe('hello, ')
     timeout(200)
-    await tick(vm, 2)
+    await tick(2)
     expect(erroredSWR).toEqual('error!')
     done()
   })
@@ -988,15 +984,15 @@ describe('useSWRV - error', () => {
     }).$mount()
 
     timeout(300) // 200 refresh + 100 timeout
-    await tick(vm, 3)
+    await tick(3)
     expect(vm.$el.textContent).toBe('count: 1 ')
 
     timeout(300)
-    await tick(vm, 3)
+    await tick(3)
     expect(vm.$el.textContent).toBe('count: 2 ')
 
     timeout(300)
-    await tick(vm, 2)
+    await tick(2)
     // stale data sticks around even when error exists
     expect(vm.$el.textContent).toBe('count: 2 Error: uh oh!')
     done()
@@ -1022,18 +1018,18 @@ describe('useSWRV - error', () => {
     }).$mount()
 
     timeout(100)
-    await tick(vm, 3)
+    await tick(3)
     expect(vm.$el.textContent).toBe('count: 1 ')
 
     revalidate()
     timeout(100)
-    await tick(vm, 3)
+    await tick(3)
     // stale data sticks around even when error exists
     expect(vm.$el.textContent).toBe('count: 1 Error: uh oh!')
 
     revalidate()
     timeout(100)
-    await tick(vm, 3)
+    await tick(3)
     // error must be reset if fetching succeeds
     expect(vm.$el.textContent).toBe('count: 3 ')
     done()
@@ -1062,19 +1058,19 @@ describe('useSWRV - error', () => {
     expect(vm.$el.textContent.trim()).toBe('count: ,')
 
     timeout(100)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent.trim()).toBe('count: , Error: 1')
 
     timeout(600)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('count: , Error: 2')
 
     timeout(900)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('count: , Error: 2')
 
     timeout(200)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent.trim()).toBe('count: 3,')
     done()
   })
@@ -1103,23 +1099,23 @@ describe('useSWRV - error', () => {
     expect(vm.$el.textContent.trim()).toBe('count: ,')
 
     timeout(100)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent.trim()).toBe('count: , Error: 1')
 
     timeout(600)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('count: , Error: 2')
 
     timeout(1100)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('count: , Error: 3')
 
     timeout(1600)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent.trim()).toBe('count: , Error: 4')
 
     timeout(2100)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent.trim()).toBe('count: , Error: 4') // Does not exceed retry count
 
     done()
@@ -1149,11 +1145,11 @@ describe('useSWRV - error', () => {
     expect(vm.$el.textContent.trim()).toBe('count: ,')
 
     timeout(100)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent.trim()).toBe('count: , Error: 1')
 
     timeout(600)
-    await tick(vm, 2)
+    await tick(2)
     expect(vm.$el.textContent).toBe('count: , Error: 1')
 
     done()
@@ -1184,19 +1180,19 @@ describe('useSWRV - window events', () => {
       }
     }).$mount()
 
-    await tick(vm, 1)
+    await tick(1)
     expect(vm.$el.textContent).toBe('count: 0')
 
     toggleVisibility(undefined)
     timeout(200)
-    await tick(vm, 1)
+    await tick(1)
     // should still update even though visibilityState is undefined
     expect(vm.$el.textContent).toBe('count: 1')
 
     toggleVisibility('hidden')
 
     timeout(200)
-    await tick(vm, 1)
+    await tick(1)
 
     // should not rerender because document is hidden e.g. switched tabs
     expect(vm.$el.textContent).toBe('count: 1')
@@ -1225,54 +1221,54 @@ describe('useSWRV - window events', () => {
     }).$mount()
 
     timeout(200)
-    await tick(vm, 1)
+    await tick(1)
     expect(vm.$el.textContent).toBe('count: 0')
     expect(count).toBe(0)
 
     timeout(200)
-    await tick(vm, 1)
+    await tick(1)
     expect(vm.$el.textContent).toBe('count: 0')
     expect(count).toBe(0)
 
     timeout(200)
-    await tick(vm, 1)
+    await tick(1)
     expect(vm.$el.textContent).toBe('count: 0')
     expect(count).toBe(0)
 
     timeout(200)
-    await tick(vm, 1)
+    await tick(1)
     expect(vm.$el.textContent).toBe('count: 0')
     expect(count).toBe(0)
 
     timeout(200)
-    await tick(vm, 1)
+    await tick(1)
     expect(vm.$el.textContent).toBe('count: 0')
     expect(count).toBe(0)
 
     toggleVisibility('visible')
 
     timeout(200)
-    await tick(vm, 1)
+    await tick(1)
     expect(vm.$el.textContent).toBe('count: 1')
     expect(count).toBe(1)
 
     timeout(200)
-    await tick(vm, 1)
+    await tick(1)
     expect(vm.$el.textContent).toBe('count: 1')
     expect(count).toBe(1)
 
     timeout(200)
-    await tick(vm, 1)
+    await tick(1)
     expect(vm.$el.textContent).toBe('count: 2')
     expect(count).toBe(2)
 
     timeout(200)
-    await tick(vm, 1)
+    await tick(1)
     expect(vm.$el.textContent).toBe('count: 2')
     expect(count).toBe(2)
 
     timeout(200)
-    await tick(vm, 1)
+    await tick(1)
     expect(vm.$el.textContent).toBe('count: 3')
     expect(count).toBe(3)
 
@@ -1294,13 +1290,13 @@ describe('useSWRV - window events', () => {
       }
     }).$mount()
 
-    await tick(vm, 1)
+    await tick(1)
     expect(vm.$el.textContent).toBe('count: 0')
 
     toggleOnline(undefined)
 
     timeout(200)
-    await tick(vm, 1)
+    await tick(1)
     // should rerender since we're AMERICA ONLINE
     expect(vm.$el.textContent).toBe('count: 1')
 
@@ -1308,7 +1304,7 @@ describe('useSWRV - window events', () => {
     toggleOnline(false)
 
     timeout(200)
-    await tick(vm, 1)
+    await tick(1)
     // should not rerender cuz offline
     expect(vm.$el.textContent).toBe('count: 1')
 
diff --git a/tests/utils/jest-timeout.ts b/tests/utils/jest-timeout.ts
new file mode 100644
index 0000000..03fef07
--- /dev/null
+++ b/tests/utils/jest-timeout.ts
@@ -0,0 +1,4 @@
+export default function timeout (milliseconds: number) {
+  jest.advanceTimersByTime(milliseconds)
+}
+
diff --git a/tests/utils/tick.ts b/tests/utils/tick.ts
new file mode 100644
index 0000000..5e170b4
--- /dev/null
+++ b/tests/utils/tick.ts
@@ -0,0 +1,7 @@
+import { nextTick } from '@vue/composition-api'
+
+export default async function tick (times = 1) {
+  for (let _ in [...Array(times).keys()]) {
+    await nextTick()
+  }
+}