From 1e2e10f1c76cd04379e7e51bcbc61ed8e209cd32 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Sun, 12 Sep 2021 13:18:50 +0200 Subject: [PATCH 01/25] (wip) initial impl. --- mutation/index.ts | 49 ++++++++++++++++++++++++++++++++++++++++++ mutation/package.json | 11 ++++++++++ mutation/tsconfig.json | 8 +++++++ package.json | 9 +++++++- src/types.ts | 4 +++- src/use-swr.ts | 36 +++++++++++++++++++++---------- src/utils/config.ts | 3 +++ src/utils/state.ts | 12 +++++++---- tsconfig.json | 3 ++- 9 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 mutation/index.ts create mode 100644 mutation/package.json create mode 100644 mutation/tsconfig.json diff --git a/mutation/index.ts b/mutation/index.ts new file mode 100644 index 000000000..9f9228fe4 --- /dev/null +++ b/mutation/index.ts @@ -0,0 +1,49 @@ +import { useCallback } from 'react' +import useSWR, { Middleware, SWRHook } from 'swr' + +import { serialize } from '../src/utils/serialize' +import { useStateWithDeps } from '../src/utils/state' +import { withMiddleware } from '../src/utils/with-middleware' + +export const mutation: Middleware = useSWRNext => (key, fetcher, config) => { + // Override all revalidate options, disable polling, disable cache write back + // by default. + config.revalidateOnFocus = false + config.revalidateOnMount = false + config.revalidateOnReconnect = false + config.refreshInterval = 0 + config.populateCache = false + config.shouldRetryOnError = false + config.local = true + + const [dataKey, args] = serialize(key) + const swr = useSWRNext(key, fetcher, config) + + const [stateRef, stateDependencies, setState] = useStateWithDeps({ + data: undefined, + error: undefined, + isValidating: false + }) + + const trigger = useCallback((extraArg, opts) => { + fetcher(...args, extraArg) + }, []) + + return { + mutate: swr.mutate, + get data() { + stateDependencies.data = true + return stateRef.current.data + }, + get error() { + stateDependencies.error = true + return stateRef.current.error + }, + get isValidating() { + stateDependencies.isValidating = true + return stateRef.current.isValidating + } + } +} + +export default withMiddleware(useSWR as SWRHook, mutation) 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/package.json b/package.json index ece9597ca..bec201b85 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,11 @@ "import": "./immutable/dist/index.esm.js", "require": "./immutable/dist/index.js", "types": "./immutable/dist/immutable/index.d.ts" + }, + "./mutation": { + "import": "./mutation/dist/index.esm.js", + "require": "./mutation/dist/index.js", + "types": "./mutation/dist/mutation/index.d.ts" } }, "react-native": "./dist/index.esm.js", @@ -28,8 +33,10 @@ "dist/**", "infinite/dist/**", "immutable/dist/**", + "mutation/dist/**", "infinite/package.json", - "immutable/package.json" + "immutable/package.json", + "mutation/package.json" ], "repository": "vercel/swr", "homepage": "https://swr.vercel.app", diff --git a/src/types.ts b/src/types.ts index 9481a36bd..721834aa9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,8 @@ export interface PublicConfiguration< revalidateIfStale: boolean shouldRetryOnError: boolean suspense?: boolean + populateCache?: boolean + local?: boolean fallbackData?: Data fetcher?: Fn use?: Middleware[] @@ -147,7 +149,7 @@ export type SWRConfiguration< export type Key = ValueKey | (() => ValueKey) -export interface SWRResponse { +export interface SWRResponse { data?: Readonly error?: Readonly mutate: KeyedMutator diff --git a/src/use-swr.ts b/src/use-swr.ts index a0b4f43c9..1b23e1dfc 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -111,8 +111,7 @@ export const useSWRHandler = ( data, error, isValidating - }, - unmountedRef + } ) // The revalidation function is a carefully crafted wrapper of the original @@ -143,9 +142,16 @@ export const useSWRHandler = ( delete CONCURRENT_PROMISES_TS[key] } + const writeCache = (k: string, v: any) => { + if (getConfig().populateCache) { + cache.set(k, v) + } + } + // start fetching try { - cache.set(keyValidating, true) + writeCache(keyValidating, true) + setState({ isValidating: true }) @@ -247,7 +253,7 @@ export const useSWRHandler = ( // For global state, it's possible that the key has changed. // https://github.com/vercel/swr/pull/1058 if (!compare(cache.get(key), newData)) { - cache.set(key, newData) + writeCache(key, newData) } // merge the new state @@ -259,7 +265,9 @@ export const useSWRHandler = ( } } catch (err) { cleanupState() - cache.set(keyValidating, false) + + writeCache(keyValidating, false) + if (getConfig().isPaused()) { setState({ isValidating: false @@ -267,8 +275,9 @@ export const useSWRHandler = ( return false } - // Get a new error, don't use deep comparison for errors. - cache.set(keyErr, err) + // We don't use deep comparison for errors. + writeCache(keyErr, err) + if (stateRef.current.error !== err) { // Keep the stale data but update error. setState({ @@ -382,8 +391,13 @@ export const useSWRHandler = ( return } - const unsubUpdate = subscribeCallback(key, STATE_UPDATERS, onStateUpdate) - const unsubEvents = subscribeCallback(key, EVENT_REVALIDATORS, onRevalidate) + const shouldSubscribeToGlobal = !getConfig().local + const unsubUpdate = + shouldSubscribeToGlobal && + subscribeCallback(key, STATE_UPDATERS, onStateUpdate) + const unsubEvents = + shouldSubscribeToGlobal && + subscribeCallback(key, EVENT_REVALIDATORS, onRevalidate) // Mark the component as mounted and update corresponding refs. unmountedRef.current = false @@ -418,8 +432,8 @@ export const useSWRHandler = ( // Mark it as unmounted. unmountedRef.current = true - unsubUpdate() - unsubEvents() + unsubUpdate && unsubUpdate() + unsubEvents && unsubEvents() } }, [key, revalidate]) diff --git a/src/utils/config.ts b/src/utils/config.ts index b7cd5c707..83950338c 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -61,6 +61,9 @@ export const defaultConfig: FullConfiguration = mergeObjects( revalidateIfStale: true, shouldRetryOnError: true, + // cache strategy + populateCache: true, + // timeouts errorRetryInterval: slowConnection ? 10000 : 5000, focusThrottleInterval: 5 * 1000, diff --git a/src/utils/state.ts b/src/utils/state.ts index 9e1eed449..9e724f1ff 100644 --- a/src/utils/state.ts +++ b/src/utils/state.ts @@ -2,6 +2,7 @@ import { useRef, useCallback, useState, MutableRefObject } from 'react' import { useIsomorphicLayoutEffect } from './env' import { State } from '../types' +import { noop } from '../utils/helper' type StateKeys = keyof State type StateDeps = Record @@ -10,10 +11,9 @@ type StateDeps = Record * An implementation of state with dependency-tracking. */ export const useStateWithDeps = >( - state: S, - unmountedRef: MutableRefObject + state: S ): [MutableRefObject, Record, (payload: S) => void] => { - const rerender = useState>({})[1] + let rerender = useState>({})[1] const stateRef = useRef(state) // If a state property (data, error or isValidating) is accessed by the render @@ -64,7 +64,7 @@ export const useStateWithDeps = >( } } - if (shouldRerender && !unmountedRef.current) { + if (shouldRerender) { rerender({}) } }, @@ -76,6 +76,10 @@ export const useStateWithDeps = >( // Always update the state reference. useIsomorphicLayoutEffect(() => { stateRef.current = state + return () => { + // When unmounting, set the `rerender` function to a no-op. + rerender = noop + } }) return [stateRef, stateDependenciesRef.current, setState] diff --git a/tsconfig.json b/tsconfig.json index 3a446ac12..8f6a15cd1 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"] }, From 8ba262a310fcef139cfd017c751b9c7b76920d06 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Sun, 12 Sep 2021 14:21:37 +0200 Subject: [PATCH 02/25] fix test --- mutation/index.ts | 39 +++++++++++++++------------------ src/types.ts | 16 +++++++++----- src/use-swr.ts | 27 +++++++---------------- src/utils/broadcast-state.ts | 7 +++--- src/utils/mutate.ts | 29 +++++++++++++++--------- src/utils/state.ts | 10 ++++----- test/use-swr-immutable.test.tsx | 5 +---- 7 files changed, 65 insertions(+), 68 deletions(-) diff --git a/mutation/index.ts b/mutation/index.ts index 9f9228fe4..107815a33 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -1,36 +1,33 @@ -import { useCallback } from 'react' -import useSWR, { Middleware, SWRHook } from 'swr' +import { useCallback, useRef } from 'react' +import useSWR, { Middleware, SWRHook, useSWRConfig } from 'swr' import { serialize } from '../src/utils/serialize' import { useStateWithDeps } from '../src/utils/state' import { withMiddleware } from '../src/utils/with-middleware' -export const mutation: Middleware = useSWRNext => (key, fetcher, config) => { - // Override all revalidate options, disable polling, disable cache write back - // by default. - config.revalidateOnFocus = false - config.revalidateOnMount = false - config.revalidateOnReconnect = false - config.refreshInterval = 0 - config.populateCache = false - config.shouldRetryOnError = false - config.local = true - - const [dataKey, args] = serialize(key) - const swr = useSWRNext(key, fetcher, config) +export const mutation: Middleware = () => (key, fetcher, config) => { + const [serializedKey, args] = serialize(key) + const argsRef = useRef(args) + const { mutate } = useSWRConfig() const [stateRef, stateDependencies, setState] = useStateWithDeps({ data: undefined, error: undefined, - isValidating: false + isMutating: false }) - const trigger = useCallback((extraArg, opts) => { - fetcher(...args, extraArg) + const trigger = useCallback((extraArg, shouldRevalidate, opts) => { + // Trigger a mutation. + // Assign extra arguments to the ref, so the fetcher can access them later. + return mutate(serializedKey, () => fetcher(...argsRef.current, extraArg), { + revalidate: shouldRevalidate, + populateCache: false + }) }, []) return { - mutate: swr.mutate, + mutate, + trigger, get data() { stateDependencies.data = true return stateRef.current.data @@ -40,8 +37,8 @@ export const mutation: Middleware = useSWRNext => (key, fetcher, config) => { return stateRef.current.error }, get isValidating() { - stateDependencies.isValidating = true - return stateRef.current.isValidating + ;(stateDependencies as any).isMutating = true + return stateRef.current.isMutating } } } diff --git a/src/types.ts b/src/types.ts index 721834aa9..646626a6b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,8 +27,6 @@ export interface PublicConfiguration< revalidateIfStale: boolean shouldRetryOnError: boolean suspense?: boolean - populateCache?: boolean - local?: boolean fallbackData?: Data fetcher?: Fn use?: Middleware[] @@ -97,13 +95,19 @@ export type MutatorCallback = ( currentValue?: Data ) => Promise | undefined | Data +export type MutatorConfig = { + revalidate?: boolean + populateCache?: boolean +} + export type Broadcaster = ( cache: Cache, key: string, data: Data, error?: Error, isValidating?: boolean, - shouldRevalidate?: boolean + shouldRevalidate?: boolean, + populateCache?: boolean ) => Promise export type State = { @@ -150,10 +154,10 @@ export type SWRConfiguration< export type Key = ValueKey | (() => ValueKey) export interface SWRResponse { - data?: Readonly - error?: Readonly + data?: Data + error?: Error mutate: KeyedMutator - isValidating: Readonly + isValidating: boolean } export type KeyLoader = diff --git a/src/use-swr.ts b/src/use-swr.ts index 1b23e1dfc..e2a166faf 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -142,15 +142,9 @@ export const useSWRHandler = ( delete CONCURRENT_PROMISES_TS[key] } - const writeCache = (k: string, v: any) => { - if (getConfig().populateCache) { - cache.set(k, v) - } - } - // start fetching try { - writeCache(keyValidating, true) + cache.set(keyValidating, true) setState({ isValidating: true @@ -253,7 +247,7 @@ export const useSWRHandler = ( // For global state, it's possible that the key has changed. // https://github.com/vercel/swr/pull/1058 if (!compare(cache.get(key), newData)) { - writeCache(key, newData) + cache.set(key, newData) } // merge the new state @@ -266,7 +260,7 @@ export const useSWRHandler = ( } catch (err) { cleanupState() - writeCache(keyValidating, false) + cache.set(keyValidating, false) if (getConfig().isPaused()) { setState({ @@ -276,7 +270,7 @@ export const useSWRHandler = ( } // We don't use deep comparison for errors. - writeCache(keyErr, err) + cache.set(keyErr, err) if (stateRef.current.error !== err) { // Keep the stale data but update error. @@ -391,13 +385,8 @@ export const useSWRHandler = ( return } - const shouldSubscribeToGlobal = !getConfig().local - const unsubUpdate = - shouldSubscribeToGlobal && - subscribeCallback(key, STATE_UPDATERS, onStateUpdate) - const unsubEvents = - shouldSubscribeToGlobal && - subscribeCallback(key, EVENT_REVALIDATORS, onRevalidate) + const unsubUpdate = subscribeCallback(key, STATE_UPDATERS, onStateUpdate) + const unsubEvents = subscribeCallback(key, EVENT_REVALIDATORS, onRevalidate) // Mark the component as mounted and update corresponding refs. unmountedRef.current = false @@ -432,8 +421,8 @@ export const useSWRHandler = ( // Mark it as unmounted. unmountedRef.current = true - unsubUpdate && unsubUpdate() - unsubEvents && unsubEvents() + unsubUpdate() + unsubEvents() } }, [key, revalidate]) diff --git a/src/utils/broadcast-state.ts b/src/utils/broadcast-state.ts index 7a361dd47..58adb0966 100644 --- a/src/utils/broadcast-state.ts +++ b/src/utils/broadcast-state.ts @@ -8,7 +8,8 @@ export const broadcastState: Broadcaster = ( data, error, isValidating, - shouldRevalidate = false + shouldRevalidate = false, + populateCache = true ) => { const [EVENT_REVALIDATORS, STATE_UPDATERS] = SWRGlobalState.get( cache @@ -16,8 +17,8 @@ export const broadcastState: Broadcaster = ( const revalidators = EVENT_REVALIDATORS[key] const updaters = STATE_UPDATERS[key] - // Always update states of all hooks. - if (updaters) { + // Cache was populated, update states of all hooks. + if (populateCache && updaters) { for (let i = 0; i < updaters.length; ++i) { updaters[i](data, error, isValidating) } diff --git a/src/utils/mutate.ts b/src/utils/mutate.ts index a87014ef2..9dec923d1 100644 --- a/src/utils/mutate.ts +++ b/src/utils/mutate.ts @@ -4,14 +4,19 @@ import { SWRGlobalState, GlobalState } from './global-state' import { broadcastState } from './broadcast-state' import { getTimestamp } from './timestamp' -import { Key, Cache, MutatorCallback } from '../types' +import { Key, Cache, MutatorCallback, MutatorConfig } from '../types' export const internalMutate = async ( cache: Cache, _key: Key, _data?: Data | Promise | MutatorCallback, - shouldRevalidate = true + opts?: boolean | MutatorConfig ) => { + const options: MutatorConfig = + typeof opts === 'boolean' ? { revalidate: opts } : {} + const revalidate = options.revalidate !== false + const populateCache = options.populateCache !== false + const [key, , keyErr] = serialize(_key) if (!key) return @@ -19,7 +24,7 @@ export const internalMutate = async ( cache ) as GlobalState - // if there is no new data to update, let's just revalidate the key + // If there is no new data to update, let's just revalidate the key if (isUndefined(_data)) { return broadcastState( cache, @@ -27,7 +32,8 @@ export const internalMutate = async ( cache.get(key), cache.get(keyErr), UNDEFINED, - shouldRevalidate + revalidate, + populateCache ) } @@ -69,12 +75,14 @@ export const internalMutate = async ( data = _data } - if (!isUndefined(data)) { - // update cached data - cache.set(key, data) + if (populateCache) { + if (!isUndefined(data)) { + // update cached data + cache.set(key, data) + } + // Always update or reset the error. + cache.set(keyErr, error) } - // Always update or reset the error. - cache.set(keyErr, error) // Reset the timestamp to mark the mutation has ended MUTATION_END_TS[key] = getTimestamp() @@ -86,7 +94,8 @@ export const internalMutate = async ( data, error, UNDEFINED, - shouldRevalidate + revalidate, + populateCache ) // Throw error or return data diff --git a/src/utils/state.ts b/src/utils/state.ts index 9e724f1ff..370b5339b 100644 --- a/src/utils/state.ts +++ b/src/utils/state.ts @@ -2,7 +2,6 @@ import { useRef, useCallback, useState, MutableRefObject } from 'react' import { useIsomorphicLayoutEffect } from './env' import { State } from '../types' -import { noop } from '../utils/helper' type StateKeys = keyof State type StateDeps = Record @@ -13,7 +12,8 @@ type StateDeps = Record export const useStateWithDeps = >( state: S ): [MutableRefObject, Record, (payload: S) => void] => { - let rerender = useState>({})[1] + 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 @@ -64,7 +64,7 @@ export const useStateWithDeps = >( } } - if (shouldRerender) { + if (shouldRerender && !unmountedRef.current) { rerender({}) } }, @@ -76,9 +76,9 @@ export const useStateWithDeps = >( // Always update the state reference. useIsomorphicLayoutEffect(() => { stateRef.current = state + unmountedRef.current = false return () => { - // When unmounting, set the `rerender` function to a no-op. - rerender = noop + unmountedRef.current = true } }) diff --git a/test/use-swr-immutable.test.tsx b/test/use-swr-immutable.test.tsx index 2f462ca44..b57aec46c 100644 --- a/test/use-swr-immutable.test.tsx +++ b/test/use-swr-immutable.test.tsx @@ -140,10 +140,7 @@ describe('useSWR - immutable', () => { }) it('should not revalidate with revalidateIfStale disabled when key changes', async () => { - const fetcher = jest.fn(v => { - console.log(v) - return v - }) + const fetcher = jest.fn(v => v) const key = createKey() const useData = (id: string) => From 8383f035d2bdd5b3eb921b82a4cfa71abbc5a83c Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Sun, 12 Sep 2021 15:03:52 +0200 Subject: [PATCH 03/25] (wip) fix deps --- immutable/index.ts | 4 +-- jest.config.js | 7 ++--- mutation/index.ts | 50 ++++++++++++++++++++++++++---------- package.json | 8 +++--- src/utils/broadcast-state.ts | 2 +- 5 files changed, 48 insertions(+), 23 deletions(-) diff --git a/immutable/index.ts b/immutable/index.ts index 24c0030bd..a73b9516c 100644 --- a/immutable/index.ts +++ b/immutable/index.ts @@ -1,4 +1,4 @@ -import useSWR, { Middleware, SWRHook } from 'swr' +import useSWR, { Middleware } from 'swr' import { withMiddleware } from '../src/utils/with-middleware' export const immutable: Middleware = useSWRNext => (key, fetcher, config) => { @@ -9,4 +9,4 @@ export const immutable: Middleware = useSWRNext => (key, fetcher, config) => { return useSWRNext(key, fetcher, config) } -export default withMiddleware(useSWR as SWRHook, immutable) +export default withMiddleware(useSWR, immutable) diff --git a/jest.config.js b/jest.config.js index fbe8209c4..3fbc1d108 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,12 +7,13 @@ 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' }, globals: { 'ts-jest': { tsconfig: 'test/tsconfig.json', - diagnostics: false, + diagnostics: false } - }, + } } diff --git a/mutation/index.ts b/mutation/index.ts index 107815a33..6fdb11fb2 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -5,24 +5,46 @@ import { serialize } from '../src/utils/serialize' import { useStateWithDeps } from '../src/utils/state' import { withMiddleware } from '../src/utils/with-middleware' -export const mutation: Middleware = () => (key, fetcher, config) => { +export const mutation: Middleware = () => ( + key, + fetcher, + config +) => { const [serializedKey, args] = serialize(key) const argsRef = useRef(args) const { mutate } = useSWRConfig() - const [stateRef, stateDependencies, setState] = useStateWithDeps({ - data: undefined, - error: undefined, - isMutating: false - }) + const [stateRef, stateDependencies, setState] = useStateWithDeps( + { + data: undefined, + error: undefined, + isValidating: false + } + ) - const trigger = useCallback((extraArg, shouldRevalidate, opts) => { + const trigger = useCallback(async (extraArg, shouldRevalidate, opts) => { // Trigger a mutation. // Assign extra arguments to the ref, so the fetcher can access them later. - return mutate(serializedKey, () => fetcher(...argsRef.current, extraArg), { - revalidate: shouldRevalidate, - populateCache: false - }) + try { + setState({ isValidating: true }) + const data = await mutate( + serializedKey, + () => fetcher(...argsRef.current, extraArg), + { + revalidate: shouldRevalidate, + populateCache: false + } + ) + setState({ data, isValidating: false }) + return data + } catch (error) { + setState({ error, isValidating: false }) + throw error + } + }, []) + + const reset = useCallback(() => { + setState({ data: undefined, error: undefined, isValidating: false }) }, []) return { @@ -36,9 +58,9 @@ export const mutation: Middleware = () => (key, fetcher, config) => { stateDependencies.error = true return stateRef.current.error }, - get isValidating() { - ;(stateDependencies as any).isMutating = true - return stateRef.current.isMutating + get isMutating() { + ;(stateDependencies as any).isValidating = true + return stateRef.current.isValidating } } } diff --git a/package.json b/package.json index bec201b85..737ba547b 100644 --- a/package.json +++ b/package.json @@ -42,15 +42,17 @@ "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": "yarn run-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": "yarn run-p watch:core watch:infinite watch:immutable watch:mutation", "watch:core": "bunchee src/index.ts --watch", "watch:infinite": "bunchee index.ts --cwd infinite --watch", "watch:immutable": "bunchee index.ts --cwd immutable --watch", + "watch:mutation": "bunchee index.ts --cwd mutation --watch", "build:core": "bunchee src/index.ts -m --no-sourcemap", "build:infinite": "bunchee index.ts --cwd infinite -m --no-sourcemap", "build:immutable": "bunchee index.ts --cwd immutable -m --no-sourcemap", + "build:mutation": "bunchee index.ts --cwd mutation -m --no-sourcemap", "prepublishOnly": "yarn clean && yarn build", "publish-beta": "yarn publish --tag beta", "types:check": "tsc --noEmit", diff --git a/src/utils/broadcast-state.ts b/src/utils/broadcast-state.ts index 58adb0966..956980bea 100644 --- a/src/utils/broadcast-state.ts +++ b/src/utils/broadcast-state.ts @@ -8,7 +8,7 @@ export const broadcastState: Broadcaster = ( data, error, isValidating, - shouldRevalidate = false, + shouldRevalidate, populateCache = true ) => { const [EVENT_REVALIDATORS, STATE_UPDATERS] = SWRGlobalState.get( From ac87cd03b3236b91edbdfe39a60424bf5d720c18 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Sun, 12 Sep 2021 16:49:53 +0200 Subject: [PATCH 04/25] initial implementation --- mutation/index.ts | 59 +++- mutation/types.ts | 18 + scripts/next-config.js | 7 +- scripts/require-hook.js | 5 +- src/types.ts | 8 +- src/utils/config.ts | 3 - src/utils/mutate.ts | 4 +- test/use-swr-remote-mutation.test.tsx | 479 ++++++++++++++++++++++++++ 8 files changed, 554 insertions(+), 29 deletions(-) create mode 100644 mutation/types.ts create mode 100644 test/use-swr-remote-mutation.test.tsx diff --git a/mutation/index.ts b/mutation/index.ts index 6fdb11fb2..6f019cd22 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -1,17 +1,20 @@ import { useCallback, useRef } from 'react' -import useSWR, { Middleware, SWRHook, useSWRConfig } from 'swr' +import useSWR, { Middleware, Key, Fetcher, useSWRConfig } 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 { isUndefined } from '../src/utils/helper' -export const mutation: Middleware = () => ( - key, - fetcher, - config +import { SWRMutationConfiguration, SWRMutationResponse } from './types' + +const mutation = () => ( + key: Key, + fetcher: Fetcher, + config: SWRMutationConfiguration = {} ) => { - const [serializedKey, args] = serialize(key) - const argsRef = useRef(args) + const keyRef = useRef(key) const { mutate } = useSWRConfig() const [stateRef, stateDependencies, setState] = useStateWithDeps( @@ -22,23 +25,33 @@ export const mutation: Middleware = () => ( } ) - const trigger = useCallback(async (extraArg, shouldRevalidate, opts) => { + const trigger = useCallback(async (extraArg, opts) => { + if (!fetcher) + throw new Error('Can’t trigger the mutation: missing fetcher.') + + const [serializedKey, args] = serialize(keyRef.current) + const options = Object.assign({}, config, opts) + + // Disable cache population by default. + if (isUndefined(options.populateCache)) { + options.populateCache = false + } + // Trigger a mutation. // Assign extra arguments to the ref, so the fetcher can access them later. try { setState({ isValidating: true }) const data = await mutate( serializedKey, - () => fetcher(...argsRef.current, extraArg), - { - revalidate: shouldRevalidate, - populateCache: false - } + () => fetcher(...args, extraArg), + options ) setState({ data, isValidating: false }) + options.onSuccess && options.onSuccess(data, serializedKey) return data } catch (error) { setState({ error, isValidating: false }) + options.onError && options.onError(error, serializedKey) throw error } }, []) @@ -47,9 +60,14 @@ export const mutation: Middleware = () => ( setState({ data: undefined, error: undefined, isValidating: false }) }, []) + useIsomorphicLayoutEffect(() => { + keyRef.current = key + }) + return { mutate, trigger, + reset, get data() { stateDependencies.data = true return stateRef.current.data @@ -62,7 +80,18 @@ export const mutation: Middleware = () => ( ;(stateDependencies as any).isValidating = true return stateRef.current.isValidating } - } + } as SWRMutationResponse } -export default withMiddleware(useSWR as SWRHook, mutation) +type SWRMutationHook = ( + ...args: + | readonly [Key, Fetcher] + | readonly [Key, Fetcher, SWRMutationConfiguration] +) => SWRMutationResponse + +export default (withMiddleware( + useSWR, + (mutation as unknown) as Middleware +) as unknown) as SWRMutationHook + +export { SWRMutationConfiguration, SWRMutationResponse } diff --git a/mutation/types.ts b/mutation/types.ts new file mode 100644 index 000000000..50ba600a9 --- /dev/null +++ b/mutation/types.ts @@ -0,0 +1,18 @@ +import { SWRResponse } from 'swr' + +export type SWRMutationConfiguration = { + revalidate?: boolean + populateCache?: boolean + onSuccess?: (data: Data, key: string) => void + onError?: (error: Error, key: string) => void +} + +export interface SWRMutationResponse + extends Omit, 'isValidating'> { + isMutating: boolean + trigger: ( + extraArgument?: any, + options?: SWRMutationConfiguration + ) => Promise + reset: () => void +} diff --git a/scripts/next-config.js b/scripts/next-config.js index d67aae30b..51538cc9b 100644 --- a/scripts/next-config.js +++ b/scripts/next-config.js @@ -7,15 +7,16 @@ module.exports = { // FIXME: resolving react/jsx-runtime https://github.com/facebook/react/issues/20235 alias['react/jsx-dev-runtime'] = require.resolve('react/jsx-dev-runtime.js') alias['react/jsx-runtime'] = require.resolve('react/jsx-runtime.js') - + alias['swr'] = resolve(__dirname, '../dist/index.js') alias['swr/infinite'] = resolve(__dirname, '../infinite/dist/index.js') alias['swr/immutable'] = resolve(__dirname, '../immutable/dist/index.js') + alias['swr/mutation'] = resolve(__dirname, '../mutation/dist/index.js') alias['react'] = require.resolve('react') alias['react-dom'] = require.resolve('react-dom') alias['react-dom/server'] = require.resolve('react-dom/server') return config - }, -} \ No newline at end of file + } +} diff --git a/scripts/require-hook.js b/scripts/require-hook.js index a0b7b1876..1ff2ce376 100644 --- a/scripts/require-hook.js +++ b/scripts/require-hook.js @@ -8,12 +8,13 @@ const hookPropertyMap = new Map([ ['swr', resolve(rootDir, 'dist/index.js')], ['swr/infinite', resolve(rootDir, 'infinite/dist/index.js')], ['swr/immutable', resolve(rootDir, 'immutable/dist/index.js')], + ['swr/mutation', resolve(rootDir, 'mutation/dist/index.js')], ['react', resolve(nodeModulesDir, 'react')], - ['react-dom', resolve(nodeModulesDir, 'react-dom')], + ['react-dom', resolve(nodeModulesDir, 'react-dom')] ]) const resolveFilename = mod._resolveFilename -mod._resolveFilename = function (request, ...args) { +mod._resolveFilename = function(request, ...args) { const hookResolved = hookPropertyMap.get(request) if (hookResolved) request = hookResolved return resolveFilename.call(mod, request, ...args) diff --git a/src/types.ts b/src/types.ts index 67a6bd931..68da29dd3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -120,7 +120,7 @@ export type Mutator = ( cache: Cache, key: Key, data?: Data | Promise | MutatorCallback, - shouldRevalidate?: boolean + opts?: boolean | MutatorConfig ) => Promise export interface ScopedMutator { @@ -128,19 +128,19 @@ export interface ScopedMutator { ( key: Key, data?: Data | Promise | MutatorCallback, - shouldRevalidate?: boolean + opts?: boolean | MutatorConfig ): Promise /** This is used for global mutator */ ( key: Key, data?: T | Promise | MutatorCallback, - shouldRevalidate?: boolean + opts?: boolean | MutatorConfig ): Promise } export type KeyedMutator = ( data?: Data | Promise | MutatorCallback, - shouldRevalidate?: boolean + opts?: boolean | MutatorConfig ) => Promise // Public types diff --git a/src/utils/config.ts b/src/utils/config.ts index 3c28d0449..f6c074aa5 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -65,9 +65,6 @@ export const defaultConfig: FullConfiguration = mergeObjects( revalidateIfStale: true, shouldRetryOnError: true, - // cache strategy - populateCache: true, - // timeouts errorRetryInterval: slowConnection ? 10000 : 5000, focusThrottleInterval: 5 * 1000, diff --git a/src/utils/mutate.ts b/src/utils/mutate.ts index 9dec923d1..3cffef259 100644 --- a/src/utils/mutate.ts +++ b/src/utils/mutate.ts @@ -13,7 +13,7 @@ export const internalMutate = async ( opts?: boolean | MutatorConfig ) => { const options: MutatorConfig = - typeof opts === 'boolean' ? { revalidate: opts } : {} + typeof opts === 'boolean' ? { revalidate: opts } : opts || {} const revalidate = options.revalidate !== false const populateCache = options.populateCache !== false @@ -100,5 +100,5 @@ export const internalMutate = async ( // Throw error or return data if (error) throw error - return res + return populateCache ? res : data } diff --git a/test/use-swr-remote-mutation.test.tsx b/test/use-swr-remote-mutation.test.tsx new file mode 100644 index 000000000..33e501cba --- /dev/null +++ b/test/use-swr-remote-mutation.test.tsx @@ -0,0 +1,479 @@ +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 } 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 trigger request with correct args', 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', '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 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 (_, 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 not trigger request when mutating', async () => { + const key = createKey() + const fn = jest.fn(() => 'data') + + function Page() { + const { mutate } = useSWRMutation(key, fn) + return ( +
+ +
+ ) + } + + render() + + // mount + await screen.findByText('mutate') + + fireEvent.click(screen.getByText('mutate')) + expect(fn).not.toHaveBeenCalled() + }) + + 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') + }) +}) From 4a8adbf797f54abf2958c311a634e5fedcac9041 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Sun, 12 Sep 2021 22:28:41 +0200 Subject: [PATCH 05/25] fix linter --- mutation/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mutation/index.ts b/mutation/index.ts index 6f019cd22..5d0e82602 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -77,7 +77,7 @@ const mutation = () => ( return stateRef.current.error }, get isMutating() { - ;(stateDependencies as any).isValidating = true + stateDependencies.isValidating = true return stateRef.current.isValidating } } as SWRMutationResponse From 4f9d67d606b1775e173b654d3327cbd3690cf3c6 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Sun, 12 Sep 2021 23:29:45 +0200 Subject: [PATCH 06/25] fix state reset --- mutation/index.ts | 12 +++++----- mutation/types.ts | 9 ++++---- src/utils/state.ts | 10 ++++++--- test/use-swr-remote-mutation.test.tsx | 32 +++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/mutation/index.ts b/mutation/index.ts index 5d0e82602..3a9d94007 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -5,7 +5,7 @@ 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 { isUndefined } from '../src/utils/helper' +import { isUndefined, UNDEFINED } from '../src/utils/helper' import { SWRMutationConfiguration, SWRMutationResponse } from './types' @@ -22,7 +22,8 @@ const mutation = () => ( data: undefined, error: undefined, isValidating: false - } + }, + true ) const trigger = useCallback(async (extraArg, opts) => { @@ -41,17 +42,18 @@ const mutation = () => ( // Assign extra arguments to the ref, so the fetcher can access them later. try { setState({ isValidating: true }) + args.push(extraArg) const data = await mutate( serializedKey, - () => fetcher(...args, extraArg), + fetcher.apply(UNDEFINED, args), options ) setState({ data, isValidating: false }) - options.onSuccess && options.onSuccess(data, serializedKey) + options.onSuccess && options.onSuccess(data, serializedKey, options) return data } catch (error) { setState({ error, isValidating: false }) - options.onError && options.onError(error, serializedKey) + options.onError && options.onError(error, serializedKey, options) throw error } }, []) diff --git a/mutation/types.ts b/mutation/types.ts index 50ba600a9..2dd28a78f 100644 --- a/mutation/types.ts +++ b/mutation/types.ts @@ -1,10 +1,11 @@ -import { SWRResponse } from 'swr' +import { SWRResponse, SWRConfiguration, Fetcher } from 'swr' -export type SWRMutationConfiguration = { +export type SWRMutationConfiguration = Pick< + SWRConfiguration>, + 'fetcher' | 'onSuccess' | 'onError' +> & { revalidate?: boolean populateCache?: boolean - onSuccess?: (data: Data, key: string) => void - onError?: (error: Error, key: string) => void } export interface SWRMutationResponse diff --git a/src/utils/state.ts b/src/utils/state.ts index 370b5339b..6be5dda39 100644 --- a/src/utils/state.ts +++ b/src/utils/state.ts @@ -10,7 +10,8 @@ type StateDeps = Record * An implementation of state with dependency-tracking. */ export const useStateWithDeps = >( - state: S + state: S, + asInitialState?: boolean ): [MutableRefObject, Record, (payload: S) => void] => { const rerender = useState>({})[1] const unmountedRef = useRef(false) @@ -73,9 +74,12 @@ export const useStateWithDeps = >( [] ) - // Always update the state reference. useIsomorphicLayoutEffect(() => { - stateRef.current = state + // Always update the state reference. + if (!asInitialState) { + stateRef.current = state + } + unmountedRef.current = false return () => { unmountedRef.current = true diff --git a/test/use-swr-remote-mutation.test.tsx b/test/use-swr-remote-mutation.test.tsx index 33e501cba..8431d48ad 100644 --- a/test/use-swr-remote-mutation.test.tsx +++ b/test/use-swr-remote-mutation.test.tsx @@ -476,4 +476,36 @@ describe('useSWR - remote mutation', () => { // A revalidation is triggered 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') + }) }) From b57bf8a9260cf9a1aec1bc82223500f8267e8275 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 13 Sep 2021 15:51:54 +0200 Subject: [PATCH 07/25] avoid reset race condition --- mutation/index.ts | 18 ++++-- test/use-swr-remote-mutation.test.tsx | 85 ++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/mutation/index.ts b/mutation/index.ts index 3a9d94007..3e3f0b57d 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -6,6 +6,7 @@ import { useStateWithDeps } from '../src/utils/state' import { withMiddleware } from '../src/utils/with-middleware' import { useIsomorphicLayoutEffect } from '../src/utils/env' import { isUndefined, UNDEFINED } from '../src/utils/helper' +import { getTimestamp } from '../src/utils/timestamp' import { SWRMutationConfiguration, SWRMutationResponse } from './types' @@ -15,6 +16,7 @@ const mutation = () => ( config: SWRMutationConfiguration = {} ) => { const keyRef = useRef(key) + const resetStartedRef = useRef(0) const { mutate } = useSWRConfig() const [stateRef, stateDependencies, setState] = useStateWithDeps( @@ -40,6 +42,7 @@ const mutation = () => ( // Trigger a mutation. // Assign extra arguments to the ref, so the fetcher can access them later. + const mutationStartedAt = getTimestamp() try { setState({ isValidating: true }) args.push(extraArg) @@ -48,17 +51,24 @@ const mutation = () => ( fetcher.apply(UNDEFINED, args), options ) - setState({ data, isValidating: false }) - options.onSuccess && options.onSuccess(data, serializedKey, options) + // If it's reset after the mutation, we don't broadcast any state change. + if (resetStartedRef.current < mutationStartedAt) { + setState({ data, isValidating: false }) + options.onSuccess && options.onSuccess(data, serializedKey, options) + } return data } catch (error) { - setState({ error, isValidating: false }) - options.onError && options.onError(error, serializedKey, options) + // If it's reset after the mutation, we don't broadcast any state change. + if (resetStartedRef.current < mutationStartedAt) { + setState({ error, isValidating: false }) + options.onError && options.onError(error, serializedKey, options) + } throw error } }, []) const reset = useCallback(() => { + resetStartedRef.current = getTimestamp() setState({ data: undefined, error: undefined, isValidating: false }) }, []) diff --git a/test/use-swr-remote-mutation.test.tsx b/test/use-swr-remote-mutation.test.tsx index 8431d48ad..202b470ea 100644 --- a/test/use-swr-remote-mutation.test.tsx +++ b/test/use-swr-remote-mutation.test.tsx @@ -2,7 +2,7 @@ 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 } from './utils' +import { createKey, sleep, nextTick } from './utils' const waitForNextTick = () => act(() => sleep(1)) @@ -77,6 +77,32 @@ describe('useSWR - remote mutation', () => { 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() @@ -508,4 +534,61 @@ describe('useSWR - remote mutation', () => { 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 sleep(30) + await screen.findByText('data:none') + }) + + 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() + }) }) From 016cb475773018923e00decb5023a6793bffac8d Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 13 Sep 2021 16:02:57 +0200 Subject: [PATCH 08/25] fix race conditions --- mutation/index.ts | 18 ++++++++------ test/use-swr-remote-mutation.test.tsx | 35 ++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/mutation/index.ts b/mutation/index.ts index 3e3f0b57d..8c50d11d9 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -15,10 +15,12 @@ const mutation = () => ( fetcher: Fetcher, config: SWRMutationConfiguration = {} ) => { - const keyRef = useRef(key) - const resetStartedRef = useRef(0) const { mutate } = useSWRConfig() + const keyRef = useRef(key) + // Ditch all mutation results that happened earlier than this timestamp. + const ditchMutationsTilRef = useRef(0) + const [stateRef, stateDependencies, setState] = useStateWithDeps( { data: undefined, @@ -40,9 +42,11 @@ const mutation = () => ( options.populateCache = false } - // Trigger a mutation. - // Assign extra arguments to the ref, so the fetcher can access them later. + // Trigger a mutation, also track the timestamp. Any mutation that happened + // earlier this timestamp should be ignored. const mutationStartedAt = getTimestamp() + ditchMutationsTilRef.current = mutationStartedAt + try { setState({ isValidating: true }) args.push(extraArg) @@ -52,14 +56,14 @@ const mutation = () => ( options ) // If it's reset after the mutation, we don't broadcast any state change. - if (resetStartedRef.current < mutationStartedAt) { + if (ditchMutationsTilRef.current <= mutationStartedAt) { setState({ data, isValidating: false }) options.onSuccess && options.onSuccess(data, serializedKey, options) } return data } catch (error) { // If it's reset after the mutation, we don't broadcast any state change. - if (resetStartedRef.current < mutationStartedAt) { + if (ditchMutationsTilRef.current <= mutationStartedAt) { setState({ error, isValidating: false }) options.onError && options.onError(error, serializedKey, options) } @@ -68,7 +72,7 @@ const mutation = () => ( }, []) const reset = useCallback(() => { - resetStartedRef.current = getTimestamp() + ditchMutationsTilRef.current = getTimestamp() setState({ data: undefined, error: undefined, isValidating: false }) }, []) diff --git a/test/use-swr-remote-mutation.test.tsx b/test/use-swr-remote-mutation.test.tsx index 202b470ea..815a1d0a3 100644 --- a/test/use-swr-remote-mutation.test.tsx +++ b/test/use-swr-remote-mutation.test.tsx @@ -567,10 +567,43 @@ describe('useSWR - remote mutation', () => { // reset, before it ends fireEvent.click(screen.getByText('reset')) - await sleep(30) + 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() From 8d5c28fb38fae759ccf0a0e060828ba1eea395fb Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 13 Sep 2021 16:21:43 +0200 Subject: [PATCH 09/25] code tweaks --- mutation/index.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/mutation/index.ts b/mutation/index.ts index 8c50d11d9..1d6db69cb 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -10,6 +10,12 @@ import { getTimestamp } from '../src/utils/timestamp' import { SWRMutationConfiguration, SWRMutationResponse } from './types' +const DEFAULT_STATE = { + data: UNDEFINED, + error: UNDEFINED, + isValidating: false +} + const mutation = () => ( key: Key, fetcher: Fetcher, @@ -22,17 +28,14 @@ const mutation = () => ( const ditchMutationsTilRef = useRef(0) const [stateRef, stateDependencies, setState] = useStateWithDeps( - { - data: undefined, - error: undefined, - isValidating: false - }, + DEFAULT_STATE, true ) const trigger = useCallback(async (extraArg, opts) => { - if (!fetcher) + if (!fetcher) { throw new Error('Can’t trigger the mutation: missing fetcher.') + } const [serializedKey, args] = serialize(keyRef.current) const options = Object.assign({}, config, opts) @@ -73,7 +76,7 @@ const mutation = () => ( const reset = useCallback(() => { ditchMutationsTilRef.current = getTimestamp() - setState({ data: undefined, error: undefined, isValidating: false }) + setState(DEFAULT_STATE) }, []) useIsomorphicLayoutEffect(() => { From 3557505092866ebb7a75bb4efc036f7f125e46a3 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 13 Sep 2021 16:29:44 +0200 Subject: [PATCH 10/25] code tweaks --- mutation/index.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/mutation/index.ts b/mutation/index.ts index 1d6db69cb..38c8e9936 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -10,12 +10,6 @@ import { getTimestamp } from '../src/utils/timestamp' import { SWRMutationConfiguration, SWRMutationResponse } from './types' -const DEFAULT_STATE = { - data: UNDEFINED, - error: UNDEFINED, - isValidating: false -} - const mutation = () => ( key: Key, fetcher: Fetcher, @@ -28,9 +22,14 @@ const mutation = () => ( const ditchMutationsTilRef = useRef(0) const [stateRef, stateDependencies, setState] = useStateWithDeps( - DEFAULT_STATE, + { + data: UNDEFINED, + error: UNDEFINED, + isValidating: false + }, true ) + const currentState = stateRef.current const trigger = useCallback(async (extraArg, opts) => { if (!fetcher) { @@ -76,7 +75,7 @@ const mutation = () => ( const reset = useCallback(() => { ditchMutationsTilRef.current = getTimestamp() - setState(DEFAULT_STATE) + setState({ data: UNDEFINED, error: UNDEFINED, isValidating: false }) }, []) useIsomorphicLayoutEffect(() => { @@ -89,15 +88,15 @@ const mutation = () => ( reset, get data() { stateDependencies.data = true - return stateRef.current.data + return currentState.data }, get error() { stateDependencies.error = true - return stateRef.current.error + return currentState.error }, get isMutating() { stateDependencies.isValidating = true - return stateRef.current.isValidating + return currentState.isValidating } } as SWRMutationResponse } From e485ae6249daa2ff7afac933aa6a3128707f4c08 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Wed, 15 Sep 2021 13:40:48 +0200 Subject: [PATCH 11/25] return bound mutate --- mutation/index.ts | 21 +++++++++++++++++-- test/use-swr-remote-mutation.test.tsx | 30 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/mutation/index.ts b/mutation/index.ts index 38c8e9936..75c00dcec 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -1,5 +1,11 @@ import { useCallback, useRef } from 'react' -import useSWR, { Middleware, Key, Fetcher, useSWRConfig } from 'swr' +import useSWR, { + useSWRConfig, + Middleware, + Key, + Fetcher, + SWRResponse +} from 'swr' import { serialize } from '../src/utils/serialize' import { useStateWithDeps } from '../src/utils/state' @@ -31,6 +37,17 @@ const mutation = () => ( ) const currentState = stateRef.current + // Similar to the global mutate, but bound to the current cache and key. + // `cache` isn't allowed to change during the lifecycle. + const boundMutate: SWRResponse['mutate'] = useCallback( + (newData, shouldRevalidate) => { + const serializedKey = serialize(keyRef.current)[0] + return mutate(serializedKey, newData, shouldRevalidate) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + const trigger = useCallback(async (extraArg, opts) => { if (!fetcher) { throw new Error('Can’t trigger the mutation: missing fetcher.') @@ -83,7 +100,7 @@ const mutation = () => ( }) return { - mutate, + mutate: boundMutate, trigger, reset, get data() { diff --git a/test/use-swr-remote-mutation.test.tsx b/test/use-swr-remote-mutation.test.tsx index 815a1d0a3..522a65144 100644 --- a/test/use-swr-remote-mutation.test.tsx +++ b/test/use-swr-remote-mutation.test.tsx @@ -624,4 +624,34 @@ describe('useSWR - remote mutation', () => { await nextTick() expect(catchError).toBeCalled() }) + + it('should return the bound mutate', async () => { + const key = createKey() + + function Page() { + const { data } = useSWR(key, async () => { + await sleep(10) + return 'stale' + }) + const { trigger, mutate } = useSWRMutation(key, () => 'new') + + return ( +
+ + {data || 'none'} +
+ ) + } + + render() + + await screen.findByText('none') + // Initial result + await screen.findByText('stale') + fireEvent.click(screen.getByText('request')) + // Mutate + await screen.findByText('new') + // Revalidate + await screen.findByText('stale') + }) }) From d4a5ad33cfffca9a816f552ea6fec9f2d4bad727 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Thu, 23 Sep 2021 23:53:04 +0200 Subject: [PATCH 12/25] apply review comments --- mutation/index.ts | 9 +++------ src/utils/state.ts | 3 ++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/mutation/index.ts b/mutation/index.ts index 75c00dcec..4408bd615 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -11,7 +11,7 @@ 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 { isUndefined, UNDEFINED } from '../src/utils/helper' +import { UNDEFINED } from '../src/utils/helper' import { getTimestamp } from '../src/utils/timestamp' import { SWRMutationConfiguration, SWRMutationResponse } from './types' @@ -54,12 +54,9 @@ const mutation = () => ( } const [serializedKey, args] = serialize(keyRef.current) - const options = Object.assign({}, config, opts) // Disable cache population by default. - if (isUndefined(options.populateCache)) { - options.populateCache = false - } + 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. @@ -83,7 +80,7 @@ const mutation = () => ( } catch (error) { // If it's reset after the mutation, we don't broadcast any state change. if (ditchMutationsTilRef.current <= mutationStartedAt) { - setState({ error, isValidating: false }) + setState({ error: error as Error, isValidating: false }) options.onError && options.onError(error, serializedKey, options) } throw error diff --git a/src/utils/state.ts b/src/utils/state.ts index 6be5dda39..579a1981e 100644 --- a/src/utils/state.ts +++ b/src/utils/state.ts @@ -75,7 +75,8 @@ export const useStateWithDeps = >( ) useIsomorphicLayoutEffect(() => { - // Always update the state reference. + // Always update the state reference if it's not used as the initial state + // only. if (!asInitialState) { stateRef.current = state } From c3b621f1073485af7e198777623cb9c7cb9ee09c Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 4 Oct 2021 01:09:34 +0200 Subject: [PATCH 13/25] fix tsconfig --- tsconfig.check.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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"] } From e481922c08740180c9c89b5a9bbeec4e3da79690 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 4 Oct 2021 02:27:27 +0200 Subject: [PATCH 14/25] type fixes --- mutation/index.ts | 34 +++++++------------- mutation/types.ts | 76 ++++++++++++++++++++++++++++++++++++++++---- test/type/trigger.ts | 30 +++++++++++++++++ 3 files changed, 111 insertions(+), 29 deletions(-) create mode 100644 test/type/trigger.ts diff --git a/mutation/index.ts b/mutation/index.ts index 4408bd615..6a8ee5d0d 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -1,11 +1,5 @@ import { useCallback, useRef } from 'react' -import useSWR, { - useSWRConfig, - Middleware, - Key, - Fetcher, - SWRResponse -} from 'swr' +import useSWR, { useSWRConfig, Middleware, Key } from 'swr' import { serialize } from '../src/utils/serialize' import { useStateWithDeps } from '../src/utils/state' @@ -14,11 +8,16 @@ import { useIsomorphicLayoutEffect } from '../src/utils/env' import { UNDEFINED } from '../src/utils/helper' import { getTimestamp } from '../src/utils/timestamp' -import { SWRMutationConfiguration, SWRMutationResponse } from './types' +import { + SWRMutationConfiguration, + SWRMutationResponse, + SWRMutationHook, + MutationFetcher +} from './types' const mutation = () => ( key: Key, - fetcher: Fetcher, + fetcher: MutationFetcher, config: SWRMutationConfiguration = {} ) => { const { mutate } = useSWRConfig() @@ -39,7 +38,7 @@ const mutation = () => ( // Similar to the global mutate, but bound to the current cache and key. // `cache` isn't allowed to change during the lifecycle. - const boundMutate: SWRResponse['mutate'] = useCallback( + const boundMutate = useCallback( (newData, shouldRevalidate) => { const serializedKey = serialize(keyRef.current)[0] return mutate(serializedKey, newData, shouldRevalidate) @@ -66,11 +65,8 @@ const mutation = () => ( try { setState({ isValidating: true }) args.push(extraArg) - const data = await mutate( - serializedKey, - fetcher.apply(UNDEFINED, args), - options - ) + const data = await mutate(serializedKey, fetcher(...args), options) + // If it's reset after the mutation, we don't broadcast any state change. if (ditchMutationsTilRef.current <= mutationStartedAt) { setState({ data, isValidating: false }) @@ -112,15 +108,9 @@ const mutation = () => ( stateDependencies.isValidating = true return currentState.isValidating } - } as SWRMutationResponse + } } -type SWRMutationHook = ( - ...args: - | readonly [Key, Fetcher] - | readonly [Key, Fetcher, SWRMutationConfiguration] -) => SWRMutationResponse - export default (withMiddleware( useSWR, (mutation as unknown) as Middleware diff --git a/mutation/types.ts b/mutation/types.ts index 2dd28a78f..3743787ab 100644 --- a/mutation/types.ts +++ b/mutation/types.ts @@ -1,19 +1,81 @@ -import { SWRResponse, SWRConfiguration, Fetcher } from 'swr' +import { SWRResponse, SWRConfiguration, Key } from 'swr' -export type SWRMutationConfiguration = Pick< - SWRConfiguration>, +type Async = Data | Promise + +export type MutationFetcher< + Data = unknown, + SWRKey extends Key = Key, + ExtraArg = any +> = + /** + * () => [{ foo: string }, { bar: number }] | null + * () => ( [{ foo: string }, { bar: number } ] as const | null ) + */ + SWRKey extends (() => readonly [...infer Args] | null) + ? ((...args: [...Args, ExtraArg]) => Async) + : /** + * [{ foo: string }, { bar: number } ] | null + * [{ foo: string }, { bar: number } ] as const | null + */ + SWRKey extends (readonly [...infer Args]) + ? ((...args: [...Args, ExtraArg]) => Async) + : /** + * () => string | null + * () => Record | null + */ + SWRKey extends (() => infer Arg | null) + ? (...args: [Arg, ExtraArg]) => Async + : /** + * string | null | Record + */ + SWRKey extends null + ? never + : SWRKey extends (infer Arg) + ? (...args: [Arg, ExtraArg]) => Async + : never + +export type SWRMutationConfiguration< + Data, + Error, + SWRMutationKey extends Key = Key, + ExtraArg = any +> = Pick< + SWRConfiguration< + Data, + Error, + MutationFetcher + >, 'fetcher' | 'onSuccess' | 'onError' > & { revalidate?: boolean populateCache?: boolean } -export interface SWRMutationResponse - extends Omit, 'isValidating'> { +export interface SWRMutationResponse< + Data = any, + Error = any, + SWRMutationKey extends Key = Key, + ExtraArg = any +> extends Omit, 'isValidating'> { isMutating: boolean trigger: ( - extraArgument?: any, - options?: SWRMutationConfiguration + 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/test/type/trigger.ts b/test/type/trigger.ts new file mode 100644 index 000000000..0eb76ac6d --- /dev/null +++ b/test/type/trigger.ts @@ -0,0 +1,30 @@ +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', (_, extra) => { + expectType>(true) + }) +} + +export function useTrigger() { + const { trigger } = useSWRMutation('/api/user', (_, extra: number) => + String(extra) + ) + + // The argument of trigger should be number or undefined. + // TODO: handle the `undefined` cases. + expectType[0], number | undefined>>(true) + expectType>(trigger(1)) +} From 67c9f36451a5f20a3dd28f4e63fc26beacacb8f9 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 4 Oct 2021 02:28:43 +0200 Subject: [PATCH 15/25] fix lint errors --- mutation/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mutation/index.ts b/mutation/index.ts index 6a8ee5d0d..bd54c6eac 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -81,11 +81,13 @@ const mutation = () => ( } throw error } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const reset = useCallback(() => { ditchMutationsTilRef.current = getTimestamp() setState({ data: UNDEFINED, error: UNDEFINED, isValidating: false }) + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useIsomorphicLayoutEffect(() => { From c9fc40e85eee0c6739008ac78aeadb96e895aed9 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 4 Oct 2021 02:29:34 +0200 Subject: [PATCH 16/25] code tweaks --- mutation/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mutation/index.ts b/mutation/index.ts index bd54c6eac..511e389ca 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -62,9 +62,10 @@ const mutation = () => ( const mutationStartedAt = getTimestamp() ditchMutationsTilRef.current = mutationStartedAt + setState({ isValidating: true }) + args.push(extraArg) + try { - setState({ isValidating: true }) - args.push(extraArg) const data = await mutate(serializedKey, fetcher(...args), options) // If it's reset after the mutation, we don't broadcast any state change. From 4caa4b0d1c0ebce78bc0f1231c62862b5fc373dc Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Tue, 28 Dec 2021 17:39:23 +0100 Subject: [PATCH 17/25] fix type error --- mutation/index.ts | 201 ++++++++++++++++++++++++---------------------- 1 file changed, 105 insertions(+), 96 deletions(-) diff --git a/mutation/index.ts b/mutation/index.ts index 511e389ca..cac1184bc 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -15,108 +15,117 @@ import { 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 ditchMutationsTilRef = useRef(0) - - const [stateRef, stateDependencies, setState] = useStateWithDeps( - { - data: UNDEFINED, - error: UNDEFINED, - isValidating: false - }, - true - ) - const currentState = stateRef.current - - // Similar to the global mutate, but bound to the current cache and key. - // `cache` isn't allowed to change during the lifecycle. - const boundMutate = useCallback( - (newData, shouldRevalidate) => { - const serializedKey = serialize(keyRef.current)[0] - return mutate(serializedKey, newData, shouldRevalidate) - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ) - - const trigger = useCallback(async (extraArg, opts) => { - if (!fetcher) { - throw new Error('Can’t trigger the mutation: missing fetcher.') - } - - const [serializedKey, args] = serialize(keyRef.current) - - // 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() - ditchMutationsTilRef.current = mutationStartedAt - - setState({ isValidating: true }) - args.push(extraArg) - - try { - const data = await mutate(serializedKey, fetcher(...args), options) +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 ditchMutationsTilRef = useRef(0) + + const [stateRef, stateDependencies, setState] = useStateWithDeps< + Data, + Error + >( + { + data: UNDEFINED, + error: UNDEFINED, + isValidating: false + }, + true + ) + const currentState = stateRef.current + + // Similar to the global mutate, but bound to the current cache and key. + // `cache` isn't allowed to change during the lifecycle. + const boundMutate = useCallback( + (newData, shouldRevalidate) => { + const serializedKey = serialize(keyRef.current)[0] + return mutate(serializedKey, newData, shouldRevalidate) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + + const trigger = useCallback(async (extraArg, opts) => { + if (!fetcher) { + throw new Error('Can’t trigger the mutation: missing fetcher.') + } - // If it's reset after the mutation, we don't broadcast any state change. - if (ditchMutationsTilRef.current <= mutationStartedAt) { - setState({ data, isValidating: false }) - options.onSuccess && options.onSuccess(data, serializedKey, options) + const [serializedKey, args] = serialize(keyRef.current) + + // 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() + ditchMutationsTilRef.current = mutationStartedAt + + setState({ isValidating: true }) + args.push(extraArg) + + try { + const data = await mutate( + serializedKey, + (fetcher as any)(...args), + options + ) + + // If it's reset after the mutation, we don't broadcast any state change. + if (ditchMutationsTilRef.current <= mutationStartedAt) { + setState({ data, isValidating: false }) + options.onSuccess && options.onSuccess(data, serializedKey, options) + } + return data + } catch (error) { + // If it's reset after the mutation, we don't broadcast any state change. + if (ditchMutationsTilRef.current <= mutationStartedAt) { + setState({ error: error as Error, isValidating: false }) + options.onError && options.onError(error, serializedKey, options) + } + throw error } - return data - } catch (error) { - // If it's reset after the mutation, we don't broadcast any state change. - if (ditchMutationsTilRef.current <= mutationStartedAt) { - setState({ error: error as Error, isValidating: false }) - options.onError && options.onError(error, serializedKey, options) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const reset = useCallback(() => { + ditchMutationsTilRef.current = getTimestamp() + setState({ data: UNDEFINED, error: UNDEFINED, isValidating: false }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useIsomorphicLayoutEffect(() => { + keyRef.current = key + }) + + return { + mutate: boundMutate, + trigger, + reset, + get data() { + stateDependencies.data = true + return currentState.data + }, + get error() { + stateDependencies.error = true + return currentState.error + }, + get isMutating() { + stateDependencies.isValidating = true + return currentState.isValidating } - throw error - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - const reset = useCallback(() => { - ditchMutationsTilRef.current = getTimestamp() - setState({ data: UNDEFINED, error: UNDEFINED, isValidating: false }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - useIsomorphicLayoutEffect(() => { - keyRef.current = key - }) - - return { - mutate: boundMutate, - trigger, - reset, - get data() { - stateDependencies.data = true - return currentState.data - }, - get error() { - stateDependencies.error = true - return currentState.error - }, - get isMutating() { - stateDependencies.isValidating = true - return currentState.isValidating } } -} -export default (withMiddleware( +export default withMiddleware( useSWR, - (mutation as unknown) as Middleware -) as unknown) as SWRMutationHook + mutation as unknown as Middleware +) as unknown as SWRMutationHook export { SWRMutationConfiguration, SWRMutationResponse } From bedaddfe385382f752701abc42fcac02392f2fa3 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Wed, 29 Dec 2021 21:45:19 +0100 Subject: [PATCH 18/25] update types --- mutation/index.ts | 23 ++++++++++------------- mutation/types.ts | 20 +++++++++----------- src/use-swr.ts | 12 +++++------- src/utils/state.ts | 24 ++++++++++++------------ 4 files changed, 36 insertions(+), 43 deletions(-) diff --git a/mutation/index.ts b/mutation/index.ts index cac1184bc..e3947a9e3 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -28,14 +28,11 @@ const mutation = // Ditch all mutation results that happened earlier than this timestamp. const ditchMutationsTilRef = useRef(0) - const [stateRef, stateDependencies, setState] = useStateWithDeps< - Data, - Error - >( + const [stateRef, stateDependencies, setState] = useStateWithDeps( { data: UNDEFINED, error: UNDEFINED, - isValidating: false + isMutating: false }, true ) @@ -44,9 +41,9 @@ const mutation = // Similar to the global mutate, but bound to the current cache and key. // `cache` isn't allowed to change during the lifecycle. const boundMutate = useCallback( - (newData, shouldRevalidate) => { + (arg0, arg1) => { const serializedKey = serialize(keyRef.current)[0] - return mutate(serializedKey, newData, shouldRevalidate) + return mutate(serializedKey, arg0, arg1) }, // eslint-disable-next-line react-hooks/exhaustive-deps [] @@ -67,7 +64,7 @@ const mutation = const mutationStartedAt = getTimestamp() ditchMutationsTilRef.current = mutationStartedAt - setState({ isValidating: true }) + setState({ isMutating: true }) args.push(extraArg) try { @@ -79,14 +76,14 @@ const mutation = // If it's reset after the mutation, we don't broadcast any state change. if (ditchMutationsTilRef.current <= mutationStartedAt) { - setState({ data, isValidating: false }) + setState({ data, isMutating: false }) options.onSuccess && options.onSuccess(data, serializedKey, options) } return data } catch (error) { // If it's reset after the mutation, we don't broadcast any state change. if (ditchMutationsTilRef.current <= mutationStartedAt) { - setState({ error: error as Error, isValidating: false }) + setState({ error: error as Error, isMutating: false }) options.onError && options.onError(error, serializedKey, options) } throw error @@ -96,7 +93,7 @@ const mutation = const reset = useCallback(() => { ditchMutationsTilRef.current = getTimestamp() - setState({ data: UNDEFINED, error: UNDEFINED, isValidating: false }) + setState({ data: UNDEFINED, error: UNDEFINED, isMutating: false }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -117,8 +114,8 @@ const mutation = return currentState.error }, get isMutating() { - stateDependencies.isValidating = true - return currentState.isValidating + stateDependencies.isMutating = true + return currentState.isMutating } } } diff --git a/mutation/types.ts b/mutation/types.ts index 3743787ab..c1ce38439 100644 --- a/mutation/types.ts +++ b/mutation/types.ts @@ -1,4 +1,4 @@ -import { SWRResponse, SWRConfiguration, Key } from 'swr' +import { SWRResponse, SWRConfiguration, Key, MutatorOptions } from 'swr' type Async = Data | Promise @@ -11,26 +11,26 @@ export type MutationFetcher< * () => [{ foo: string }, { bar: number }] | null * () => ( [{ foo: string }, { bar: number } ] as const | null ) */ - SWRKey extends (() => readonly [...infer Args] | null) - ? ((...args: [...Args, ExtraArg]) => Async) + SWRKey extends () => readonly [...infer Args] | null + ? (...args: [...Args, ExtraArg]) => Async : /** * [{ foo: string }, { bar: number } ] | null * [{ foo: string }, { bar: number } ] as const | null */ - SWRKey extends (readonly [...infer Args]) - ? ((...args: [...Args, ExtraArg]) => Async) + SWRKey extends readonly [...infer Args] + ? (...args: [...Args, ExtraArg]) => Async : /** * () => string | null * () => Record | null */ - SWRKey extends (() => infer Arg | null) + SWRKey extends () => infer Arg | null ? (...args: [Arg, ExtraArg]) => Async : /** * string | null | Record */ SWRKey extends null ? never - : SWRKey extends (infer Arg) + : SWRKey extends infer Arg ? (...args: [Arg, ExtraArg]) => Async : never @@ -46,10 +46,8 @@ export type SWRMutationConfiguration< MutationFetcher >, 'fetcher' | 'onSuccess' | 'onError' -> & { - revalidate?: boolean - populateCache?: boolean -} +> & + MutatorOptions export interface SWRMutationResponse< Data = any, diff --git a/src/use-swr.ts b/src/use-swr.ts index 0a2d1c524..b805422cb 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -110,13 +110,11 @@ export const useSWRHandler = ( } const isValidating = resolveValidating() - const [stateRef, stateDependencies, setState] = useStateWithDeps( - { - data, - error, - isValidating - } - ) + const [stateRef, stateDependencies, setState] = useStateWithDeps({ + data, + error, + isValidating + }) // The revalidation function is a carefully crafted wrapper of the original // `fetcher`, to correctly handle the many edge cases. diff --git a/src/utils/state.ts b/src/utils/state.ts index 579a1981e..8edc930f2 100644 --- a/src/utils/state.ts +++ b/src/utils/state.ts @@ -1,18 +1,18 @@ 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, +export const useStateWithDeps = ( + state: any, asInitialState?: boolean -): [MutableRefObject, Record, (payload: S) => void] => { +): [ + MutableRefObject, + Record, + (payload: Partial) => void +] => { const rerender = useState>({})[1] const unmountedRef = useRef(false) const stateRef = useRef(state) @@ -21,11 +21,11 @@ export const useStateWithDeps = >( // 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: @@ -45,17 +45,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. From c7c1fc6dd37639e3132e33d0307c3f237c1ff2a7 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Wed, 12 Jan 2022 17:55:11 +0100 Subject: [PATCH 19/25] inline serialization result --- mutation/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/mutation/index.ts b/mutation/index.ts index e3947a9e3..5350d7acb 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -41,10 +41,7 @@ const mutation = // Similar to the global mutate, but bound to the current cache and key. // `cache` isn't allowed to change during the lifecycle. const boundMutate = useCallback( - (arg0, arg1) => { - const serializedKey = serialize(keyRef.current)[0] - return mutate(serializedKey, arg0, arg1) - }, + (arg0, arg1) => mutate(serialize(keyRef.current)[0], arg0, arg1), // eslint-disable-next-line react-hooks/exhaustive-deps [] ) From 2198dbeb219991934ff913a023073884d6c5d178 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 11 Apr 2022 15:33:12 +0200 Subject: [PATCH 20/25] merge main and update argument api --- mutation/index.ts | 21 ++++++++++----------- package.json | 6 ++++-- src/utils/broadcast-state.ts | 7 +++++-- test/use-swr-remote-mutation.test.tsx | 6 +++--- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/mutation/index.ts b/mutation/index.ts index 5350d7acb..2c0acacbc 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -26,7 +26,7 @@ const mutation = const keyRef = useRef(key) // Ditch all mutation results that happened earlier than this timestamp. - const ditchMutationsTilRef = useRef(0) + const ditchMutationsUntilRef = useRef(0) const [stateRef, stateDependencies, setState] = useStateWithDeps( { @@ -46,12 +46,12 @@ const mutation = [] ) - const trigger = useCallback(async (extraArg, opts) => { + const trigger = useCallback(async (arg, opts) => { if (!fetcher) { throw new Error('Can’t trigger the mutation: missing fetcher.') } - const [serializedKey, args] = serialize(keyRef.current) + const [serializedKey, resolvedKey] = serialize(keyRef.current) // Disable cache population by default. const options = Object.assign({ populateCache: false }, config, opts) @@ -59,29 +59,28 @@ const mutation = // Trigger a mutation, also track the timestamp. Any mutation that happened // earlier this timestamp should be ignored. const mutationStartedAt = getTimestamp() - ditchMutationsTilRef.current = mutationStartedAt + ditchMutationsUntilRef.current = mutationStartedAt setState({ isMutating: true }) - args.push(extraArg) try { const data = await mutate( serializedKey, - (fetcher as any)(...args), + (fetcher as any)(resolvedKey, { arg }), options ) // If it's reset after the mutation, we don't broadcast any state change. - if (ditchMutationsTilRef.current <= mutationStartedAt) { + if (ditchMutationsUntilRef.current <= mutationStartedAt) { setState({ data, isMutating: false }) - options.onSuccess && options.onSuccess(data, serializedKey, options) + options.onSuccess?.(data, serializedKey, options) } return data } catch (error) { // If it's reset after the mutation, we don't broadcast any state change. - if (ditchMutationsTilRef.current <= mutationStartedAt) { + if (ditchMutationsUntilRef.current <= mutationStartedAt) { setState({ error: error as Error, isMutating: false }) - options.onError && options.onError(error, serializedKey, options) + options.onError?.(error, serializedKey, options) } throw error } @@ -89,7 +88,7 @@ const mutation = }, []) const reset = useCallback(() => { - ditchMutationsTilRef.current = getTimestamp() + ditchMutationsUntilRef.current = getTimestamp() setState({ data: UNDEFINED, error: UNDEFINED, isMutating: false }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) diff --git a/package.json b/package.json index 8d031d1d0..0baff361b 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "types": "./immutable/dist/immutable/index.d.ts" }, "./mutation": { - "import": "./mutation/dist/index.esm.js", + "import": "./mutation/dist/index.mjs", + "module": "./mutation/dist/index.esm.js", "require": "./mutation/dist/index.js", "types": "./mutation/dist/mutation/index.d.ts" } @@ -69,7 +70,8 @@ "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/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/test/use-swr-remote-mutation.test.tsx b/test/use-swr-remote-mutation.test.tsx index 522a65144..3299b351a 100644 --- a/test/use-swr-remote-mutation.test.tsx +++ b/test/use-swr-remote-mutation.test.tsx @@ -50,7 +50,7 @@ describe('useSWR - remote mutation', () => { expect(fetcher).toHaveBeenCalled() expect(fetcher.mock.calls.length).toBe(1) - expect(fetcher.mock.calls[0]).toEqual([key, 'arg0', 'arg1']) + expect(fetcher.mock.calls[0]).toEqual([[key, 'arg0'], { arg: 'arg1' }]) }) it('should call `onSuccess` event', async () => { @@ -172,7 +172,7 @@ describe('useSWR - remote mutation', () => { function Page() { const { data, error, trigger } = useSWRMutation( key, - async (_, shouldReturnValue) => { + async (_, { arg: shouldReturnValue }) => { await sleep(10) if (shouldReturnValue) return 'data' throw new Error('error') @@ -281,7 +281,7 @@ describe('useSWR - remote mutation', () => { function Page() { const { data } = useSWR(key, () => 'data') - const { trigger } = useSWRMutation(key, (_, arg) => arg) + const { trigger } = useSWRMutation(key, (_, { arg }) => arg) return (
trigger('updated!', { populateCache: true })}> data:{data || 'none'} From cf0b79546a71142dbb9bef8eaf66ef0030476d36 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 11 Apr 2022 17:23:24 +0200 Subject: [PATCH 21/25] add tests --- mutation/index.ts | 91 ++++++------ mutation/types.ts | 19 ++- src/utils/mutate.ts | 2 +- test/use-swr-remote-mutation.test.tsx | 203 +++++++++++++++++++++++--- 4 files changed, 245 insertions(+), 70 deletions(-) diff --git a/mutation/index.ts b/mutation/index.ts index 2c0acacbc..185a27dfa 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -38,54 +38,53 @@ const mutation = ) const currentState = stateRef.current - // Similar to the global mutate, but bound to the current cache and key. - // `cache` isn't allowed to change during the lifecycle. - const boundMutate = useCallback( - (arg0, arg1) => mutate(serialize(keyRef.current)[0], arg0, arg1), - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ) - - const trigger = useCallback(async (arg, opts) => { - if (!fetcher) { - throw new Error('Can’t trigger the mutation: missing fetcher.') - } - - const [serializedKey, resolvedKey] = serialize(keyRef.current) - - // 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 }) + const trigger = useCallback( + async (arg, opts?: SWRMutationConfiguration) => { + const [serializedKey, resolvedKey] = serialize(keyRef.current) - 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, serializedKey, options) + if (!fetcher) { + throw new Error('Can’t trigger the mutation: missing fetcher.') } - 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, serializedKey, options) + if (!serializedKey) { + throw new Error('Can’t trigger the mutation: key isn’t ready.') } - throw error - } + + // 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 + } + }, // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + [] + ) const reset = useCallback(() => { ditchMutationsUntilRef.current = getTimestamp() @@ -97,8 +96,10 @@ const mutation = 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 { - mutate: boundMutate, trigger, reset, get data() { diff --git a/mutation/types.ts b/mutation/types.ts index c1ce38439..3dec7c6fe 100644 --- a/mutation/types.ts +++ b/mutation/types.ts @@ -45,9 +45,24 @@ export type SWRMutationConfiguration< Error, MutationFetcher >, - 'fetcher' | 'onSuccess' | 'onError' + 'fetcher' > & - MutatorOptions + MutatorOptions & { + onSuccess?: ( + data: Data, + key: string, + config: Readonly< + SWRMutationConfiguration + > + ) => void + onError?: ( + err: Error, + key: string, + config: Readonly< + SWRMutationConfiguration + > + ) => void + } export interface SWRMutationResponse< Data = any, 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/test/use-swr-remote-mutation.test.tsx b/test/use-swr-remote-mutation.test.tsx index 3299b351a..51503a42b 100644 --- a/test/use-swr-remote-mutation.test.tsx +++ b/test/use-swr-remote-mutation.test.tsx @@ -26,7 +26,35 @@ describe('useSWR - remote mutation', () => { screen.getByText('data') }) - it('should trigger request with correct args', async () => { + 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') @@ -301,26 +329,35 @@ describe('useSWR - remote mutation', () => { await screen.findByText('data:updated!') }) - it('should not trigger request when mutating', async () => { + it('should be able to populate the cache with a transformer', async () => { const key = createKey() - const fn = jest.fn(() => 'data') function Page() { - const { mutate } = useSWRMutation(key, fn) + 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('mutate') + await screen.findByText('data:data') - fireEvent.click(screen.getByText('mutate')) - expect(fn).not.toHaveBeenCalled() + // mutate + fireEvent.click(screen.getByText('data:data')) + await screen.findByText('data:updated!:data') }) it('should not trigger request when mutating from shared hooks', async () => { @@ -503,6 +540,108 @@ describe('useSWR - remote mutation', () => { 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 } + ) + + 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() + let counter = 0 + + function Page() { + const { data } = useSWR( + key, + async () => { + await sleep(10) + return 'foo' + ++counter + }, + { 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('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() @@ -625,33 +764,53 @@ describe('useSWR - remote mutation', () => { expect(catchError).toBeCalled() }) - it('should return the bound mutate', async () => { + it('should support optimistic updates', async () => { const key = createKey() function Page() { const { data } = useSWR(key, async () => { await sleep(10) - return 'stale' + return ['foo'] + }) + const { trigger } = useSWRMutation(key, async (_, { arg }) => { + await sleep(20) + return arg.toUpperCase() }) - const { trigger, mutate } = useSWRMutation(key, () => 'new') return (
- - {data || 'none'} + +
data:{JSON.stringify(data)}
) } render() - await screen.findByText('none') - // Initial result - await screen.findByText('stale') - fireEvent.click(screen.getByText('request')) - // Mutate - await screen.findByText('new') - // Revalidate - await screen.findByText('stale') + // mount + await screen.findByText('data:undefined') + 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"]') }) }) From 11546fa1fac42d3cff5c3955916747e4238dbb16 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 11 Apr 2022 17:43:14 +0200 Subject: [PATCH 22/25] fix tests --- test/use-swr-remote-mutation.test.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/test/use-swr-remote-mutation.test.tsx b/test/use-swr-remote-mutation.test.tsx index 51503a42b..8cd37312c 100644 --- a/test/use-swr-remote-mutation.test.tsx +++ b/test/use-swr-remote-mutation.test.tsx @@ -559,7 +559,7 @@ describe('useSWR - remote mutation', () => { await sleep(20) return 'bar' }, - { revalidate: false } + { revalidate: false, populateCache: true } ) logger(data) @@ -590,21 +590,24 @@ describe('useSWR - remote mutation', () => { it('should be able to configure auto revalidation from trigger', async () => { const key = createKey() const logger = jest.fn() - let counter = 0 function Page() { const { data } = useSWR( key, async () => { await sleep(10) - return 'foo' + ++counter + return 'foo' }, { revalidateOnMount: false } ) - const { trigger } = useSWRMutation(key, async () => { - await sleep(20) - return 'bar' - }) + const { trigger } = useSWRMutation( + key, + async () => { + await sleep(20) + return 'bar' + }, + { populateCache: true } + ) logger(data) @@ -798,7 +801,6 @@ describe('useSWR - remote mutation', () => { render() // mount - await screen.findByText('data:undefined') await screen.findByText('data:["foo"]') // optimistic update From 94f3579353cb7a3cb7177d14d7e57e50aaf659eb Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 11 Apr 2022 18:31:01 +0200 Subject: [PATCH 23/25] update typing --- mutation/types.ts | 107 +++++++++++++++++-------------------------- test/type/trigger.ts | 37 ++++++++++++--- 2 files changed, 73 insertions(+), 71 deletions(-) diff --git a/mutation/types.ts b/mutation/types.ts index 3dec7c6fe..679a15abe 100644 --- a/mutation/types.ts +++ b/mutation/types.ts @@ -1,79 +1,56 @@ -import { SWRResponse, SWRConfiguration, Key, MutatorOptions } from 'swr' +import { SWRResponse, Key, MutatorOptions } from 'swr' -type Async = Data | Promise +type FetcherResponse = Data | Promise + +type FetcherOptions = Readonly<{ + arg: ExtraArg +}> export type MutationFetcher< Data = unknown, - SWRKey extends Key = Key, - ExtraArg = any -> = - /** - * () => [{ foo: string }, { bar: number }] | null - * () => ( [{ foo: string }, { bar: number } ] as const | null ) - */ - SWRKey extends () => readonly [...infer Args] | null - ? (...args: [...Args, ExtraArg]) => Async - : /** - * [{ foo: string }, { bar: number } ] | null - * [{ foo: string }, { bar: number } ] as const | null - */ - SWRKey extends readonly [...infer Args] - ? (...args: [...Args, ExtraArg]) => Async - : /** - * () => string | null - * () => Record | null - */ - SWRKey extends () => infer Arg | null - ? (...args: [Arg, ExtraArg]) => Async - : /** - * string | null | Record - */ - SWRKey extends null - ? never - : SWRKey extends infer Arg - ? (...args: [Arg, ExtraArg]) => Async - : never + 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, - SWRMutationKey extends Key = Key, - ExtraArg = any -> = Pick< - SWRConfiguration< - Data, - Error, - MutationFetcher - >, - 'fetcher' -> & - MutatorOptions & { - onSuccess?: ( - data: Data, - key: string, - config: Readonly< - SWRMutationConfiguration - > - ) => void - onError?: ( - err: Error, - key: string, - config: Readonly< - SWRMutationConfiguration - > - ) => void - } + 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, - SWRMutationKey extends Key = Key, - ExtraArg = any -> extends Omit, 'isValidating'> { + ExtraArg = any, + SWRMutationKey extends Key = Key +> extends Pick, 'data' | 'error'> { isMutating: boolean trigger: ( extraArgument?: ExtraArg, - options?: SWRMutationConfiguration + options?: SWRMutationConfiguration ) => Promise reset: () => void } @@ -85,10 +62,10 @@ export type SWRMutationHook = < ExtraArg = any >( ...args: - | readonly [SWRMutationKey, MutationFetcher] + | readonly [SWRMutationKey, MutationFetcher] | readonly [ SWRMutationKey, - MutationFetcher, - SWRMutationConfiguration + MutationFetcher, + SWRMutationConfiguration ] -) => SWRMutationResponse +) => SWRMutationResponse diff --git a/test/type/trigger.ts b/test/type/trigger.ts index 0eb76ac6d..083ef91a1 100644 --- a/test/type/trigger.ts +++ b/test/type/trigger.ts @@ -3,9 +3,9 @@ import useSWRMutation from 'swr/mutation' type ExpectType = (value: T) => void const expectType: ExpectType = () => {} -type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 - : 2) + : 2 ? true : false @@ -13,14 +13,39 @@ export function useExtraParam() { useSWRMutation('/api/user', key => { expectType(key) }) - useSWRMutation('/api/user', (_, extra) => { - expectType>(true) + useSWRMutation('/api/user', (_, opts) => { + expectType>>(true) }) } export function useTrigger() { - const { trigger } = useSWRMutation('/api/user', (_, extra: number) => - String(extra) + 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. From be0d14a3c820bee7ec425484de9fbc0e50029ed4 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 11 Apr 2022 23:05:09 +0200 Subject: [PATCH 24/25] update state api --- mutation/index.ts | 13 +++++-------- src/use-swr.ts | 8 +++++--- src/utils/state.ts | 9 +-------- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/mutation/index.ts b/mutation/index.ts index 185a27dfa..0efb97537 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -28,14 +28,11 @@ const mutation = // 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 - }, - true - ) + const [stateRef, stateDependencies, setState] = useStateWithDeps({ + data: UNDEFINED, + error: UNDEFINED, + isMutating: false + }) const currentState = stateRef.current const trigger = useCallback( diff --git a/src/use-swr.ts b/src/use-swr.ts index 06d05d1ac..26404dfe5 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -131,11 +131,12 @@ export const useSWRHandler = ( } const isValidating = resolveValidating() - const [stateRef, stateDependencies, setState] = useStateWithDeps({ + 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. @@ -376,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/state.ts b/src/utils/state.ts index 8edc930f2..43f4ce68c 100644 --- a/src/utils/state.ts +++ b/src/utils/state.ts @@ -6,8 +6,7 @@ import { useIsomorphicLayoutEffect } from './env' * An implementation of state with dependency-tracking. */ export const useStateWithDeps = ( - state: any, - asInitialState?: boolean + state: any ): [ MutableRefObject, Record, @@ -75,12 +74,6 @@ export const useStateWithDeps = ( ) useIsomorphicLayoutEffect(() => { - // Always update the state reference if it's not used as the initial state - // only. - if (!asInitialState) { - stateRef.current = state - } - unmountedRef.current = false return () => { unmountedRef.current = true From 76d7864712dad1718de5f23c9badc2a00f052a51 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 11 Apr 2022 23:10:11 +0200 Subject: [PATCH 25/25] change error condition --- mutation/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mutation/index.ts b/mutation/index.ts index 0efb97537..1dee32b06 100644 --- a/mutation/index.ts +++ b/mutation/index.ts @@ -75,8 +75,8 @@ const mutation = if (ditchMutationsUntilRef.current <= mutationStartedAt) { setState({ error: error as Error, isMutating: false }) options.onError?.(error as Error, serializedKey, options) + throw error as Error } - throw error } }, // eslint-disable-next-line react-hooks/exhaustive-deps