Skip to content

Commit 5aaaf82

Browse files
authored
feat: Hook for remote mutations (#1450)
* (wip) initial impl. * fix test * (wip) fix deps * initial implementation * fix linter * fix state reset * avoid reset race condition * fix race conditions * code tweaks * code tweaks * return bound mutate * apply review comments * fix tsconfig * type fixes * fix lint errors * code tweaks * fix type error * update types * inline serialization result * merge main and update argument api * add tests * fix tests * update typing * update state api * change error condition
1 parent db93f66 commit 5aaaf82

File tree

17 files changed

+1159
-46
lines changed

17 files changed

+1159
-46
lines changed

examples/axios-typescript/libs/useRequest.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ export default function useRequest<Data = unknown, Error = unknown>(
2424
request: GetRequest,
2525
{ fallbackData, ...config }: Config<Data, Error> = {}
2626
): Return<Data, Error> {
27-
const { data: response, error, isValidating, mutate } = useSWR<
28-
AxiosResponse<Data>,
29-
AxiosError<Error>
30-
>(
27+
const {
28+
data: response,
29+
error,
30+
isValidating,
31+
mutate
32+
} = useSWR<AxiosResponse<Data>, AxiosError<Error>>(
3133
request && JSON.stringify(request),
3234
/**
3335
* NOTE: Typescript thinks `request` can be `null` here, but the fetcher

jest.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ module.exports = {
66
moduleNameMapper: {
77
'^swr$': '<rootDir>/src',
88
'^swr/infinite$': '<rootDir>/infinite/index.ts',
9-
'^swr/immutable$': '<rootDir>/immutable/index.ts'
9+
'^swr/immutable$': '<rootDir>/immutable/index.ts',
10+
'^swr/mutation$': '<rootDir>/mutation/index.ts'
1011
},
1112
transform: {
1213
'^.+\\.(t|j)sx?$': '@swc/jest'

mutation/index.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { useCallback, useRef } from 'react'
2+
import useSWR, { useSWRConfig, Middleware, Key } from 'swr'
3+
4+
import { serialize } from '../src/utils/serialize'
5+
import { useStateWithDeps } from '../src/utils/state'
6+
import { withMiddleware } from '../src/utils/with-middleware'
7+
import { useIsomorphicLayoutEffect } from '../src/utils/env'
8+
import { UNDEFINED } from '../src/utils/helper'
9+
import { getTimestamp } from '../src/utils/timestamp'
10+
11+
import {
12+
SWRMutationConfiguration,
13+
SWRMutationResponse,
14+
SWRMutationHook,
15+
MutationFetcher
16+
} from './types'
17+
18+
const mutation =
19+
<Data, Error>() =>
20+
(
21+
key: Key,
22+
fetcher: MutationFetcher<Data>,
23+
config: SWRMutationConfiguration<Data, Error> = {}
24+
) => {
25+
const { mutate } = useSWRConfig()
26+
27+
const keyRef = useRef(key)
28+
// Ditch all mutation results that happened earlier than this timestamp.
29+
const ditchMutationsUntilRef = useRef(0)
30+
31+
const [stateRef, stateDependencies, setState] = useStateWithDeps({
32+
data: UNDEFINED,
33+
error: UNDEFINED,
34+
isMutating: false
35+
})
36+
const currentState = stateRef.current
37+
38+
const trigger = useCallback(
39+
async (arg, opts?: SWRMutationConfiguration<Data, Error>) => {
40+
const [serializedKey, resolvedKey] = serialize(keyRef.current)
41+
42+
if (!fetcher) {
43+
throw new Error('Can’t trigger the mutation: missing fetcher.')
44+
}
45+
if (!serializedKey) {
46+
throw new Error('Can’t trigger the mutation: key isn’t ready.')
47+
}
48+
49+
// Disable cache population by default.
50+
const options = Object.assign({ populateCache: false }, config, opts)
51+
52+
// Trigger a mutation, also track the timestamp. Any mutation that happened
53+
// earlier this timestamp should be ignored.
54+
const mutationStartedAt = getTimestamp()
55+
56+
ditchMutationsUntilRef.current = mutationStartedAt
57+
58+
setState({ isMutating: true })
59+
60+
try {
61+
const data = await mutate<Data>(
62+
serializedKey,
63+
(fetcher as any)(resolvedKey, { arg }),
64+
options
65+
)
66+
67+
// If it's reset after the mutation, we don't broadcast any state change.
68+
if (ditchMutationsUntilRef.current <= mutationStartedAt) {
69+
setState({ data, isMutating: false })
70+
options.onSuccess?.(data as Data, serializedKey, options)
71+
}
72+
return data
73+
} catch (error) {
74+
// If it's reset after the mutation, we don't broadcast any state change.
75+
if (ditchMutationsUntilRef.current <= mutationStartedAt) {
76+
setState({ error: error as Error, isMutating: false })
77+
options.onError?.(error as Error, serializedKey, options)
78+
throw error as Error
79+
}
80+
}
81+
},
82+
// eslint-disable-next-line react-hooks/exhaustive-deps
83+
[]
84+
)
85+
86+
const reset = useCallback(() => {
87+
ditchMutationsUntilRef.current = getTimestamp()
88+
setState({ data: UNDEFINED, error: UNDEFINED, isMutating: false })
89+
// eslint-disable-next-line react-hooks/exhaustive-deps
90+
}, [])
91+
92+
useIsomorphicLayoutEffect(() => {
93+
keyRef.current = key
94+
})
95+
96+
// We don't return `mutate` here as it can be pretty confusing (e.g. people
97+
// calling `mutate` but they actually mean `trigger`).
98+
// And also, `mutate` relies on the useSWR hook to exist too.
99+
return {
100+
trigger,
101+
reset,
102+
get data() {
103+
stateDependencies.data = true
104+
return currentState.data
105+
},
106+
get error() {
107+
stateDependencies.error = true
108+
return currentState.error
109+
},
110+
get isMutating() {
111+
stateDependencies.isMutating = true
112+
return currentState.isMutating
113+
}
114+
}
115+
}
116+
117+
export default withMiddleware(
118+
useSWR,
119+
mutation as unknown as Middleware
120+
) as unknown as SWRMutationHook
121+
122+
export { SWRMutationConfiguration, SWRMutationResponse }

mutation/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "swr-mutation",
3+
"version": "0.0.1",
4+
"main": "./dist/index.js",
5+
"module": "./dist/index.esm.js",
6+
"types": "./dist/mutation",
7+
"peerDependencies": {
8+
"swr": "*",
9+
"react": "*"
10+
}
11+
}

mutation/tsconfig.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../tsconfig.json",
3+
"compilerOptions": {
4+
"rootDir": "..",
5+
"outDir": "./dist"
6+
},
7+
"include": ["./*.ts"]
8+
}

mutation/types.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { SWRResponse, Key, MutatorOptions } from 'swr'
2+
3+
type FetcherResponse<Data> = Data | Promise<Data>
4+
5+
type FetcherOptions<ExtraArg = unknown> = Readonly<{
6+
arg: ExtraArg
7+
}>
8+
9+
export type MutationFetcher<
10+
Data = unknown,
11+
ExtraArg = unknown,
12+
SWRKey extends Key = Key
13+
> = SWRKey extends () => infer Arg | null | undefined | false
14+
? (key: Arg, options: FetcherOptions<ExtraArg>) => FetcherResponse<Data>
15+
: SWRKey extends null | undefined | false
16+
? never
17+
: SWRKey extends infer Arg
18+
? (key: Arg, options: FetcherOptions<ExtraArg>) => FetcherResponse<Data>
19+
: never
20+
21+
export type SWRMutationConfiguration<
22+
Data,
23+
Error,
24+
ExtraArg = any,
25+
SWRMutationKey extends Key = Key
26+
> = MutatorOptions<Data> & {
27+
fetcher?: MutationFetcher<Data, ExtraArg, SWRMutationKey>
28+
onSuccess?: (
29+
data: Data,
30+
key: string,
31+
config: Readonly<
32+
SWRMutationConfiguration<Data, Error, SWRMutationKey, ExtraArg>
33+
>
34+
) => void
35+
onError?: (
36+
err: Error,
37+
key: string,
38+
config: Readonly<
39+
SWRMutationConfiguration<Data, Error, SWRMutationKey, ExtraArg>
40+
>
41+
) => void
42+
}
43+
44+
export interface SWRMutationResponse<
45+
Data = any,
46+
Error = any,
47+
ExtraArg = any,
48+
SWRMutationKey extends Key = Key
49+
> extends Pick<SWRResponse<Data, Error>, 'data' | 'error'> {
50+
isMutating: boolean
51+
trigger: (
52+
extraArgument?: ExtraArg,
53+
options?: SWRMutationConfiguration<Data, Error, ExtraArg, SWRMutationKey>
54+
) => Promise<Data | undefined>
55+
reset: () => void
56+
}
57+
58+
export type SWRMutationHook = <
59+
Data = any,
60+
Error = any,
61+
SWRMutationKey extends Key = Key,
62+
ExtraArg = any
63+
>(
64+
...args:
65+
| readonly [SWRMutationKey, MutationFetcher<Data, ExtraArg, SWRMutationKey>]
66+
| readonly [
67+
SWRMutationKey,
68+
MutationFetcher<Data, ExtraArg, SWRMutationKey>,
69+
SWRMutationConfiguration<Data, Error, ExtraArg, SWRMutationKey>
70+
]
71+
) => SWRMutationResponse<Data, Error, ExtraArg, SWRMutationKey>

package.json

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,36 +31,47 @@
3131
"module": "./immutable/dist/index.esm.js",
3232
"require": "./immutable/dist/index.js",
3333
"types": "./immutable/dist/immutable/index.d.ts"
34+
},
35+
"./mutation": {
36+
"import": "./mutation/dist/index.mjs",
37+
"module": "./mutation/dist/index.esm.js",
38+
"require": "./mutation/dist/index.js",
39+
"types": "./mutation/dist/mutation/index.d.ts"
3440
}
3541
},
3642
"types": "./dist/index.d.ts",
3743
"files": [
3844
"dist/**",
3945
"infinite/dist/**",
4046
"immutable/dist/**",
47+
"mutation/dist/**",
4148
"infinite/package.json",
42-
"immutable/package.json"
49+
"immutable/package.json",
50+
"mutation/package.json"
4351
],
4452
"repository": "github:vercel/swr",
4553
"homepage": "https://swr.vercel.app",
4654
"license": "MIT",
4755
"scripts": {
48-
"clean": "rimraf dist infinite/dist immutable/dist",
49-
"build": "yarn build:core && yarn build:infinite && yarn build:immutable",
50-
"watch": "npm-run-all -p watch:core watch:infinite watch:immutable",
56+
"clean": "rimraf dist infinite/dist immutable/dist mutation/dist",
57+
"build": "yarn build:core && yarn build:infinite && yarn build:immutable && yarn build:mutation",
58+
"watch": "npm-run-all -p watch:core watch:infinite watch:immutable watch:mutation",
5159
"watch:core": "yarn build:core -w",
5260
"watch:infinite": "yarn build:infinite -w",
5361
"watch:immutable": "yarn build:immutable -w",
62+
"watch:mutation": "yarn build:mutation -w",
5463
"build:core": "bunchee src/index.ts --no-sourcemap",
5564
"build:infinite": "bunchee index.ts --cwd infinite --no-sourcemap",
5665
"build:immutable": "bunchee index.ts --cwd immutable --no-sourcemap",
66+
"build:mutation": "bunchee index.ts --cwd mutation --no-sourcemap",
5767
"prepublishOnly": "yarn clean && yarn build",
5868
"publish-beta": "yarn publish --tag beta",
5969
"types:check": "tsc --noEmit --project tsconfig.check.json && tsc --noEmit -p test",
6070
"format": "prettier --write ./**/*.{ts,tsx}",
6171
"lint": "eslint . --ext .ts,.tsx --cache",
6272
"lint:fix": "yarn lint --fix",
63-
"test": "jest --coverage"
73+
"coverage": "jest --coverage",
74+
"test": "jest"
6475
},
6576
"husky": {
6677
"hooks": {

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,11 @@ export type MutatorOptions<Data = any> = {
145145
rollbackOnError?: boolean
146146
}
147147

148+
export type MutatorConfig = {
149+
revalidate?: boolean
150+
populateCache?: boolean
151+
}
152+
148153
export type Broadcaster<Data = any, Error = any> = (
149154
cache: Cache<Data>,
150155
key: string,

src/use-swr.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,12 @@ export const useSWRHandler = <Data = any, Error = any>(
131131
}
132132
const isValidating = resolveValidating()
133133

134-
const [stateRef, stateDependencies, setState] = useStateWithDeps<Data, Error>(
135-
{
136-
data,
137-
error,
138-
isValidating
139-
},
140-
unmountedRef
141-
)
134+
const currentState = {
135+
data,
136+
error,
137+
isValidating
138+
}
139+
const [stateRef, stateDependencies, setState] = useStateWithDeps(currentState)
142140

143141
// The revalidation function is a carefully crafted wrapper of the original
144142
// `fetcher`, to correctly handle the many edge cases.
@@ -379,10 +377,11 @@ export const useSWRHandler = <Data = any, Error = any>(
379377
[]
380378
)
381379

382-
// Always update fetcher and config refs.
380+
// Always update fetcher, config and state refs.
383381
useIsomorphicLayoutEffect(() => {
384382
fetcherRef.current = fetcher
385383
configRef.current = config
384+
stateRef.current = currentState
386385
})
387386

388387
// After mounted or key changed.

src/utils/broadcast-state.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Broadcaster } from '../types'
22
import { SWRGlobalState, GlobalState } from './global-state'
33
import * as revalidateEvents from '../constants'
4+
import { createCacheHelper } from './cache'
45

56
export const broadcastState: Broadcaster = (
67
cache,
@@ -15,6 +16,8 @@ export const broadcastState: Broadcaster = (
1516
const revalidators = EVENT_REVALIDATORS[key]
1617
const updaters = STATE_UPDATERS[key]
1718

19+
const [get] = createCacheHelper(cache, key)
20+
1821
// Cache was populated, update states of all hooks.
1922
if (broadcast && updaters) {
2023
for (let i = 0; i < updaters.length; ++i) {
@@ -30,10 +33,10 @@ export const broadcastState: Broadcaster = (
3033

3134
if (revalidators && revalidators[0]) {
3235
return revalidators[0](revalidateEvents.MUTATE_EVENT).then(
33-
() => cache.get(key).data
36+
() => get().data
3437
)
3538
}
3639
}
3740

38-
return cache.get(key).data
41+
return get().data
3942
}

0 commit comments

Comments
 (0)