Skip to content

Commit 5dbd6d5

Browse files
authored
Fix race condition when calling mutate synchronously (#735)
* fix race condition when calling mutate synchronously * fix test * add comment * fix code reviews
1 parent 9e734aa commit 5dbd6d5

File tree

2 files changed

+91
-26
lines changed

2 files changed

+91
-26
lines changed

src/use-swr.ts

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ const CACHE_REVALIDATORS = {}
4545
const MUTATION_TS = {}
4646
const MUTATION_END_TS = {}
4747

48+
// generate strictly increasing timestamps
49+
const now = (() => {
50+
let ts = 0
51+
return () => ts++
52+
})()
53+
4854
// setup DOM events listeners for `focus` and `reconnect` actions
4955
if (!IS_SERVER && window.addEventListener) {
5056
const revalidate = revalidators => {
@@ -122,28 +128,32 @@ const mutate: mutateInterface = async (
122128
const [key, , keyErr] = cache.serializeKey(_key)
123129
if (!key) return
124130

125-
// if there is no new data, call revalidate against the key
131+
// if there is no new data to update, let's just revalidate the key
126132
if (typeof _data === 'undefined') return trigger(_key, shouldRevalidate)
127133

128-
// update timestamps
129-
MUTATION_TS[key] = Date.now() - 1
134+
// update global timestamps
135+
MUTATION_TS[key] = now() - 1
130136
MUTATION_END_TS[key] = 0
131137

132-
// keep track of timestamps before await asynchronously
138+
// track timestamps before await asynchronously
133139
const beforeMutationTs = MUTATION_TS[key]
134140
const beforeConcurrentPromisesTs = CONCURRENT_PROMISES_TS[key]
135141

136142
let data, error
143+
let isAsyncMutation = false
137144

138145
if (_data && typeof _data === 'function') {
139146
// `_data` is a function, call it passing current cache value
140147
try {
141-
data = await _data(cache.get(key))
148+
_data = _data(cache.get(key))
142149
} catch (err) {
143150
error = err
144151
}
145-
} else if (_data && typeof _data.then === 'function') {
152+
}
153+
154+
if (_data && typeof _data.then === 'function') {
146155
// `_data` is a promise
156+
isAsyncMutation = true
147157
try {
148158
data = await _data
149159
} catch (err) {
@@ -153,23 +163,39 @@ const mutate: mutateInterface = async (
153163
data = _data
154164
}
155165

156-
// check if other mutations have occurred since we've started awaiting, if so then do not persist this change
157-
if (
158-
beforeMutationTs !== MUTATION_TS[key] ||
159-
beforeConcurrentPromisesTs !== CONCURRENT_PROMISES_TS[key]
160-
) {
161-
if (error) throw error
162-
return data
166+
const shouldAbort = (): boolean | void => {
167+
// check if other mutations have occurred since we've started this mutation
168+
if (
169+
beforeMutationTs !== MUTATION_TS[key] ||
170+
beforeConcurrentPromisesTs !== CONCURRENT_PROMISES_TS[key]
171+
) {
172+
if (error) throw error
173+
return true
174+
}
163175
}
164176

177+
// if there's a race we don't update cache or broadcast change, just return the data
178+
if (shouldAbort()) return data
179+
165180
if (typeof data !== 'undefined') {
166-
// update cached data, avoid notifying from the cache
181+
// update cached data
167182
cache.set(key, data)
168183
}
184+
// always update or reset the error
169185
cache.set(keyErr, error)
170186

171187
// reset the timestamp to mark the mutation has ended
172-
MUTATION_END_TS[key] = Date.now() - 1
188+
MUTATION_END_TS[key] = now() - 1
189+
190+
if (!isAsyncMutation) {
191+
// let's always broadcast in the next tick
192+
// to dedupe synchronous mutation calls
193+
// check out https://github.com/vercel/swr/pull/735 for more details
194+
await 0
195+
196+
// we skip broadcasting if there's another mutation happened synchronously
197+
if (shouldAbort()) return data
198+
}
173199

174200
// enter the revalidation stage
175201
// update existing SWR Hooks' state
@@ -385,7 +411,7 @@ function useSWR<Data = any, Error = any>(
385411
CONCURRENT_PROMISES[key] = fn(key)
386412
}
387413

388-
CONCURRENT_PROMISES_TS[key] = startAt = Date.now()
414+
CONCURRENT_PROMISES_TS[key] = startAt = now()
389415

390416
newData = await CONCURRENT_PROMISES[key]
391417

test/use-swr.test.tsx

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1524,7 +1524,8 @@ describe('useSWR - local mutation', () => {
15241524
)
15251525
// call bound mutate
15261526
fireEvent.click(container.firstElementChild)
1527-
// expect new updated value
1527+
// expect new updated value (after a tick)
1528+
await 0
15281529
expect(container.firstChild.textContent).toMatchInlineSnapshot(
15291530
`"data: mutated"`
15301531
)
@@ -1547,6 +1548,7 @@ describe('useSWR - local mutation', () => {
15471548
expect(container.textContent).toMatchInlineSnapshot(`"1"`) // directly from cache
15481549
await act(() => new Promise(res => setTimeout(res, 150))) // still suspending
15491550
mutate('mutate-2', 3) // set it to 3. this will drop the ongoing request
1551+
await 0
15501552
expect(container.textContent).toMatchInlineSnapshot(`"3"`)
15511553
await act(() => new Promise(res => setTimeout(res, 100)))
15521554
expect(container.textContent).toMatchInlineSnapshot(`"3"`)
@@ -1568,9 +1570,14 @@ describe('useSWR - local mutation', () => {
15681570
await act(() => new Promise(res => setTimeout(res, 250)))
15691571
expect(container.textContent).toMatchInlineSnapshot(`"off"`) // Initial state
15701572

1573+
mutate('mutate-3', 'on', false)
1574+
1575+
// Validate local state is now "on"
1576+
await 0
1577+
expect(container.textContent).toMatchInlineSnapshot(`"on"`)
1578+
15711579
// Simulate toggling "on"
15721580
await act(async () => {
1573-
mutate('mutate-3', 'on', false)
15741581
expect(
15751582
mutate(
15761583
'mutate-3',
@@ -1585,12 +1592,14 @@ describe('useSWR - local mutation', () => {
15851592
).resolves.toBe('on')
15861593
})
15871594

1588-
// Validate local state is now "on"
1589-
expect(container.textContent).toMatchInlineSnapshot(`"on"`)
1595+
mutate('mutate-3', 'off', false)
1596+
1597+
// Validate local state is now "off"
1598+
await 0
1599+
expect(container.textContent).toMatchInlineSnapshot(`"off"`)
15901600

15911601
// Simulate toggling "off"
15921602
await act(async () => {
1593-
mutate('mutate-3', 'off', false)
15941603
expect(
15951604
mutate(
15961605
'mutate-3',
@@ -1605,15 +1614,12 @@ describe('useSWR - local mutation', () => {
16051614
).resolves.toBe('off')
16061615
})
16071616

1608-
// Validate local state is now "off"
1609-
expect(container.textContent).toMatchInlineSnapshot(`"off"`)
1610-
16111617
// Wait for toggling "on" promise to resolve, but the "on" mutation is cancelled
1612-
await act(() => new Promise(res => setTimeout(res, 200)))
1618+
await act(() => new Promise(res => setTimeout(res, 210)))
16131619
expect(container.textContent).toMatchInlineSnapshot(`"off"`)
16141620

16151621
// Wait for toggling "off" promise to resolve
1616-
await act(() => new Promise(res => setTimeout(res, 200)))
1622+
await act(() => new Promise(res => setTimeout(res, 210)))
16171623
expect(container.textContent).toMatchInlineSnapshot(`"off"`)
16181624
})
16191625

@@ -1731,6 +1737,39 @@ describe('useSWR - local mutation', () => {
17311737
expect(ref).toEqual(refs[0])
17321738
}
17331739
})
1740+
1741+
it('should dedupe synchronous mutations', async () => {
1742+
const mutationRecivedValues = []
1743+
const renderRecivedValues = []
1744+
1745+
function Component() {
1746+
const { data, mutate: boundMutate } = useSWR('mutate-6', () => 0)
1747+
1748+
useEffect(() => {
1749+
setTimeout(() => {
1750+
// let's mutate twice, synchronously
1751+
boundMutate(v => {
1752+
mutationRecivedValues.push(v) // should be 0
1753+
return 1
1754+
}, false)
1755+
boundMutate(v => {
1756+
mutationRecivedValues.push(v) // should be 1
1757+
return 2
1758+
}, false)
1759+
}, 1)
1760+
}, [])
1761+
1762+
renderRecivedValues.push(data) // should be 0 -> 2, never render 1 in between
1763+
return null
1764+
}
1765+
1766+
render(<Component />)
1767+
1768+
await act(() => new Promise(res => setTimeout(res, 50)))
1769+
1770+
expect(mutationRecivedValues).toEqual([0, 1])
1771+
expect(renderRecivedValues).toEqual([undefined, 0, 2])
1772+
})
17341773
})
17351774

17361775
describe('useSWR - context configs', () => {

0 commit comments

Comments
 (0)