From be97edc362b242eee31cdf3831ca562ff8c8fa73 Mon Sep 17 00:00:00 2001 From: Toru Kobayashi Date: Thu, 16 Jan 2025 22:15:19 +0900 Subject: [PATCH] feat: mutateTag --- src/_internal/index.ts | 8 +- src/_internal/types.ts | 13 ++ src/_internal/utils/cache.ts | 19 +- src/_internal/utils/config-context.ts | 5 +- src/_internal/utils/config.ts | 12 +- src/_internal/utils/helper.ts | 4 +- src/_internal/utils/mutate.ts | 323 +++++++++++++++----------- src/index/index.ts | 2 +- src/index/use-swr.ts | 2 +- src/infinite/index.ts | 5 +- test/use-swr-mutate-tag.test.tsx | 136 +++++++++++ 11 files changed, 371 insertions(+), 158 deletions(-) create mode 100644 test/use-swr-mutate-tag.test.tsx diff --git a/src/_internal/index.ts b/src/_internal/index.ts index dfe9a81485..e5da8468c6 100644 --- a/src/_internal/index.ts +++ b/src/_internal/index.ts @@ -5,7 +5,13 @@ import { INFINITE_PREFIX } from './constants' export { SWRConfig, revalidateEvents, INFINITE_PREFIX } export { initCache } from './utils/cache' -export { defaultConfig, cache, mutate, compare } from './utils/config' +export { + defaultConfig, + cache, + mutate, + mutateTag, + compare +} from './utils/config' import { setupDevTools } from './utils/devtools' export * from './utils/env' export { SWRGlobalState } from './utils/global-state' diff --git a/src/_internal/types.ts b/src/_internal/types.ts index 528dae9327..09a7fd2e59 100644 --- a/src/_internal/types.ts +++ b/src/_internal/types.ts @@ -6,6 +6,7 @@ export type GlobalState = [ Record, // FETCH: [data, ts] Record>, // PRELOAD ScopedMutator, // Mutator + TagMutator, // TagMutator (key: string, value: any, prev: any) => void, // Setter (key: string, callback: (current: any, prev: any) => void) => () => void // Subscriber ] @@ -45,6 +46,7 @@ export type BlockingData< export interface InternalConfiguration { cache: Cache mutate: ScopedMutator + mutateTag: TagMutator } /** @@ -206,6 +208,10 @@ export interface PublicConfiguration< * @link https://swr.vercel.app/docs/advanced/react-native#customize-focus-and-reconnect-events */ isVisible: () => boolean + /** + * tags to associate with the data, you can mutate data based on these tags + */ + tag: string[] } export type FullConfiguration< @@ -391,6 +397,7 @@ export type State = { error?: Error isValidating?: boolean isLoading?: boolean + _tag?: string[] } export type MutatorFn = ( @@ -435,6 +442,12 @@ export interface ScopedMutator { ): Promise } +export type TagMutator = ( + tag: string, + data?: MutationData | Promise | MutatorCallback, + opts?: boolean | MutatorOptions +) => Promise> + /** * @typeParam Data - The type of the data related to the key * @typeParam MutationData - The type of the data returned by the mutator diff --git a/src/_internal/utils/cache.ts b/src/_internal/utils/cache.ts index 9e3112ff34..0412cf111d 100644 --- a/src/_internal/utils/cache.ts +++ b/src/_internal/utils/cache.ts @@ -1,7 +1,7 @@ import { defaultConfigOptions } from './web-preset' import { IS_SERVER } from './env' import { UNDEFINED, mergeObjects, noop } from './shared' -import { internalMutate } from './mutate' +import { internalMutate, internalMutateTag } from './mutate' import { SWRGlobalState } from './global-state' import * as revalidateEvents from '../events' @@ -11,7 +11,8 @@ import type { RevalidateEvent, RevalidateCallback, ProviderConfiguration, - GlobalState + GlobalState, + TagMutator } from '../types' const revalidateAllKeys = ( @@ -27,8 +28,8 @@ export const initCache = ( provider: Cache, options?: Partial ): - | [Cache, ScopedMutator, () => void, () => void] - | [Cache, ScopedMutator] + | [Cache, ScopedMutator, TagMutator, () => void, () => void] + | [Cache, ScopedMutator, TagMutator] | undefined => { // The global state for a specific provider will be used to deduplicate // requests and store listeners. As well as a mutate function that is bound to @@ -44,6 +45,7 @@ export const initCache = ( const EVENT_REVALIDATORS = {} const mutate = internalMutate.bind(UNDEFINED, provider) as ScopedMutator + const mutateTag = internalMutateTag.bind(UNDEFINED, provider) as TagMutator let unmount = noop const subscriptions: Record void)[]> = @@ -77,6 +79,7 @@ export const initCache = ( {}, {}, mutate, + mutateTag, setter, subscribe ]) @@ -126,8 +129,12 @@ export const initCache = ( // We might want to inject an extra layer on top of `provider` in the future, // such as key serialization, auto GC, etc. // For now, it's just a `Map` interface without any modifications. - return [provider, mutate, initProvider, unmount] + return [provider, mutate, mutateTag, initProvider, unmount] } - return [provider, (SWRGlobalState.get(provider) as GlobalState)[4]] + return [ + provider, + (SWRGlobalState.get(provider) as GlobalState)[4], + (SWRGlobalState.get(provider) as GlobalState)[5] + ] } diff --git a/src/_internal/utils/config-context.ts b/src/_internal/utils/config-context.ts index 55eb6fb16c..9763fa9d61 100644 --- a/src/_internal/utils/config-context.ts +++ b/src/_internal/utils/config-context.ts @@ -54,13 +54,14 @@ const SWRConfig: FC< if (cacheContext) { ;(extendedConfig as any).cache = cacheContext[0] ;(extendedConfig as any).mutate = cacheContext[1] + ;(extendedConfig as any).mutateTag = cacheContext[2] } // Unsubscribe events. useIsomorphicLayoutEffect(() => { if (cacheContext) { - cacheContext[2] && cacheContext[2]() - return cacheContext[3] + cacheContext[3] && cacheContext[3]() + return cacheContext[4] } }, []) diff --git a/src/_internal/utils/config.ts b/src/_internal/utils/config.ts index ce05caea25..fb9f9e6911 100644 --- a/src/_internal/utils/config.ts +++ b/src/_internal/utils/config.ts @@ -4,7 +4,8 @@ import type { RevalidatorOptions, Revalidator, ScopedMutator, - Cache + Cache, + TagMutator } from '../types' import { initCache } from './cache' @@ -42,8 +43,12 @@ const onErrorRetry = ( const compare = dequal // Default cache provider -const [cache, mutate] = initCache(new Map()) as [Cache, ScopedMutator] -export { cache, mutate, compare } +const [cache, mutate, mutateTag] = initCache(new Map()) as [ + Cache, + ScopedMutator, + TagMutator +] +export { cache, mutate, mutateTag, compare } // Default config export const defaultConfig: FullConfiguration = mergeObjects( @@ -72,6 +77,7 @@ export const defaultConfig: FullConfiguration = mergeObjects( isPaused: () => false, cache, mutate, + mutateTag, fallback: {} }, // use web preset by default diff --git a/src/_internal/utils/helper.ts b/src/_internal/utils/helper.ts index da3092f289..f46d57e872 100644 --- a/src/_internal/utils/helper.ts +++ b/src/_internal/utils/helper.ts @@ -34,11 +34,11 @@ export const createCacheHelper = >( INITIAL_CACHE[key] = prev } - state[5](key, mergeObjects(prev, info), prev || EMPTY_CACHE) + state[6](key, mergeObjects(prev, info), prev || EMPTY_CACHE) } }, // Subscriber - state[6], + state[7], // Get server cache snapshot () => { if (!isUndefined(key)) { diff --git a/src/_internal/utils/mutate.ts b/src/_internal/utils/mutate.ts index 6ea91bec08..0aa6a13453 100644 --- a/src/_internal/utils/mutate.ts +++ b/src/_internal/utils/mutate.ts @@ -19,33 +19,23 @@ import type { Arguments, Key } from '../types' +import type { SWRInfiniteCacheValue } from '../../infinite/types' type KeyFilter = (key?: Arguments) => boolean type MutateState = State & { // The previously committed data. _c?: Data } - -export async function internalMutate( +type InternalMutateArgs = [ cache: Cache, - _key: KeyFilter, + _key: MutateKey, _data?: Data | Promise | MutatorCallback, _opts?: boolean | MutatorOptions -): Promise> -export async function internalMutate( - cache: Cache, - _key: Arguments, - _data?: Data | Promise | MutatorCallback, - _opts?: boolean | MutatorOptions -): Promise -export async function internalMutate( - ...args: [ - cache: Cache, - _key: KeyFilter | Arguments, - _data?: Data | Promise | MutatorCallback, - _opts?: boolean | MutatorOptions - ] -): Promise { +] + +async function mutateByKey( + ...args: InternalMutateArgs +): Promise { const [cache, _key, _data, _opts] = args // When passing as a boolean, it's explicitly used to disable/enable @@ -67,150 +57,203 @@ export async function internalMutate( } const throwOnError = options.throwOnError - // If the second argument is a key filter, return the mutation results for all - // filtered keys. - if (isFunction(_key)) { - const keyFilter = _key - const matchedKeys: Key[] = [] - const it = cache.keys() - for (const key of it) { - if ( - // Skip the special useSWRInfinite and useSWRSubscription keys. - !/^\$(inf|sub)\$/.test(key) && - keyFilter((cache.get(key) as { _k: Arguments })._k) - ) { - matchedKeys.push(key) + // Serialize key + const [key] = serialize(_key) + if (!key) return + const [get, set] = createCacheHelper>(cache, key) + const [EVENT_REVALIDATORS, MUTATION, FETCH, PRELOAD] = SWRGlobalState.get( + cache + ) as GlobalState + + const startRevalidate = () => { + const revalidators = EVENT_REVALIDATORS[key] + const revalidate = isFunction(options.revalidate) + ? options.revalidate(get().data, _key) + : options.revalidate !== false + if (revalidate) { + // Invalidate the key by deleting the concurrent request markers so new + // requests will not be deduped. + delete FETCH[key] + delete PRELOAD[key] + if (revalidators && revalidators[0]) { + return revalidators[0](revalidateEvents.MUTATE_EVENT).then( + () => get().data + ) } } - return Promise.all(matchedKeys.map(mutateByKey)) + return get().data } - return mutateByKey(_key) - - async function mutateByKey(_k: Key): Promise { - // Serialize key - const [key] = serialize(_k) - if (!key) return - const [get, set] = createCacheHelper>(cache, key) - const [EVENT_REVALIDATORS, MUTATION, FETCH, PRELOAD] = SWRGlobalState.get( - cache - ) as GlobalState - - const startRevalidate = () => { - const revalidators = EVENT_REVALIDATORS[key] - const revalidate = isFunction(options.revalidate) - ? options.revalidate(get().data, _k) - : options.revalidate !== false - if (revalidate) { - // Invalidate the key by deleting the concurrent request markers so new - // requests will not be deduped. - delete FETCH[key] - delete PRELOAD[key] - if (revalidators && revalidators[0]) { - return revalidators[0](revalidateEvents.MUTATE_EVENT).then( - () => get().data - ) - } - } - return get().data - } + // If there is no new data provided, revalidate the key with current state. + if (args.length < 3) { + // Revalidate and broadcast state. + return startRevalidate() + } - // If there is no new data provided, revalidate the key with current state. - if (args.length < 3) { - // Revalidate and broadcast state. - return startRevalidate() - } + let data: any = _data + let error: unknown - let data: any = _data - let error: unknown + // Update global timestamps. + const beforeMutationTs = getTimestamp() + MUTATION[key] = [beforeMutationTs, 0] - // Update global timestamps. - const beforeMutationTs = getTimestamp() - MUTATION[key] = [beforeMutationTs, 0] + const hasOptimisticData = !isUndefined(optimisticData) + const state = get() - const hasOptimisticData = !isUndefined(optimisticData) - const state = get() + // `displayedData` is the current value on screen. It could be the optimistic value + // that is going to be overridden by a `committedData`, or get reverted back. + // `committedData` is the validated value that comes from a fetch or mutation. + const displayedData = state.data + const currentData = state._c + const committedData = isUndefined(currentData) ? displayedData : currentData - // `displayedData` is the current value on screen. It could be the optimistic value - // that is going to be overridden by a `committedData`, or get reverted back. - // `committedData` is the validated value that comes from a fetch or mutation. - const displayedData = state.data - const currentData = state._c - const committedData = isUndefined(currentData) ? displayedData : currentData + // Do optimistic data update. + if (hasOptimisticData) { + optimisticData = isFunction(optimisticData) + ? optimisticData(committedData, displayedData) + : optimisticData - // Do optimistic data update. - if (hasOptimisticData) { - optimisticData = isFunction(optimisticData) - ? optimisticData(committedData, displayedData) - : optimisticData + // When we set optimistic data, backup the current committedData data in `_c`. + set({ data: optimisticData, _c: committedData }) + } - // When we set optimistic data, backup the current committedData data in `_c`. - set({ data: optimisticData, _c: committedData }) + if (isFunction(data)) { + // `data` is a function, call it passing current cache value. + try { + data = (data as MutatorCallback)(committedData) + } catch (err) { + // If it throws an error synchronously, we shouldn't update the cache. + error = err } + } - if (isFunction(data)) { - // `data` is a function, call it passing current cache value. - try { - data = (data as MutatorCallback)(committedData) - } catch (err) { - // If it throws an error synchronously, we shouldn't update the cache. - error = err - } - } + // `data` is a promise/thenable, resolve the final data first. + if (data && isPromiseLike(data)) { + // This means that the mutation is async, we need to check timestamps to + // avoid race conditions. + data = await (data as Promise).catch(err => { + error = err + }) - // `data` is a promise/thenable, resolve the final data first. - if (data && isPromiseLike(data)) { - // This means that the mutation is async, we need to check timestamps to - // avoid race conditions. - data = await (data as Promise).catch(err => { - error = err - }) + // Check if other mutations have occurred since we've started this mutation. + // If there's a race we don't update cache or broadcast the change, + // just return the data. + if (beforeMutationTs !== MUTATION[key][0]) { + if (error) throw error + return data + } else if (error && hasOptimisticData && rollbackOnError(error)) { + // Rollback. Always populate the cache in this case but without + // transforming the data. + populateCache = true - // Check if other mutations have occurred since we've started this mutation. - // If there's a race we don't update cache or broadcast the change, - // just return the data. - if (beforeMutationTs !== MUTATION[key][0]) { - if (error) throw error - return data - } else if (error && hasOptimisticData && rollbackOnError(error)) { - // Rollback. Always populate the cache in this case but without - // transforming the data. - populateCache = true - - // Reset data to be the latest committed data, and clear the `_c` value. - set({ data: committedData, _c: UNDEFINED }) - } + // Reset data to be the latest committed data, and clear the `_c` value. + set({ data: committedData, _c: UNDEFINED }) } + } - // If we should write back the cache after request. - if (populateCache) { - if (!error) { - // Transform the result into data. - if (isFunction(populateCache)) { - const populateCachedData = populateCache(data, committedData) - set({ data: populateCachedData, error: UNDEFINED, _c: UNDEFINED }) - } else { - // Only update cached data and reset the error if there's no error. Data can be `undefined` here. - set({ data, error: UNDEFINED, _c: UNDEFINED }) - } + // If we should write back the cache after request. + if (populateCache) { + if (!error) { + // Transform the result into data. + if (isFunction(populateCache)) { + const populateCachedData = populateCache(data, committedData) + set({ data: populateCachedData, error: UNDEFINED, _c: UNDEFINED }) + } else { + // Only update cached data and reset the error if there's no error. Data can be `undefined` here. + set({ data, error: UNDEFINED, _c: UNDEFINED }) } } + } - // Reset the timestamp to mark the mutation has ended. - MUTATION[key][1] = getTimestamp() + // Reset the timestamp to mark the mutation has ended. + MUTATION[key][1] = getTimestamp() - // Update existing SWR Hooks' internal states: - Promise.resolve(startRevalidate()).then(() => { - // The mutation and revalidation are ended, we can clear it since the data is - // not an optimistic value anymore. - set({ _c: UNDEFINED }) - }) + // Update existing SWR Hooks' internal states: + Promise.resolve(startRevalidate()).then(() => { + // The mutation and revalidation are ended, we can clear it since the data is + // not an optimistic value anymore. + set({ _c: UNDEFINED }) + }) - // Throw error or return data - if (error) { - if (throwOnError) throw error - return + // Throw error or return data + if (error) { + if (throwOnError) throw error + return + } + return data +} + +export async function internalMutate( + cache: Cache, + _key: KeyFilter, + _data?: Data | Promise | MutatorCallback, + _opts?: boolean | MutatorOptions +): Promise> +export async function internalMutate( + cache: Cache, + _key: Arguments, + _data?: Data | Promise | MutatorCallback, + _opts?: boolean | MutatorOptions +): Promise +export async function internalMutate( + ...args: InternalMutateArgs +): Promise { + const [cache, _key, _data, _opts] = args + + // If the second argument is a key filter, return the mutation results for all + // filtered keys. + if (isFunction(_key)) { + const keyFilter = _key + const matchedKeys: Key[] = [] + const it = cache.keys() + for (const key of it) { + if ( + // Skip the special useSWRInfinite and useSWRSubscription keys. + !/^\$(inf|sub)\$/.test(key) && + keyFilter((cache.get(key) as { _k: Arguments })._k) + ) { + matchedKeys.push(key) + } } - return data + return Promise.all( + matchedKeys.map(key => { + const newArgs: InternalMutateArgs = [ + ...args + ] + newArgs[1] = key + return mutateByKey(...newArgs) + }) + ) } + return mutateByKey(...args) +} + +export async function internalMutateTag( + ...args: InternalMutateArgs +): Promise { + const [cache, _tag, _data, _opts] = args + + const matchedKeys: Key[] = [] + const it = cache.keys() + for (const key of it) { + if (_tag && cache.get(key)?._tag?.includes(_tag)) { + matchedKeys.push(key) + if (/^\$inf\$/.test(key)) { + const [_, set] = createCacheHelper< + Data, + SWRInfiniteCacheValue + >(cache, key) + // mutate all pages + set({ _i: true }) + } + } + } + + return Promise.all( + matchedKeys.map(key => { + const newArgs: InternalMutateArgs = [...args] + newArgs[1] = key + return mutateByKey(...newArgs) + }) + ) } diff --git a/src/index/index.ts b/src/index/index.ts index b5419ddb53..be2a30843a 100644 --- a/src/index/index.ts +++ b/src/index/index.ts @@ -5,7 +5,7 @@ export default useSWR export { SWRConfig } from './use-swr' export { unstable_serialize } from './serialize' export { useSWRConfig } from '../_internal' -export { mutate } from '../_internal' +export { mutate, mutateTag } from '../_internal' export { preload } from '../_internal' // Types diff --git a/src/index/use-swr.ts b/src/index/use-swr.ts index 98ef34f1f2..4db58777ff 100644 --- a/src/index/use-swr.ts +++ b/src/index/use-swr.ts @@ -626,7 +626,7 @@ export const useSWRHandler = ( initialMountedRef.current = true // Keep the original key in the cache. - setCache({ _k: fnArg }) + setCache({ _k: fnArg, _tag: getConfig().tag }) // Trigger a revalidation if (shouldDoInitialRevalidation) { diff --git a/src/infinite/index.ts b/src/infinite/index.ts index 4292ea4486..573150802d 100644 --- a/src/infinite/index.ts +++ b/src/infinite/index.ts @@ -56,7 +56,8 @@ export const infinite = ((useSWRNext: SWRHook) => persistSize = false, revalidateFirstPage = true, revalidateOnMount = false, - parallel = false + parallel = false, + tag } = config const [, , , PRELOAD] = SWRGlobalState.get(defaultCache) as GlobalState @@ -133,7 +134,7 @@ export const infinite = ((useSWRNext: SWRHook) => // get the revalidate context const forceRevalidateAll = get()._i const shouldRevalidatePage = get()._r - set({ _r: UNDEFINED }) + set({ _r: UNDEFINED, _tag: tag }) // return an array of page data const data: Data[] = [] diff --git a/test/use-swr-mutate-tag.test.tsx b/test/use-swr-mutate-tag.test.tsx new file mode 100644 index 0000000000..2591c39bcf --- /dev/null +++ b/test/use-swr-mutate-tag.test.tsx @@ -0,0 +1,136 @@ +import { screen, fireEvent } from '@testing-library/react' +import useSWR, { useSWRConfig, mutateTag as globalMutateTag } from 'swr' +import { + createKey, + createResponse, + renderWithConfig, + renderWithGlobalCache +} from './utils' +import useSWRInfinite from 'swr/infinite' + +describe('mutateTag', () => { + it('should return the global mutateTag by default', async () => { + let localMutateTag + function Page() { + const { mutateTag } = useSWRConfig() + localMutateTag = mutateTag + return null + } + + renderWithGlobalCache() + expect(localMutateTag).toBe(globalMutateTag) + }) + + it('should support mutate tag', async () => { + const key1 = createKey() + const key2 = createKey() + const key3 = createKey() + const key4 = createKey() + + let count1 = 0 + let count2 = 0 + let count3 = 0 + let count4 = 0 + + function Page() { + const { data: tag1Data } = useSWR(key1, () => ++count1, { tag: ['tag1'] }) + const { data: tag12Data } = useSWR(key2, () => ++count2, { + tag: ['tag1', 'tag2'] + }) + const { data: tag2Data } = useSWR(key3, () => ++count3, { tag: ['tag2'] }) + const { data: nonTagData } = useSWR(key4, () => ++count4) + const { mutateTag } = useSWRConfig() + + return ( +
+ +

tag1Data:{tag1Data}

+

tag12Data:{tag12Data}

+

tag2Data:{tag2Data}

+

nonTagData:{nonTagData}

+
+ ) + } + + renderWithConfig() + await screen.findByText('tag1Data:1') + await screen.findByText('tag12Data:1') + await screen.findByText('tag2Data:1') + await screen.findByText('nonTagData:1') + + // mutate tag1 + fireEvent.click(screen.getByText('click')) + await screen.findByText('tag1Data:2') + await screen.findByText('tag12Data:2') + await screen.findByText('tag2Data:1') + await screen.findByText('nonTagData:1') + }) + + it("should support useSWRInfinite's mutate tag", async () => { + const key1 = createKey() + const key2 = createKey() + let count1 = 0 + let count2 = 0 + + const infiniteKey1 = 'infinite-' + createKey() + const infiniteKey2 = 'infinite-' + createKey() + let infiniteCount1 = 0 + let infiniteCount2 = 0 + + function Page() { + const { data: tag1Data } = useSWR(key1, () => createResponse(++count1), { + tag: ['tag1'] + }) + const { data: tag2Data } = useSWR(key2, () => createResponse(++count2), { + tag: ['tag2'] + }) + const { data: tag1InfiniteData } = useSWRInfinite( + index => `page-${index}-${infiniteKey1}`, + () => createResponse(++infiniteCount1), + { tag: ['tag1'], revalidateFirstPage: false } + ) + const { data: tag2InfiniteData } = useSWRInfinite( + index => `page-${index}-${infiniteKey2}`, + () => createResponse(++infiniteCount2), + { tag: ['tag2'], revalidateFirstPage: false } + ) + + const { mutateTag } = useSWRConfig() + + return ( +
+ +

tag1Data:{tag1Data}

+

tag2Data:{tag2Data}

+

tag1InfiniteData:{tag1InfiniteData}

+

tag2InfiniteData:{tag2InfiniteData}

+
+ ) + } + + renderWithConfig() + await screen.findByText('tag1Data:1') + await screen.findByText('tag2Data:1') + await screen.findByText('tag1InfiniteData:1') + await screen.findByText('tag2InfiniteData:1') + + // mutate tag1 + fireEvent.click(screen.getByText('click')) + await screen.findByText('tag1Data:2') + await screen.findByText('tag2Data:1') + await screen.findByText('tag1InfiniteData:2') + await screen.findByText('tag2InfiniteData:1') + }) +})