diff --git a/.eslintrc b/.eslintrc index bf14365f9..70c9968ba 100644 --- a/.eslintrc +++ b/.eslintrc @@ -43,6 +43,7 @@ "@typescript-eslint/no-explicit-any": 0, "@typescript-eslint/explicit-module-boundary-types": 0, "react-hooks/rules-of-hooks": 2, - "react-hooks/exhaustive-deps": 1 + "react-hooks/exhaustive-deps": 1, + "react/prop-types": 0 } } diff --git a/src/config-context.ts b/src/config-context.ts index 4a031b756..1231af49a 100644 --- a/src/config-context.ts +++ b/src/config-context.ts @@ -1,8 +1,16 @@ -import { createContext } from 'react' +import { createContext, createElement, useContext, FC } from 'react' +import mergeConfig from './libs/merge-config' import { SWRConfiguration } from './types' -const SWRConfigContext = createContext({}) -SWRConfigContext.displayName = 'SWRConfig' +export const SWRConfigContext = createContext({}) -export default SWRConfigContext +const SWRConfig: FC<{ + value: SWRConfiguration +}> = ({ children, value }) => { + // Extend parent context values and middlewares. + value = mergeConfig(useContext(SWRConfigContext) || {}, value) + return createElement(SWRConfigContext.Provider, { value }, children) +} + +export default SWRConfig diff --git a/src/config.ts b/src/config.ts index 4a94f501a..25fc6bb28 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,7 +12,7 @@ const noop = () => {} function onErrorRetry( _: unknown, __: string, - config: Readonly>, + config: Readonly, revalidate: Revalidator, opts: Required ): void { diff --git a/src/index.ts b/src/index.ts index c110e2ace..5e665fb2d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,10 @@ // `useSWR` and related APIs import { default as useSWR } from './use-swr' +export { SWRConfig, mutate, createCache } from './use-swr' export default useSWR -export * from './use-swr' // `useSWRInfinite` -export { useSWRInfinite } from './use-swr-infinite' +export { default as useSWRInfinite } from './use-swr-infinite' // Types export { @@ -17,6 +17,7 @@ export { KeyLoader, SWRResponse, Cache, + Middleware, // Legacy, for backwards compatibility ConfigInterface, SWRInfiniteConfigInterface, diff --git a/src/libs/merge-config.ts b/src/libs/merge-config.ts new file mode 100644 index 000000000..197933d47 --- /dev/null +++ b/src/libs/merge-config.ts @@ -0,0 +1,16 @@ +import { Configuration } from '../types' + +export default function mergeConfig( + a: Partial | null, + b: Partial | null +) { + const v: Partial = { ...a, ...b } + + const m1 = a && a.middlewares + const m2 = b && b.middlewares + if (m1 && m2) { + v.middlewares = m1.concat(m2) + } + + return v +} diff --git a/src/resolve-args.ts b/src/resolve-args.ts index 0775552d4..fc997057e 100644 --- a/src/resolve-args.ts +++ b/src/resolve-args.ts @@ -1,28 +1,35 @@ import { useContext } from 'react' import defaultConfig from './config' -import SWRConfigContext from './config-context' +import { SWRConfigContext } from './config-context' +import mergeConfig from './libs/merge-config' -import { Fetcher } from './types' +import { Fetcher, SWRConfiguration } from './types' // Resolve arguments for SWR hooks. // This function itself is a hook because it uses `useContext` inside. -export default function useArgs( +function useArgs( args: | readonly [KeyType] | readonly [KeyType, Fetcher | null] - | readonly [KeyType, ConfigType | undefined] - | readonly [KeyType, Fetcher | null, ConfigType | undefined] -): [KeyType, Fetcher | null, (typeof defaultConfig) & ConfigType] { - const config = { + | readonly [KeyType, SWRConfiguration | undefined] + | readonly [KeyType, Fetcher | null, SWRConfiguration | undefined] +): [KeyType, Fetcher | null, (typeof defaultConfig) & SWRConfiguration] { + const fallbackConfig = { ...defaultConfig, - ...useContext(SWRConfigContext), - ...(args.length > 2 - ? args[2] - : args.length === 2 && typeof args[1] === 'object' - ? args[1] - : {}) - } as (typeof defaultConfig) & ConfigType + ...useContext(SWRConfigContext) + } + const currentConfig = (args.length > 2 + ? args[2] + : args.length === 2 && typeof args[1] === 'object' + ? args[1] + : {}) as (typeof defaultConfig) & SWRConfiguration + + // Merge configs. + const config = mergeConfig( + fallbackConfig, + currentConfig + ) as (typeof defaultConfig) & SWRConfiguration // In TypeScript `args.length > 2` is not same as `args.lenth === 3`. // We do a safe type assertion here. @@ -38,3 +45,22 @@ export default function useArgs( return [args[0], fn, config] } + +// It's tricky to pass generic types as parameters, so we just directly override +// the types here. +export default function withArgs(hook: any) { + return (((...args: any) => { + const [key, fn, config] = useArgs(args) + + // Apply middlewares to the hook. + let next = hook + const { middlewares } = config + if (middlewares) { + for (let i = 0; i < middlewares.length; i++) { + next = middlewares[i](next) + } + } + + return next(key, fn, config) + }) as unknown) as SWRType +} diff --git a/src/types.ts b/src/types.ts index d768cf50d..a477f2824 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,6 +20,7 @@ export interface Configuration< fetcher: Fn initialData?: Data cache: Cache + middlewares?: Middleware[] isPaused: () => boolean onLoadingSlow: ( @@ -47,6 +48,18 @@ export interface Configuration< compare: (a: Data | undefined, b: Data | undefined) => boolean } +export type SWRHook = ( + ...args: + | readonly [Key] + | readonly [Key, Fetcher | null] + | readonly [Key, SWRConfiguration | undefined] + | readonly [ + Key, + Fetcher | null, + SWRConfiguration | undefined + ] +) => SWRResponse + export interface Preset { isOnline: () => boolean isDocumentVisible: () => boolean @@ -54,6 +67,15 @@ export interface Preset { registerOnReconnect?: (cb: () => void) => void } +// Middlewares guarantee that a SWRHook receives a key, fetcher, and config as the argument +type SWRHookWithdMiddleware = ( + key: Key, + fetcher: Fetcher | null, + config: SWRConfiguration | undefined +) => SWRResponse + +export type Middleware = (useSWRNext: SWRHook) => SWRHookWithdMiddleware + export type ValueKey = string | any[] | null export type Updater = ( diff --git a/src/use-swr-infinite.ts b/src/use-swr-infinite.ts index d5d8be9f0..430ead155 100644 --- a/src/use-swr-infinite.ts +++ b/src/use-swr-infinite.ts @@ -1,11 +1,12 @@ // TODO: use @ts-expect-error import { useRef, useState, useCallback } from 'react' +import defaultConfig from './config' import { useIsomorphicLayoutEffect } from './env' import { serialize } from './libs/serialize' import { isUndefined, UNDEFINED } from './libs/helper' -import useArgs from './resolve-args' -import useSWR from './use-swr' +import withArgs from './resolve-args' +import { useSWRHandler } from './use-swr' import { KeyLoader, @@ -15,26 +16,11 @@ import { MutatorCallback } from './types' -function useSWRInfinite( - ...args: - | readonly [KeyLoader] - | readonly [KeyLoader, Fetcher | null] - | readonly [ - KeyLoader, - SWRInfiniteConfiguration | undefined - ] - | readonly [ - KeyLoader, - Fetcher | null, - SWRInfiniteConfiguration | undefined - ] +function useSWRInfiniteHandler( + getKey: KeyLoader, + fn: Fetcher | null, + config: typeof defaultConfig & SWRInfiniteConfiguration ): SWRInfiniteResponse { - const [getKey, fn, config] = useArgs< - KeyLoader, - SWRInfiniteConfiguration, - Data - >(args) - const { cache, initialSize = 1, @@ -99,7 +85,7 @@ function useSWRInfinite( const dataRef = useRef() // actual swr of all pages - const swr = useSWR( + const swr = useSWRHandler( firstPageKey ? ['inf', firstPageKey] : null, async () => { // get the revalidate context @@ -234,4 +220,19 @@ function useSWRInfinite( return (swrInfinite as unknown) as SWRInfiniteResponse } -export { useSWRInfinite } +type SWRInfiniteHook = ( + ...args: + | readonly [KeyLoader] + | readonly [KeyLoader, Fetcher | null] + | readonly [ + KeyLoader, + SWRInfiniteConfiguration | undefined + ] + | readonly [ + KeyLoader, + Fetcher | null, + SWRInfiniteConfiguration | undefined + ] +) => SWRInfiniteResponse + +export default withArgs(useSWRInfiniteHandler) diff --git a/src/use-swr.ts b/src/use-swr.ts index 27bf9452d..f00326a42 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -6,9 +6,9 @@ import { wrapCache } from './cache' import { IS_SERVER, rAF, useIsomorphicLayoutEffect } from './env' import { serialize } from './libs/serialize' import { isUndefined, UNDEFINED } from './libs/helper' -import SWRConfigContext from './config-context' +import ConfigProvider from './config-context' import useStateWithDeps from './state' -import useArgs from './resolve-args' +import withArgs from './resolve-args' import { State, Broadcaster, @@ -20,7 +20,8 @@ import { Updater, SWRConfiguration, Cache, - ScopedMutator + ScopedMutator, + SWRHook } from './types' type Revalidator = (...args: any[]) => void @@ -203,21 +204,21 @@ const addRevalidator = ( } } -function useSWR( - ...args: - | readonly [Key] - | readonly [Key, Fetcher | null] - | readonly [Key, SWRConfiguration | undefined] - | readonly [ - Key, - Fetcher | null, - SWRConfiguration | undefined - ] +export function useSWRHandler( + _key: Key, + fn: Fetcher | null, + config: typeof defaultConfig & SWRConfiguration ): SWRResponse { - const [_key, fn, config] = useArgs, Data>( - args - ) - const cache = config.cache + const { + cache, + compare, + initialData, + suspense, + revalidateOnMount, + refreshInterval, + refreshWhenHidden, + refreshWhenOffline + } = config const [ FOCUS_REVALIDATORS, RECONNECT_REVALIDATORS, @@ -245,7 +246,7 @@ function useSWR( // Get the current state that SWR should return. const resolveData = () => { const cachedData = cache.get(key) - return isUndefined(cachedData) ? config.initialData : cachedData + return isUndefined(cachedData) ? initialData : cachedData } const data = resolveData() const error = cache.get(keyErr) @@ -255,11 +256,11 @@ function useSWR( // - Suspense mode and there's stale data for the inital render. // - Not suspense mode and there is no `initialData`. const shouldRevalidateOnMount = () => { - if (!isUndefined(config.revalidateOnMount)) return config.revalidateOnMount + if (!isUndefined(revalidateOnMount)) return revalidateOnMount - return config.suspense + return suspense ? !initialMountedRef.current && !isUndefined(data) - : isUndefined(config.initialData) + : isUndefined(initialData) } // Resolve the current validating state. @@ -414,12 +415,12 @@ function useSWR( // Deep compare with latest state to avoid extra re-renders. // For local state, compare and assign. - if (!config.compare(stateRef.current.data, newData)) { + if (!compare(stateRef.current.data, newData)) { newState.data = newData } // For global state, it's possible that the key has changed. // https://github.com/vercel/swr/pull/1058 - if (!config.compare(cache.get(key), newData)) { + if (!compare(cache.get(key), newData)) { cache.set(key, newData) } @@ -555,7 +556,7 @@ function useSWR( error: updatedError, isValidating: updatedIsValidating, // if data is undefined we should not update stateRef.current.data - ...(!config.compare(updatedData, stateRef.current.data) + ...(!compare(updatedData, stateRef.current.data) ? { data: updatedData } @@ -590,20 +591,16 @@ function useSWR( let timer: any = 0 function nextTick() { - const currentConfig = configRef.current - if (currentConfig.refreshInterval) { - timer = setTimeout(tick, currentConfig.refreshInterval) + if (refreshInterval) { + timer = setTimeout(tick, refreshInterval) } } async function tick() { - const currentConfig = configRef.current - if ( !stateRef.current.error && - (currentConfig.refreshWhenHidden || - currentConfig.isDocumentVisible()) && - (currentConfig.refreshWhenOffline || currentConfig.isOnline()) + (refreshWhenHidden || config.isDocumentVisible()) && + (refreshWhenOffline || config.isOnline()) ) { // only revalidate when the page is visible // if API request errored, we stop polling in this round @@ -623,18 +620,13 @@ function useSWR( timer = 0 } } - }, [ - config.refreshInterval, - config.refreshWhenHidden, - config.refreshWhenOffline, - revalidate - ]) + }, [refreshInterval, refreshWhenHidden, refreshWhenOffline, revalidate]) // In Suspense mode, we can't return the empty `data` state. // If there is `error`, the `error` needs to be thrown to the error boundary. // If there is no `error`, the `revalidation` promise needs to be thrown to // the suspense boundary. - if (config.suspense && isUndefined(data)) { + if (suspense && isUndefined(data)) { if (isUndefined(error)) { throw revalidate({ dedupe: true }) } @@ -690,7 +682,7 @@ function useSWR( return state } -export const SWRConfig = SWRConfigContext.Provider as typeof SWRConfigContext.Provider & { +export const SWRConfig = ConfigProvider as typeof ConfigProvider & { default: SWRConfiguration } Object.defineProperty(SWRConfig, 'default', { @@ -714,4 +706,5 @@ export function createCache( mutate: internalMutate.bind(null, cache) as ScopedMutator } } -export default useSWR + +export default withArgs(useSWRHandler) diff --git a/test/use-swr-key.test.tsx b/test/use-swr-key.test.tsx index 18ac76a67..56f8ddee4 100644 --- a/test/use-swr-key.test.tsx +++ b/test/use-swr-key.test.tsx @@ -60,7 +60,7 @@ describe('useSWR - key', () => { await act(() => sleep(100)) screen.getByText('data:') // undefined, time=250 await act(() => sleep(100)) - screen.getByText('data:key-1') // 1, time=550 + screen.getByText('data:key-1') // 1, time=350 }) it('should return undefined after key change when fetcher is synchronized', async () => { diff --git a/test/use-swr-middlewares.test.tsx b/test/use-swr-middlewares.test.tsx new file mode 100644 index 000000000..a4871cce3 --- /dev/null +++ b/test/use-swr-middlewares.test.tsx @@ -0,0 +1,171 @@ +import { act, render, screen } from '@testing-library/react' +import React, { useState, useEffect, useRef } from 'react' +import useSWR, { Middleware, SWRConfig } from '../src' +import { createResponse, sleep, createKey } from './utils' + +describe('useSWR - middlewares', () => { + it('should use middlewares', async () => { + const key = createKey() + const mockConsoleLog = jest.fn(s => s) + const loggerMiddleware: Middleware = useSWRNext => (k, fn, config) => { + mockConsoleLog(k) + return useSWRNext(k, fn, config) + } + function Page() { + const { data } = useSWR(key, () => createResponse('data'), { + middlewares: [loggerMiddleware] + }) + return
hello, {data}
+ } + + render() + screen.getByText('hello,') + await screen.findByText('hello, data') + expect(mockConsoleLog.mock.calls[0][0]).toBe(key) + // Initial render and data ready. + expect(mockConsoleLog.mock.calls.length).toBe(2) + }) + + it('should pass original keys to middlewares', async () => { + const key = createKey() + const mockConsoleLog = jest.fn(s => s) + const loggerMiddleware: Middleware = useSWRNext => (k, fn, config) => { + mockConsoleLog(k) + return useSWRNext(k, fn, config) + } + function Page() { + const { data } = useSWR([key, 1, 2, 3], () => createResponse('data'), { + middlewares: [loggerMiddleware] + }) + return
hello, {data}
+ } + + render() + screen.getByText('hello,') + await screen.findByText('hello, data') + expect(mockConsoleLog.mock.calls[0][0]).toEqual([key, 1, 2, 3]) + // Initial render and data ready. + expect(mockConsoleLog.mock.calls.length).toBe(2) + }) + + it('should support middlewares in context', async () => { + const key = createKey() + const mockConsoleLog = jest.fn(s => s) + const loggerMiddleware: Middleware = useSWRNext => (k, fn, config) => { + mockConsoleLog(k) + return useSWRNext(k, fn, config) + } + function Page() { + const { data } = useSWR(key, () => createResponse('data')) + return
hello, {data}
+ } + + render( + + + + ) + screen.getByText('hello,') + await screen.findByText('hello, data') + expect(mockConsoleLog.mock.calls[0][0]).toBe(key) + expect(mockConsoleLog.mock.calls.length).toBe(2) + }) + + it('should support extending middlewares via context and per-hook config', async () => { + const key = createKey() + const mockConsoleLog = jest.fn((_, s) => s) + const createLoggerMiddleware = (id: number): Middleware => useSWRNext => ( + k, + fn, + config + ) => { + mockConsoleLog(id, k) + return useSWRNext(k, fn, config) + } + function Page() { + const { data } = useSWR(key, () => createResponse('data'), { + middlewares: [createLoggerMiddleware(0)] + }) + return
hello, {data}
+ } + + render( + + + + + + ) + screen.getByText('hello,') + await screen.findByText('hello, data') + expect(mockConsoleLog.mock.calls.map(call => call[0])).toEqual([ + 0, + 1, + 2, + 0, + 1, + 2 + ]) + }) + + it('should support react hooks inside middlewares', async () => { + const key = createKey() + const lazyMiddleware: Middleware = useSWRNext => (k, fn, config) => { + const dataRef = useRef(undefined) + const res = useSWRNext(k, fn, config) + if (res.data) { + dataRef.current = res.data + return res + } else { + return { ...res, data: dataRef.current } + } + } + function Page() { + const [mounted, setMounted] = useState(false) + const { data } = useSWR(`${key}-${mounted ? '1' : '0'}`, k => + createResponse(k, { delay: 100 }) + ) + useEffect(() => { + setTimeout(() => setMounted(true), 200) + }, []) + return
data:{data}
+ } + + render( + + + + ) + + screen.getByText('data:') // undefined, time=0 + await act(() => sleep(150)) + screen.getByText(`data:${key}-0`) // 0, time=150 + await act(() => sleep(100)) + screen.getByText(`data:${key}-0`) // still holding the previous value, even if the key has changed + await act(() => sleep(100)) + screen.getByText(`data:${key}-1`) // 1, time=350 + }) + + it('should pass modified keys to the next middlewares and useSWR', async () => { + const key = createKey() + const createDecoratingKeyMiddleware = ( + c: string + ): Middleware => useSWRNext => (k, fn, config) => { + return useSWRNext(`${c}${k}${c}`, fn, config) + } + + function Page() { + const { data } = useSWR(key, k => createResponse(k), { + middlewares: [ + createDecoratingKeyMiddleware('!'), + createDecoratingKeyMiddleware('#') + ] + }) + return
hello, {data}
+ } + + render() + screen.getByText('hello,') + await screen.findByText(`hello, !#${key}#!`) + }) +})