Skip to content

Commit 367cc96

Browse files
shudingnevilm-lt
authored andcommitted
feat: Optimistic mutation with error rollback (vercel#1745)
* add optimisticData and rollbackOnError * add test cases * add test for rollback
1 parent e4dcc1f commit 367cc96

File tree

4 files changed

+129
-11
lines changed

4 files changed

+129
-11
lines changed

src/types.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,11 @@ export type MutatorCallback<Data = any> = (
142142
currentValue?: Data
143143
) => Promise<undefined | Data> | undefined | Data
144144

145-
export type MutatorOptions = {
145+
export type MutatorOptions<Data = any> = {
146146
revalidate?: boolean
147147
populateCache?: boolean
148+
optimisticData?: Data
149+
rollbackOnError?: boolean
148150
}
149151

150152
export type Broadcaster<Data = any, Error = any> = (
@@ -167,27 +169,27 @@ export type Mutator<Data = any> = (
167169
cache: Cache,
168170
key: Key,
169171
data?: Data | Promise<Data> | MutatorCallback<Data>,
170-
opts?: boolean | MutatorOptions
172+
opts?: boolean | MutatorOptions<Data>
171173
) => Promise<Data | undefined>
172174

173175
export interface ScopedMutator<Data = any> {
174176
/** This is used for bound mutator */
175177
(
176178
key: Key,
177179
data?: Data | Promise<Data> | MutatorCallback<Data>,
178-
opts?: boolean | MutatorOptions
180+
opts?: boolean | MutatorOptions<Data>
179181
): Promise<Data | undefined>
180182
/** This is used for global mutator */
181183
<T = any>(
182184
key: Key,
183185
data?: T | Promise<T> | MutatorCallback<T>,
184-
opts?: boolean | MutatorOptions
186+
opts?: boolean | MutatorOptions<Data>
185187
): Promise<T | undefined>
186188
}
187189

188190
export type KeyedMutator<Data> = (
189191
data?: Data | Promise<Data> | MutatorCallback<Data>,
190-
opts?: boolean | MutatorOptions
192+
opts?: boolean | MutatorOptions<Data>
191193
) => Promise<Data | undefined>
192194

193195
// Public types

src/utils/broadcast-state.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ export const broadcastState: Broadcaster = (
99
error,
1010
isValidating,
1111
revalidate,
12-
populateCache = true
12+
broadcast = true
1313
) => {
1414
const [EVENT_REVALIDATORS, STATE_UPDATERS, , , CONCURRENT_REQUESTS] =
1515
SWRGlobalState.get(cache) as GlobalState
1616
const revalidators = EVENT_REVALIDATORS[key]
17-
const updaters = STATE_UPDATERS[key] || []
17+
const updaters = STATE_UPDATERS[key]
1818

1919
// Cache was populated, update states of all hooks.
20-
if (populateCache && updaters) {
20+
if (broadcast && updaters) {
2121
for (let i = 0; i < updaters.length; ++i) {
2222
updaters[i](data, error, isValidating)
2323
}

src/utils/mutate.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { serialize } from './serialize'
2-
import { isFunction, UNDEFINED } from './helper'
2+
import { isFunction, isUndefined, UNDEFINED } from './helper'
33
import { SWRGlobalState, GlobalState } from './global-state'
44
import { broadcastState } from './broadcast-state'
55
import { getTimestamp } from './timestamp'
@@ -11,7 +11,7 @@ export const internalMutate = async <Data>(
1111
Cache,
1212
Key,
1313
undefined | Data | Promise<Data | undefined> | MutatorCallback<Data>,
14-
undefined | boolean | MutatorOptions
14+
undefined | boolean | MutatorOptions<Data>
1515
]
1616
) => {
1717
const [cache, _key, _data, _opts] = args
@@ -22,8 +22,10 @@ export const internalMutate = async <Data>(
2222
typeof _opts === 'boolean' ? { revalidate: _opts } : _opts || {}
2323

2424
// Fallback to `true` if it's not explicitly set to `false`
25+
let populateCache = options.populateCache !== false
2526
const revalidate = options.revalidate !== false
26-
const populateCache = options.populateCache !== false
27+
const rollbackOnError = options.rollbackOnError !== false
28+
const optimisticData = options.optimisticData
2729

2830
// Serilaize key
2931
const [key, , keyErr] = serialize(_key)
@@ -53,6 +55,14 @@ export const internalMutate = async <Data>(
5355
// Update global timestamps.
5456
const beforeMutationTs = (MUTATION_TS[key] = getTimestamp())
5557
MUTATION_END_TS[key] = 0
58+
const hasOptimisticData = !isUndefined(optimisticData)
59+
const rollbackData = cache.get(key)
60+
61+
// Do optimistic data update.
62+
if (hasOptimisticData) {
63+
cache.set(key, optimisticData)
64+
broadcastState(cache, key, optimisticData)
65+
}
5666

5767
if (isFunction(data)) {
5868
// `data` is a function, call it passing current cache value.
@@ -78,6 +88,11 @@ export const internalMutate = async <Data>(
7888
if (beforeMutationTs !== MUTATION_TS[key]) {
7989
if (error) throw error
8090
return data
91+
} else if (error && hasOptimisticData && rollbackOnError) {
92+
// Rollback. Always populate the cache in this case.
93+
populateCache = true
94+
data = rollbackData
95+
cache.set(key, rollbackData)
8196
}
8297
}
8398

test/use-swr-local-mutation.test.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,4 +1011,105 @@ describe('useSWR - local mutation', () => {
10111011
await sleep(30)
10121012
await screen.findByText('data: foo')
10131013
})
1014+
1015+
it('should support optimistic updates via `optimisticData`', async () => {
1016+
const key = createKey()
1017+
const renderedData = []
1018+
let mutate
1019+
1020+
function Page() {
1021+
const { data, mutate: boundMutate } = useSWR(key, () =>
1022+
createResponse('foo', { delay: 20 })
1023+
)
1024+
mutate = boundMutate
1025+
renderedData.push(data)
1026+
return <div>data: {String(data)}</div>
1027+
}
1028+
1029+
renderWithConfig(<Page />)
1030+
await screen.findByText('data: foo')
1031+
1032+
await act(() =>
1033+
mutate(createResponse('baz', { delay: 20 }), {
1034+
optimisticData: 'bar'
1035+
})
1036+
)
1037+
await sleep(30)
1038+
expect(renderedData).toEqual([undefined, 'foo', 'bar', 'baz', 'foo'])
1039+
})
1040+
1041+
it('should rollback optimistic updates when mutation fails', async () => {
1042+
const key = createKey()
1043+
const renderedData = []
1044+
let mutate
1045+
let cnt = 0
1046+
1047+
function Page() {
1048+
const { data, mutate: boundMutate } = useSWR(key, () =>
1049+
createResponse(cnt++, { delay: 20 })
1050+
)
1051+
mutate = boundMutate
1052+
if (
1053+
!renderedData.length ||
1054+
renderedData[renderedData.length - 1] !== data
1055+
) {
1056+
renderedData.push(data)
1057+
}
1058+
return <div>data: {String(data)}</div>
1059+
}
1060+
1061+
renderWithConfig(<Page />)
1062+
await screen.findByText('data: 0')
1063+
1064+
try {
1065+
await act(() =>
1066+
mutate(createResponse(new Error('baz'), { delay: 20 }), {
1067+
optimisticData: 'bar'
1068+
})
1069+
)
1070+
} catch (e) {
1071+
expect(e.message).toEqual('baz')
1072+
}
1073+
1074+
await sleep(30)
1075+
expect(renderedData).toEqual([undefined, 0, 'bar', 0, 1])
1076+
})
1077+
1078+
it('should not rollback optimistic updates if `rollbackOnError`', async () => {
1079+
const key = createKey()
1080+
const renderedData = []
1081+
let mutate
1082+
let cnt = 0
1083+
1084+
function Page() {
1085+
const { data, mutate: boundMutate } = useSWR(key, () =>
1086+
createResponse(cnt++, { delay: 20 })
1087+
)
1088+
mutate = boundMutate
1089+
if (
1090+
!renderedData.length ||
1091+
renderedData[renderedData.length - 1] !== data
1092+
) {
1093+
renderedData.push(data)
1094+
}
1095+
return <div>data: {String(data)}</div>
1096+
}
1097+
1098+
renderWithConfig(<Page />)
1099+
await screen.findByText('data: 0')
1100+
1101+
try {
1102+
await act(() =>
1103+
mutate(createResponse(new Error('baz'), { delay: 20 }), {
1104+
optimisticData: 'bar',
1105+
rollbackOnError: false
1106+
})
1107+
)
1108+
} catch (e) {
1109+
expect(e.message).toEqual('baz')
1110+
}
1111+
1112+
await sleep(30)
1113+
expect(renderedData).toEqual([undefined, 0, 'bar', 1])
1114+
})
10141115
})

0 commit comments

Comments
 (0)