diff --git a/examples/axios-typescript/libs/useRequest.ts b/examples/axios-typescript/libs/useRequest.ts index a663a39f8..7c379f0c8 100644 --- a/examples/axios-typescript/libs/useRequest.ts +++ b/examples/axios-typescript/libs/useRequest.ts @@ -24,10 +24,12 @@ export default function useRequest( request: GetRequest, { fallbackData, ...config }: Config = {} ): Return { - const { data: response, error, isValidating, mutate } = useSWR< - AxiosResponse, - AxiosError - >( + const { + data: response, + error, + isValidating, + mutate + } = useSWR, AxiosError>( request && JSON.stringify(request), /** * NOTE: Typescript thinks `request` can be `null` here, but the fetcher diff --git a/jest.config.js b/jest.config.js index 6f8c63b80..832e415aa 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,7 +6,8 @@ module.exports = { moduleNameMapper: { '^swr$': '/src', '^swr/infinite$': '/infinite/index.ts', - '^swr/immutable$': '/immutable/index.ts' + '^swr/immutable$': '/immutable/index.ts', + '^swr/mutation$': '/mutation/index.ts' }, transform: { '^.+\\.(t|j)sx?$': '@swc/jest' diff --git a/mutation/index.ts b/mutation/index.ts new file mode 100644 index 000000000..1dee32b06 --- /dev/null +++ b/mutation/index.ts @@ -0,0 +1,122 @@ +import { useCallback, useRef } from 'react' +import useSWR, { useSWRConfig, Middleware, Key } from 'swr' + +import { serialize } from '../src/utils/serialize' +import { useStateWithDeps } from '../src/utils/state' +import { withMiddleware } from '../src/utils/with-middleware' +import { useIsomorphicLayoutEffect } from '../src/utils/env' +import { UNDEFINED } from '../src/utils/helper' +import { getTimestamp } from '../src/utils/timestamp' + +import { + SWRMutationConfiguration, + SWRMutationResponse, + SWRMutationHook, + MutationFetcher +} from './types' + +const mutation = + () => + ( + key: Key, + fetcher: MutationFetcher, + config: SWRMutationConfiguration = {} + ) => { + const { mutate } = useSWRConfig() + + const keyRef = useRef(key) + // Ditch all mutation results that happened earlier than this timestamp. + const ditchMutationsUntilRef = useRef(0) + + const [stateRef, stateDependencies, setState] = useStateWithDeps({ + data: UNDEFINED, + error: UNDEFINED, + isMutating: false + }) + const currentState = stateRef.current + + const trigger = useCallback( + async (arg, opts?: SWRMutationConfiguration) => { + const [serializedKey, resolvedKey] = serialize(keyRef.current) + + if (!fetcher) { + throw new Error('Can’t trigger the mutation: missing fetcher.') + } + if (!serializedKey) { + throw new Error('Can’t trigger the mutation: key isn’t ready.') + } + + // Disable cache population by default. + const options = Object.assign({ populateCache: false }, config, opts) + + // Trigger a mutation, also track the timestamp. Any mutation that happened + // earlier this timestamp should be ignored. + const mutationStartedAt = getTimestamp() + + ditchMutationsUntilRef.current = mutationStartedAt + + setState({ isMutating: true }) + + try { + const data = await mutate( + serializedKey, + (fetcher as any)(resolvedKey, { arg }), + options + ) + + // If it's reset after the mutation, we don't broadcast any state change. + if (ditchMutationsUntilRef.current <= mutationStartedAt) { + setState({ data, isMutating: false }) + options.onSuccess?.(data as Data, serializedKey, options) + } + return data + } catch (error) { + // If it's reset after the mutation, we don't broadcast any state change. + if (ditchMutationsUntilRef.current <= mutationStartedAt) { + setState({ error: error as Error, isMutating: false }) + options.onError?.(error as Error, serializedKey, options) + throw error as Error + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + + const reset = useCallback(() => { + ditchMutationsUntilRef.current = getTimestamp() + setState({ data: UNDEFINED, error: UNDEFINED, isMutating: false }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useIsomorphicLayoutEffect(() => { + keyRef.current = key + }) + + // We don't return `mutate` here as it can be pretty confusing (e.g. people + // calling `mutate` but they actually mean `trigger`). + // And also, `mutate` relies on the useSWR hook to exist too. + return { + trigger, + reset, + get data() { + stateDependencies.data = true + return currentState.data + }, + get error() { + stateDependencies.error = true + return currentState.error + }, + get isMutating() { + stateDependencies.isMutating = true + return currentState.isMutating + } + } + } + +export default withMiddleware( + useSWR, + mutation as unknown as Middleware +) as unknown as SWRMutationHook + +export { SWRMutationConfiguration, SWRMutationResponse } diff --git a/mutation/package.json b/mutation/package.json new file mode 100644 index 000000000..7287f3311 --- /dev/null +++ b/mutation/package.json @@ -0,0 +1,11 @@ +{ + "name": "swr-mutation", + "version": "0.0.1", + "main": "./dist/index.js", + "module": "./dist/index.esm.js", + "types": "./dist/mutation", + "peerDependencies": { + "swr": "*", + "react": "*" + } +} diff --git a/mutation/tsconfig.json b/mutation/tsconfig.json new file mode 100644 index 000000000..5c5c9f3e6 --- /dev/null +++ b/mutation/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "outDir": "./dist" + }, + "include": ["./*.ts"] +} diff --git a/mutation/types.ts b/mutation/types.ts new file mode 100644 index 000000000..679a15abe --- /dev/null +++ b/mutation/types.ts @@ -0,0 +1,71 @@ +import { SWRResponse, Key, MutatorOptions } from 'swr' + +type FetcherResponse = Data | Promise + +type FetcherOptions = Readonly<{ + arg: ExtraArg +}> + +export type MutationFetcher< + Data = unknown, + ExtraArg = unknown, + SWRKey extends Key = Key +> = SWRKey extends () => infer Arg | null | undefined | false + ? (key: Arg, options: FetcherOptions) => FetcherResponse + : SWRKey extends null | undefined | false + ? never + : SWRKey extends infer Arg + ? (key: Arg, options: FetcherOptions) => FetcherResponse + : never + +export type SWRMutationConfiguration< + Data, + Error, + ExtraArg = any, + SWRMutationKey extends Key = Key +> = MutatorOptions & { + fetcher?: MutationFetcher + onSuccess?: ( + data: Data, + key: string, + config: Readonly< + SWRMutationConfiguration + > + ) => void + onError?: ( + err: Error, + key: string, + config: Readonly< + SWRMutationConfiguration + > + ) => void +} + +export interface SWRMutationResponse< + Data = any, + Error = any, + ExtraArg = any, + SWRMutationKey extends Key = Key +> extends Pick, 'data' | 'error'> { + isMutating: boolean + trigger: ( + extraArgument?: ExtraArg, + options?: SWRMutationConfiguration + ) => Promise + reset: () => void +} + +export type SWRMutationHook = < + Data = any, + Error = any, + SWRMutationKey extends Key = Key, + ExtraArg = any +>( + ...args: + | readonly [SWRMutationKey, MutationFetcher] + | readonly [ + SWRMutationKey, + MutationFetcher, + SWRMutationConfiguration + ] +) => SWRMutationResponse diff --git a/package.json b/package.json index 9674c988c..0baff361b 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,12 @@ "module": "./immutable/dist/index.esm.js", "require": "./immutable/dist/index.js", "types": "./immutable/dist/immutable/index.d.ts" + }, + "./mutation": { + "import": "./mutation/dist/index.mjs", + "module": "./mutation/dist/index.esm.js", + "require": "./mutation/dist/index.js", + "types": "./mutation/dist/mutation/index.d.ts" } }, "types": "./dist/index.d.ts", @@ -38,29 +44,34 @@ "dist/**", "infinite/dist/**", "immutable/dist/**", + "mutation/dist/**", "infinite/package.json", - "immutable/package.json" + "immutable/package.json", + "mutation/package.json" ], "repository": "github:vercel/swr", "homepage": "https://swr.vercel.app", "license": "MIT", "scripts": { - "clean": "rimraf dist infinite/dist immutable/dist", - "build": "yarn build:core && yarn build:infinite && yarn build:immutable", - "watch": "npm-run-all -p watch:core watch:infinite watch:immutable", + "clean": "rimraf dist infinite/dist immutable/dist mutation/dist", + "build": "yarn build:core && yarn build:infinite && yarn build:immutable && yarn build:mutation", + "watch": "npm-run-all -p watch:core watch:infinite watch:immutable watch:mutation", "watch:core": "yarn build:core -w", "watch:infinite": "yarn build:infinite -w", "watch:immutable": "yarn build:immutable -w", + "watch:mutation": "yarn build:mutation -w", "build:core": "bunchee src/index.ts --no-sourcemap", "build:infinite": "bunchee index.ts --cwd infinite --no-sourcemap", "build:immutable": "bunchee index.ts --cwd immutable --no-sourcemap", + "build:mutation": "bunchee index.ts --cwd mutation --no-sourcemap", "prepublishOnly": "yarn clean && yarn build", "publish-beta": "yarn publish --tag beta", "types:check": "tsc --noEmit --project tsconfig.check.json && tsc --noEmit -p test", "format": "prettier --write ./**/*.{ts,tsx}", "lint": "eslint . --ext .ts,.tsx --cache", "lint:fix": "yarn lint --fix", - "test": "jest --coverage" + "coverage": "jest --coverage", + "test": "jest" }, "husky": { "hooks": { diff --git a/src/types.ts b/src/types.ts index e76ddf8d3..144ab9c33 100644 --- a/src/types.ts +++ b/src/types.ts @@ -145,6 +145,11 @@ export type MutatorOptions = { rollbackOnError?: boolean } +export type MutatorConfig = { + revalidate?: boolean + populateCache?: boolean +} + export type Broadcaster = ( cache: Cache, key: string, diff --git a/src/use-swr.ts b/src/use-swr.ts index 358b6cdb4..26404dfe5 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -131,14 +131,12 @@ export const useSWRHandler = ( } const isValidating = resolveValidating() - const [stateRef, stateDependencies, setState] = useStateWithDeps( - { - data, - error, - isValidating - }, - unmountedRef - ) + const currentState = { + data, + error, + isValidating + } + const [stateRef, stateDependencies, setState] = useStateWithDeps(currentState) // The revalidation function is a carefully crafted wrapper of the original // `fetcher`, to correctly handle the many edge cases. @@ -379,10 +377,11 @@ export const useSWRHandler = ( [] ) - // Always update fetcher and config refs. + // Always update fetcher, config and state refs. useIsomorphicLayoutEffect(() => { fetcherRef.current = fetcher configRef.current = config + stateRef.current = currentState }) // After mounted or key changed. diff --git a/src/utils/broadcast-state.ts b/src/utils/broadcast-state.ts index b02c0f6b9..011d306e0 100644 --- a/src/utils/broadcast-state.ts +++ b/src/utils/broadcast-state.ts @@ -1,6 +1,7 @@ import { Broadcaster } from '../types' import { SWRGlobalState, GlobalState } from './global-state' import * as revalidateEvents from '../constants' +import { createCacheHelper } from './cache' export const broadcastState: Broadcaster = ( cache, @@ -15,6 +16,8 @@ export const broadcastState: Broadcaster = ( const revalidators = EVENT_REVALIDATORS[key] const updaters = STATE_UPDATERS[key] + const [get] = createCacheHelper(cache, key) + // Cache was populated, update states of all hooks. if (broadcast && updaters) { for (let i = 0; i < updaters.length; ++i) { @@ -30,10 +33,10 @@ export const broadcastState: Broadcaster = ( if (revalidators && revalidators[0]) { return revalidators[0](revalidateEvents.MUTATE_EVENT).then( - () => cache.get(key).data + () => get().data ) } } - return cache.get(key).data + return get().data } diff --git a/src/utils/env.ts b/src/utils/env.ts index 3912aa311..c67ff8cae 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -5,7 +5,9 @@ export const IS_SERVER = !hasWindow() || 'Deno' in window // Polyfill requestAnimationFrame export const rAF = (f: (...args: any[]) => void) => - hasRequestAnimationFrame() ? window['requestAnimationFrame'](f) : setTimeout(f, 1) + hasRequestAnimationFrame() + ? window['requestAnimationFrame'](f) + : setTimeout(f, 1) // React currently throws a warning when using useLayoutEffect on the server. // To get around it, we can conditionally useEffect on the server (no-op) and @@ -15,12 +17,14 @@ export const useIsomorphicLayoutEffect = IS_SERVER ? useEffect : useLayoutEffect // This assignment is to extend the Navigator type to use effectiveType. const navigatorConnection = typeof navigator !== 'undefined' && - (navigator as Navigator & { - connection?: { - effectiveType: string - saveData: boolean + ( + navigator as Navigator & { + connection?: { + effectiveType: string + saveData: boolean + } } - }).connection + ).connection // Adjust the config based on slow connection status (<= 70Kbps). export const slowConnection = diff --git a/src/utils/mutate.ts b/src/utils/mutate.ts index 91b99a402..d59d6eee2 100644 --- a/src/utils/mutate.ts +++ b/src/utils/mutate.ts @@ -14,7 +14,7 @@ export const internalMutate = async ( undefined | Data | Promise | MutatorCallback, undefined | boolean | MutatorOptions ] -) => { +): Promise => { const [cache, _key, _data, _opts] = args // When passing as a boolean, it's explicitly used to disable/enable diff --git a/src/utils/state.ts b/src/utils/state.ts index 9e1eed449..43f4ce68c 100644 --- a/src/utils/state.ts +++ b/src/utils/state.ts @@ -1,30 +1,30 @@ import { useRef, useCallback, useState, MutableRefObject } from 'react' import { useIsomorphicLayoutEffect } from './env' -import { State } from '../types' - -type StateKeys = keyof State -type StateDeps = Record /** * An implementation of state with dependency-tracking. */ -export const useStateWithDeps = >( - state: S, - unmountedRef: MutableRefObject -): [MutableRefObject, Record, (payload: S) => void] => { +export const useStateWithDeps = ( + state: any +): [ + MutableRefObject, + Record, + (payload: Partial) => void +] => { const rerender = useState>({})[1] + const unmountedRef = useRef(false) const stateRef = useRef(state) // If a state property (data, error or isValidating) is accessed by the render // function, we mark the property as a dependency so if it is updated again // in the future, we trigger a rerender. // This is also known as dependency-tracking. - const stateDependenciesRef = useRef({ + const stateDependenciesRef = useRef>({ data: false, error: false, isValidating: false - }) + } as any) /** * @param payload To change stateRef, pass the values explicitly to setState: @@ -44,17 +44,17 @@ export const useStateWithDeps = >( * ``` */ const setState = useCallback( - (payload: S) => { + (payload: Partial) => { let shouldRerender = false const currentState = stateRef.current for (const _ in payload) { - const k = _ as keyof S & StateKeys + const k = _ as keyof S // If the property has changed, update the state and mark rerender as // needed. if (currentState[k] !== payload[k]) { - currentState[k] = payload[k] + currentState[k] = payload[k]! // If the property is accessed by the component, a rerender should be // triggered. @@ -73,9 +73,11 @@ export const useStateWithDeps = >( [] ) - // Always update the state reference. useIsomorphicLayoutEffect(() => { - stateRef.current = state + unmountedRef.current = false + return () => { + unmountedRef.current = true + } }) return [stateRef, stateDependenciesRef.current, setState] diff --git a/test/type/trigger.ts b/test/type/trigger.ts new file mode 100644 index 000000000..083ef91a1 --- /dev/null +++ b/test/type/trigger.ts @@ -0,0 +1,55 @@ +import useSWRMutation from 'swr/mutation' + +type ExpectType = (value: T) => void +const expectType: ExpectType = () => {} + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B + ? 1 + : 2 + ? true + : false + +export function useExtraParam() { + useSWRMutation('/api/user', key => { + expectType(key) + }) + useSWRMutation('/api/user', (_, opts) => { + expectType>>(true) + }) +} + +export function useTrigger() { + const { trigger, reset, data, error } = useSWRMutation( + '/api/user', + (_, opts: { arg: number }) => String(opts.arg) + ) + + // The argument of trigger should be number or undefined. + // TODO: handle the `undefined` cases. + expectType[0], number | undefined>>(true) + expectType>(trigger(1)) + + // Other return values + expectType void>>(true) + expectType>(true) + expectType>(true) + + // Should not return some fields. + type Ret = ReturnType + expectType, Ret>>(true) +} + +export function useTriggerWithParameter() { + const { trigger } = useSWRMutation( + '/api/user', + (_, opts) => { + expectType>>(true) + return String(opts.arg) + } + ) + + // The argument of trigger should be number or undefined. + // TODO: handle the `undefined` cases. + expectType[0], number | undefined>>(true) + expectType>(trigger(1)) +} diff --git a/test/use-swr-remote-mutation.test.tsx b/test/use-swr-remote-mutation.test.tsx new file mode 100644 index 000000000..8cd37312c --- /dev/null +++ b/test/use-swr-remote-mutation.test.tsx @@ -0,0 +1,818 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import useSWR from 'swr' +import useSWRMutation from 'swr/mutation' +import { createKey, sleep, nextTick } from './utils' + +const waitForNextTick = () => act(() => sleep(1)) + +describe('useSWR - remote mutation', () => { + it('should return data after triggering', async () => { + const key = createKey() + + function Page() { + const { data, trigger } = useSWRMutation(key, () => 'data') + return + } + + render() + + // mount + await screen.findByText('pending') + + fireEvent.click(screen.getByText('pending')) + await waitForNextTick() + + screen.getByText('data') + }) + + it('should return data from `trigger`', async () => { + const key = createKey() + + function Page() { + const [data, setData] = React.useState(null) + const { trigger } = useSWRMutation(key, () => 'data') + return ( + + ) + } + + render() + + // mount + await screen.findByText('pending') + + fireEvent.click(screen.getByText('pending')) + await waitForNextTick() + + screen.getByText('data') + }) + + it('should trigger request with the correct argument signature', async () => { + const key = createKey() + const fetcher = jest.fn(() => 'data') + + function Page() { + const { data, trigger } = useSWRMutation([key, 'arg0'], fetcher) + return ( + + ) + } + + render() + + // mount + await screen.findByText('pending') + expect(fetcher).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText('pending')) + await waitForNextTick() + + screen.getByText('data') + + expect(fetcher).toHaveBeenCalled() + expect(fetcher.mock.calls.length).toBe(1) + expect(fetcher.mock.calls[0]).toEqual([[key, 'arg0'], { arg: 'arg1' }]) + }) + + it('should call `onSuccess` event', async () => { + const key = createKey() + const onSuccess = jest.fn() + + function Page() { + const { data, trigger } = useSWRMutation(key, () => 'data', { + onSuccess + }) + return + } + + render() + + // mount + await screen.findByText('pending') + + fireEvent.click(screen.getByText('pending')) + await waitForNextTick() + + screen.getByText('data') + + expect(onSuccess).toHaveBeenCalled() + }) + + it('should support configuring `onSuccess` with trigger', async () => { + const key = createKey() + const onSuccess = jest.fn() + + function Page() { + const { data, trigger } = useSWRMutation(key, () => 'data') + return ( + + ) + } + + render() + + // mount + await screen.findByText('pending') + + fireEvent.click(screen.getByText('pending')) + await waitForNextTick() + + screen.getByText('data') + + expect(onSuccess).toHaveBeenCalled() + }) + + it('should call `onError` event', async () => { + const key = createKey() + const onError = jest.fn() + const onInplaceError = jest.fn() + + function Page() { + const { data, error, trigger } = useSWRMutation( + key, + async () => { + await sleep(10) + throw new Error('error!') + }, + { + onError + } + ) + return ( + + ) + } + + render() + + // mount + await screen.findByText('pending') + fireEvent.click(screen.getByText('pending')) + + await screen.findByText('error!') + expect(onError).toHaveBeenCalled() + expect(onInplaceError).toHaveBeenCalled() + }) + + it('should return `isMutating` state correctly', async () => { + const key = createKey() + + function Page() { + const { data, trigger, isMutating } = useSWRMutation(key, async () => { + await sleep(10) + return 'data' + }) + return ( + + ) + } + + render() + + // mount + await screen.findByText('state:') + fireEvent.click(screen.getByText('state:')) + + await screen.findByText('state:pending') + await screen.findByText('state:data') + }) + + it('should send `onError` and `onSuccess` events', async () => { + const key = createKey() + const onSuccess = jest.fn() + const onError = jest.fn() + + let arg = false + + function Page() { + const { data, error, trigger } = useSWRMutation( + key, + async (_, { arg: shouldReturnValue }) => { + await sleep(10) + if (shouldReturnValue) return 'data' + throw new Error('error') + }, + { + onSuccess, + onError + } + ) + return ( + + ) + } + + render() + + // mount + await screen.findByText('pending') + fireEvent.click(screen.getByText('pending')) + + await screen.findByText('error') + expect(onError).toHaveBeenCalled() + expect(onSuccess).not.toHaveBeenCalled() + + arg = true + fireEvent.click(screen.getByText('error')) + await screen.findByText('data') + expect(onSuccess).toHaveBeenCalled() + }) + + it('should not dedupe trigger requests', async () => { + const key = createKey() + const fn = jest.fn() + + function Page() { + const { trigger } = useSWRMutation(key, async () => { + fn() + await sleep(10) + return 'data' + }) + return + } + + render() + + // mount + await screen.findByText('trigger') + expect(fn).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText('trigger')) + expect(fn).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByText('trigger')) + fireEvent.click(screen.getByText('trigger')) + fireEvent.click(screen.getByText('trigger')) + expect(fn).toHaveBeenCalledTimes(4) + }) + + it('should share the cache with `useSWR` when `populateCache` is enabled', async () => { + const key = createKey() + + function Page() { + const { data } = useSWR(key) + const { trigger } = useSWRMutation(key, async () => { + await sleep(10) + return 'data' + }) + return ( +
+ +
data:{data || 'none'}
+
+ ) + } + + render() + + // mount + await screen.findByText('data:none') + + fireEvent.click(screen.getByText('trigger')) + await screen.findByText('data:data') + }) + + it('should not read the cache from `useSWR`', async () => { + const key = createKey() + + function Page() { + useSWR(key, () => 'data') + const { data } = useSWRMutation(key, () => 'wrong!') + return
data:{data || 'none'}
+ } + + render() + + // mount + await screen.findByText('data:none') + }) + + it('should be able to populate the cache for `useSWR`', async () => { + const key = createKey() + + function Page() { + const { data } = useSWR(key, () => 'data') + const { trigger } = useSWRMutation(key, (_, { arg }) => arg) + return ( +
trigger('updated!', { populateCache: true })}> + data:{data || 'none'} +
+ ) + } + + render() + + await screen.findByText('data:none') + + // mount + await screen.findByText('data:data') + + // mutate + fireEvent.click(screen.getByText('data:data')) + await screen.findByText('data:updated!') + }) + + it('should be able to populate the cache with a transformer', async () => { + const key = createKey() + + function Page() { + const { data } = useSWR(key, () => 'data') + const { trigger } = useSWRMutation(key, (_, { arg }) => arg) + return ( +
+ trigger('updated!', { + populateCache: (v, current) => v + ':' + current + }) + } + > + data:{data || 'none'} +
+ ) + } + + render() + + await screen.findByText('data:none') + + // mount + await screen.findByText('data:data') + + // mutate + fireEvent.click(screen.getByText('data:data')) + await screen.findByText('data:updated!:data') + }) + + it('should not trigger request when mutating from shared hooks', async () => { + const key = createKey() + const fn = jest.fn(() => 'data') + + function Page() { + useSWRMutation(key, fn) + const { mutate } = useSWR(key) + return ( +
+ +
+ ) + } + + render() + + // mount + await screen.findByText('mutate') + + fireEvent.click(screen.getByText('mutate')) + + await act(() => sleep(50)) + expect(fn).not.toHaveBeenCalled() + }) + + it('should not trigger request when key changes', async () => { + const key = createKey() + const fn = jest.fn(() => 'data') + + function Page() { + const [k, setK] = React.useState(key) + useSWRMutation(k, fn) + return ( +
+ +
+ ) + } + + render() + + // mount + await screen.findByText('update key') + + fireEvent.click(screen.getByText('update key')) + + await act(() => sleep(50)) + expect(fn).not.toHaveBeenCalled() + }) + + it('should prevent race conditions with `useSWR`', async () => { + const key = createKey() + const logger = jest.fn() + + function Page() { + const { data } = useSWR(key, async () => { + await sleep(10) + return 'foo' + }) + const { trigger } = useSWRMutation(key, async () => { + await sleep(20) + return 'bar' + }) + + logger(data) + + return ( +
+ +
data:{data || 'none'}
+
+ ) + } + + render() + + // mount + await screen.findByText('data:none') + + fireEvent.click(screen.getByText('trigger')) + await act(() => sleep(50)) + await screen.findByText('data:bar') + + // It should never render `foo`. + expect(logger).not.toHaveBeenCalledWith('foo') + }) + + it('should revalidate after mutating by default', async () => { + const key = createKey() + const logger = jest.fn() + + function Page() { + const { data } = useSWR( + key, + async () => { + await sleep(10) + return 'foo' + }, + { revalidateOnMount: false } + ) + const { trigger } = useSWRMutation(key, async () => { + await sleep(20) + return 'bar' + }) + + logger(data) + + return ( +
+ +
data:{data || 'none'}
+
+ ) + } + + render() + + // mount + await screen.findByText('data:none') + + fireEvent.click(screen.getByText('trigger')) + await act(() => sleep(50)) + + // It triggers revalidation + await screen.findByText('data:foo') + + // It should never render `bar` since we never populate the cache. + expect(logger).not.toHaveBeenCalledWith('bar') + }) + + it('should revalidate after populating the cache', async () => { + const key = createKey() + const logger = jest.fn() + + function Page() { + const { data } = useSWR( + key, + async () => { + await sleep(20) + return 'foo' + }, + { revalidateOnMount: false } + ) + const { trigger } = useSWRMutation(key, async () => { + await sleep(20) + return 'bar' + }) + + logger(data) + + return ( +
+ +
data:{data || 'none'}
+
+ ) + } + + render() + + // mount + await screen.findByText('data:none') + + fireEvent.click(screen.getByText('trigger')) + + // Cache is updated + await screen.findByText('data:bar') + + // A revalidation is triggered + await screen.findByText('data:foo') + }) + + it('should be able to turn off auto revalidation', async () => { + const key = createKey() + const logger = jest.fn() + + function Page() { + const { data } = useSWR( + key, + async () => { + await sleep(10) + return 'foo' + }, + { revalidateOnMount: false } + ) + const { trigger } = useSWRMutation( + key, + async () => { + await sleep(20) + return 'bar' + }, + { revalidate: false, populateCache: true } + ) + + logger(data) + + return ( +
+ +
data:{data || 'none'}
+
+ ) + } + + render() + + // mount + await screen.findByText('data:none') + + fireEvent.click(screen.getByText('trigger')) + await act(() => sleep(50)) + + // It should not trigger revalidation + await screen.findByText('data:bar') + + // It should never render `foo`. + expect(logger).not.toHaveBeenCalledWith('foo') + }) + + it('should be able to configure auto revalidation from trigger', async () => { + const key = createKey() + const logger = jest.fn() + + function Page() { + const { data } = useSWR( + key, + async () => { + await sleep(10) + return 'foo' + }, + { revalidateOnMount: false } + ) + const { trigger } = useSWRMutation( + key, + async () => { + await sleep(20) + return 'bar' + }, + { populateCache: true } + ) + + logger(data) + + return ( +
+ + +
data:{data || 'none'}
+
+ ) + } + + render() + + // mount + await screen.findByText('data:none') + + fireEvent.click(screen.getByText('trigger1')) + await act(() => sleep(50)) + + // It should not trigger revalidation + await screen.findByText('data:bar') + + // It should never render `foo`. + expect(logger).not.toHaveBeenCalledWith('foo') + + fireEvent.click(screen.getByText('trigger2')) + await act(() => sleep(50)) + + // It should trigger revalidation + await screen.findByText('data:foo') + }) + + it('should be able to reset the state', async () => { + const key = createKey() + + function Page() { + const { data, trigger, reset } = useSWRMutation(key, async () => { + return 'data' + }) + + return ( +
+ + +
data:{data || 'none'}
+
+ ) + } + + render() + + // mount + await screen.findByText('data:none') + + fireEvent.click(screen.getByText('trigger')) + + // Cache is updated + await screen.findByText('data:data') + + // reset + fireEvent.click(screen.getByText('reset')) + await screen.findByText('data:none') + }) + + it('should prevent race condition if reset the state', async () => { + const key = createKey() + const onSuccess = jest.fn() + + function Page() { + const { data, trigger, reset } = useSWRMutation(key, async () => { + await sleep(10) + return 'data' + }) + + return ( +
+ + +
data:{data || 'none'}
+
+ ) + } + + render() + + // mount + await screen.findByText('data:none') + + // start mutation + fireEvent.click(screen.getByText('trigger')) + + // reset, before it ends + fireEvent.click(screen.getByText('reset')) + + await act(() => sleep(30)) + await screen.findByText('data:none') + }) + + it('should prevent race condition if triggered multiple times', async () => { + const key = createKey() + const logger = [] + + let id = 0 + function Page() { + const { data, trigger } = useSWRMutation(key, async () => { + await sleep(10) + return id++ + }) + + logger.push(data) + + return + } + + render() + + // Mount + await screen.findByText('trigger') + + // Start mutation multiple times, to break the previous one + fireEvent.click(screen.getByText('trigger')) // 0 + await act(() => sleep(5)) + fireEvent.click(screen.getByText('trigger')) // 1 + await act(() => sleep(5)) + fireEvent.click(screen.getByText('trigger')) // 2 + await act(() => sleep(20)) + + // Shouldn't have intermediate states + expect(logger).toEqual([undefined, 2]) + }) + + it('should error if no mutator is given', async () => { + const key = createKey() + const catchError = jest.fn() + + function Page() { + const { trigger } = useSWRMutation(key, null) + + return ( +
+ +
+ ) + } + + render() + + fireEvent.click(screen.getByText('trigger')) + await nextTick() + expect(catchError).toBeCalled() + }) + + it('should support optimistic updates', async () => { + const key = createKey() + + function Page() { + const { data } = useSWR(key, async () => { + await sleep(10) + return ['foo'] + }) + const { trigger } = useSWRMutation(key, async (_, { arg }) => { + await sleep(20) + return arg.toUpperCase() + }) + + return ( +
+ +
data:{JSON.stringify(data)}
+
+ ) + } + + render() + + // mount + await screen.findByText('data:["foo"]') + + // optimistic update + fireEvent.click(screen.getByText('trigger')) + await screen.findByText('data:["foo","bar"]') + await act(() => sleep(50)) + await screen.findByText('data:["foo","BAR"]') + + // 2nd check + fireEvent.click(screen.getByText('trigger')) + await screen.findByText('data:["foo","BAR","bar"]') + await act(() => sleep(50)) + await screen.findByText('data:["foo","BAR","BAR"]') + }) +}) diff --git a/tsconfig.check.json b/tsconfig.check.json index a1e69e7e9..6a5ea78a7 100644 --- a/tsconfig.check.json +++ b/tsconfig.check.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "rootDir": ".", + "rootDir": "." }, - "include": ["src", "immutable", "infinite"] + "include": ["src", "immutable", "infinite", "mutation"] } diff --git a/tsconfig.json b/tsconfig.json index 000075d68..09d7513cd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,8 @@ "paths": { "swr": ["./src/index.ts"], "swr/infinite": ["./infinite/index.ts"], - "swr/immutable": ["./immutable/index.ts"] + "swr/immutable": ["./immutable/index.ts"], + "swr/mutation": ["./mutation/index.ts"] }, "typeRoots": ["./src/types", "./node_modules/@types"] },