feat: implement force + triggeredBy

This commit is contained in:
Alexander 2024-02-18 17:08:57 +01:00
parent 59dd5fd939
commit 6136b3e219
5 changed files with 103 additions and 28 deletions

View File

@ -36,6 +36,7 @@ export type KeyOfRes<Transform extends _Transform> = KeysOf<ReturnType<Transform
export type MultiWatchSources = (WatchSource<unknown> | object)[] export type MultiWatchSources = (WatchSource<unknown> | object)[]
export type GetCachedDataTriggeredBy = 'initial' | 'refresh:hook' | 'refresh:manual' | 'watch'
export interface AsyncDataOptions< export interface AsyncDataOptions<
ResT, ResT,
DataT = ResT, DataT = ResT,
@ -60,8 +61,9 @@ export interface AsyncDataOptions<
* Provide a function which returns cached data. * Provide a function which returns cached data.
* A `null` or `undefined` return value will trigger a fetch. * A `null` or `undefined` return value will trigger a fetch.
* Default is `key => nuxt.isHydrating ? nuxt.payload.data[key] : nuxt.static.data[key]` which only caches data when payloadExtraction is enabled. * Default is `key => nuxt.isHydrating ? nuxt.payload.data[key] : nuxt.static.data[key]` which only caches data when payloadExtraction is enabled.
* triggeredBy is a string that indicates in which case the cached data was requested.
*/ */
getCachedData?: (key: string) => DataT getCachedData?: (key: string, triggeredBy?: GetCachedDataTriggeredBy) => DataT
/** /**
* A function that can be used to alter handler function result after resolving * A function that can be used to alter handler function result after resolving
*/ */
@ -91,7 +93,7 @@ export interface AsyncDataOptions<
} }
export interface AsyncDataExecuteOptions { export interface AsyncDataExecuteOptions {
_initial?: boolean _triggeredBy?: GetCachedDataTriggeredBy
// TODO: remove boolean option in Nuxt 4 // TODO: remove boolean option in Nuxt 4
/** /**
* Force a refresh, even if there is already a pending request. Previous requests will * Force a refresh, even if there is already a pending request. Previous requests will
@ -101,7 +103,12 @@ export interface AsyncDataExecuteOptions {
* Instead of using `boolean` values, use `cancel` for `true` and `defer` for `false`. * Instead of using `boolean` values, use `cancel` for `true` and `defer` for `false`.
* Boolean values will be removed in a future release. * Boolean values will be removed in a future release.
*/ */
dedupe?: boolean | 'cancel' | 'defer' dedupe?: boolean | 'cancel' | 'defer',
/**
* Do not use potentially cached data from getCachedData and perform a new request
* @default false
*/
force?: boolean,
} }
export interface _AsyncData<DataT, ErrorT> { export interface _AsyncData<DataT, ErrorT> {
@ -239,7 +246,7 @@ export function useAsyncData<
console.warn('[nuxt] `boolean` values are deprecated for the `dedupe` option of `useAsyncData` and will be removed in the future. Use \'cancel\' or \'defer\' instead.') console.warn('[nuxt] `boolean` values are deprecated for the `dedupe` option of `useAsyncData` and will be removed in the future. Use \'cancel\' or \'defer\' instead.')
} }
const hasCachedData = () => ![null, undefined].includes(options.getCachedData!(key) as any) const hasCachedData = (triggeredBy?: GetCachedDataTriggeredBy) => ![null, undefined].includes(options.getCachedData!(key, triggeredBy) as any)
// Create or use a shared asyncData entity // Create or use a shared asyncData entity
if (!nuxtApp._asyncData[key] || !options.immediate) { if (!nuxtApp._asyncData[key] || !options.immediate) {
@ -248,8 +255,8 @@ export function useAsyncData<
const _ref = options.deep ? ref : shallowRef const _ref = options.deep ? ref : shallowRef
nuxtApp._asyncData[key] = { nuxtApp._asyncData[key] = {
data: _ref(options.getCachedData!(key) ?? options.default!()), data: _ref(options.getCachedData!(key, 'initial') ?? options.default!()),
pending: ref(!hasCachedData()), pending: ref(!hasCachedData('initial')),
error: toRef(nuxtApp.payload._errors, key), error: toRef(nuxtApp.payload._errors, key),
status: ref('idle') status: ref('idle')
} }
@ -258,7 +265,7 @@ export function useAsyncData<
// TODO: Else, somehow check for conflicting keys with different defaults or fetcher // TODO: Else, somehow check for conflicting keys with different defaults or fetcher
const asyncData = { ...nuxtApp._asyncData[key] } as AsyncData<DataT | DefaultT, (NuxtErrorDataT extends Error | NuxtError ? NuxtErrorDataT : NuxtError<NuxtErrorDataT>)> const asyncData = { ...nuxtApp._asyncData[key] } as AsyncData<DataT | DefaultT, (NuxtErrorDataT extends Error | NuxtError ? NuxtErrorDataT : NuxtError<NuxtErrorDataT>)>
asyncData.refresh = asyncData.execute = (opts = {}) => { asyncData.refresh = asyncData.execute = (opts = { _triggeredBy: 'refresh:manual' }) => {
if (nuxtApp._asyncDataPromises[key]) { if (nuxtApp._asyncDataPromises[key]) {
if (isDefer(opts.dedupe ?? options.dedupe)) { if (isDefer(opts.dedupe ?? options.dedupe)) {
// Avoid fetching same key more than once at a time // Avoid fetching same key more than once at a time
@ -267,8 +274,8 @@ export function useAsyncData<
(nuxtApp._asyncDataPromises[key] as any).cancelled = true (nuxtApp._asyncDataPromises[key] as any).cancelled = true
} }
// Avoid fetching same key that is already fetched // Avoid fetching same key that is already fetched
if ((opts._initial || (nuxtApp.isHydrating && opts._initial !== false)) && hasCachedData()) { if (!opts.force && hasCachedData(opts._triggeredBy)) {
return Promise.resolve(options.getCachedData!(key)) return Promise.resolve(options.getCachedData!(key, opts._triggeredBy))
} }
asyncData.pending.value = true asyncData.pending.value = true
asyncData.status.value = 'pending' asyncData.status.value = 'pending'
@ -318,7 +325,7 @@ export function useAsyncData<
return nuxtApp._asyncDataPromises[key]! return nuxtApp._asyncDataPromises[key]!
} }
const initialFetch = () => asyncData.refresh({ _initial: true }) const initialFetch = () => asyncData.refresh({ _triggeredBy: 'refresh:manual' })
const fetchOnServer = options.server !== false && nuxtApp.payload.serverRendered const fetchOnServer = options.server !== false && nuxtApp.payload.serverRendered
@ -352,7 +359,7 @@ export function useAsyncData<
} }
} }
if (fetchOnServer && nuxtApp.isHydrating && (asyncData.error.value || hasCachedData())) { if (fetchOnServer && nuxtApp.isHydrating && (asyncData.error.value || hasCachedData('initial'))) {
// 1. Hydration (server: true): no fetch // 1. Hydration (server: true): no fetch
asyncData.pending.value = false asyncData.pending.value = false
asyncData.status.value = asyncData.error.value ? 'error' : 'success' asyncData.status.value = asyncData.error.value ? 'error' : 'success'
@ -365,11 +372,11 @@ export function useAsyncData<
initialFetch() initialFetch()
} }
if (options.watch) { if (options.watch) {
watch(options.watch, () => asyncData.refresh()) watch(options.watch, () => asyncData.refresh({ _triggeredBy: 'watch'}))
} }
const off = nuxtApp.hook('app:data:refresh', async (keys) => { const off = nuxtApp.hook('app:data:refresh', async (keys, force) => {
if (!keys || keys.includes(key)) { if (!keys || keys.includes(key)) {
await asyncData.refresh() await asyncData.refresh({ force, _triggeredBy: 'refresh:hook' })
} }
}) })
if (instance) { if (instance) {
@ -462,7 +469,7 @@ export function useNuxtData<DataT = any> (key: string): { data: Ref<DataT | null
} }
/** @since 3.0.0 */ /** @since 3.0.0 */
export async function refreshNuxtData (keys?: string | string[]): Promise<void> { export async function refreshNuxtData (keys?: string | string[], force?: boolean): Promise<void> {
if (import.meta.server) { if (import.meta.server) {
return Promise.resolve() return Promise.resolve()
} }
@ -470,7 +477,7 @@ export async function refreshNuxtData (keys?: string | string[]): Promise<void>
await new Promise<void>(resolve => onNuxtReady(resolve)) await new Promise<void>(resolve => onNuxtReady(resolve))
const _keys = keys ? toArray(keys) : undefined const _keys = keys ? toArray(keys) : undefined
await useNuxtApp().hooks.callHookParallel('app:data:refresh', _keys) await useNuxtApp().hooks.callHookParallel('app:data:refresh', _keys, force)
} }
/** @since 3.0.0 */ /** @since 3.0.0 */

View File

@ -38,7 +38,7 @@ export interface RuntimeNuxtHooks {
'app:error': (err: any) => HookResult 'app:error': (err: any) => HookResult
'app:error:cleared': (options: { redirect?: string }) => HookResult 'app:error:cleared': (options: { redirect?: string }) => HookResult
'app:chunkError': (options: { error: any }) => HookResult 'app:chunkError': (options: { error: any }) => HookResult
'app:data:refresh': (keys?: string[]) => HookResult 'app:data:refresh': (keys?: string[], force?: boolean) => HookResult
'app:manifest:update': (meta?: NuxtAppManifestMeta) => HookResult 'app:manifest:update': (meta?: NuxtAppManifestMeta) => HookResult
'link:prefetch': (link: string) => HookResult 'link:prefetch': (link: string) => HookResult
'page:start': (Component?: VNode) => HookResult 'page:start': (Component?: VNode) => HookResult

View File

@ -1,13 +1,31 @@
<script setup lang="ts"> <script setup>
const page = ref(0)
const nuxt = useNuxtApp()
const { data, refresh } = await useFetch('https://icanhazdadjoke.com/', {
query: { page }, // "fed" into watch array for asyncData under the hood
getCachedData: (key) => {
return nuxt.payload.data[key] || nuxt.static.data[key]
},
headers: {
Accept: 'application/json',
},
})
</script> </script>
<template> <template>
<!-- Edit this file to play around with Nuxt but never commit changes! -->
<div> <div>
Nuxt 3 Playground <button @click="refresh()">
New Joke (refresh, default)
</button>
<button @click="refresh({ force: true })">
New Joke (refresh, force)
</button>
<button @click="page++">
New Joke (update query value + 1)
</button>
<button @click="page--">
New Joke (update query value - 1)
</button>
{{ data.joke }}
</div> </div>
</template> </template>
<style scoped>
</style>

View File

@ -1,3 +1,9 @@
export default defineNuxtConfig({ export default defineNuxtConfig({
routeRules: {
'/': {
headers: {
'My-Header': 'My-Value'
}
}
}
}) })

View File

@ -222,6 +222,13 @@ describe('useAsyncData', () => {
expect(data.data.value).toMatchInlineSnapshot('"test"') expect(data.data.value).toMatchInlineSnapshot('"test"')
}) })
it('should be refreshable with force and cache', async () => {
await useAsyncData('key', () => Promise.resolve('test'), { getCachedData: () => 'cached' })
await refreshNuxtData('key', { force: true })
const data = useNuxtData('key')
expect(data.data.value).toMatchInlineSnapshot('"test"')
})
it('allows custom access to a cache', async () => { it('allows custom access to a cache', async () => {
const { data } = await useAsyncData(() => ({ val: true }), { getCachedData: () => ({ val: false }) }) const { data } = await useAsyncData(() => ({ val: true }), { getCachedData: () => ({ val: false }) })
expect(data.value).toMatchInlineSnapshot(` expect(data.value).toMatchInlineSnapshot(`
@ -231,6 +238,43 @@ describe('useAsyncData', () => {
`) `)
}) })
it('will use cache on refresh by default', async () => {
let called = 0
const fn = () => called++
const { data, refresh } = await useAsyncData(() => 'other value', { getCachedData: () => fn() })
expect(data.value).toBe(0)
await refresh()
expect(data.value).toBe(0)
})
it('will not use cache with force option', async () => {
let called = 0
const fn = () => called++
const { data, refresh } = await useAsyncData(() => 'other value', { getCachedData: () => fn() })
expect(data.value).toBe(0)
await refresh({ force: true })
expect(data.value).toBe('other value')
})
it('getCachedData should receive triggeredBy on initial fetch', async () => {
const { data } = await useAsyncData(() => '', { getCachedData: (_, triggeredBy) => triggeredBy })
expect(data.value).toBe('initial')
})
it('getCachedData should receive triggeredBy on manual refresh', async () => {
const { data, refresh } = await useAsyncData(() => '', { getCachedData: (_, triggeredBy) => triggeredBy })
await refresh()
expect(data.value).toBe('refresh:manual')
})
it('getCachedData should receive triggeredBy on watch', async () => {
const number = ref(0)
const { data } = await useAsyncData(() => '', { getCachedData: (_, triggeredBy) => triggeredBy })
number.value = 1
// TODO: Maybe setTimeout or similar
expect(data.value).toBe('watch')
})
it('should use default while pending', async () => { it('should use default while pending', async () => {
const promise = useAsyncData(() => Promise.resolve('test'), { default: () => 'default' }) const promise = useAsyncData(() => Promise.resolve('test'), { default: () => 'default' })
const { data, pending } = promise const { data, pending } = promise
@ -369,7 +413,7 @@ describe('useHydration', () => {
it('should hydrate value from payload', async () => { it('should hydrate value from payload', async () => {
let val: any let val: any
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
useHydration('key', () => {}, (fromPayload) => { val = fromPayload }) useHydration('key', () => { }, (fromPayload) => { val = fromPayload })
await nuxtApp.hooks.callHook('app:created', nuxtApp.vueApp) await nuxtApp.hooks.callHook('app:created', nuxtApp.vueApp)
expect(val).toMatchInlineSnapshot('undefined') expect(val).toMatchInlineSnapshot('undefined')
@ -440,7 +484,7 @@ describe('useId', () => {
const vals = new Set<string>() const vals = new Set<string>()
for (let index = 0; index < 100; index++) { for (let index = 0; index < 100; index++) {
mount(defineComponent({ mount(defineComponent({
setup () { setup() {
const id = useId() const id = useId()
vals.add(id) vals.add(id)
return () => h('div', id) return () => h('div', id)
@ -452,7 +496,7 @@ describe('useId', () => {
it('generates unique ids per-component', () => { it('generates unique ids per-component', () => {
const component = defineComponent({ const component = defineComponent({
setup () { setup() {
const id = useId() const id = useId()
return () => h('div', id) return () => h('div', id)
} }