Skip to content

Commit

Permalink
fix race conditions
Browse files Browse the repository at this point in the history
  • Loading branch information
shuding committed May 18, 2022
1 parent b93d11d commit db069b1
Show file tree
Hide file tree
Showing 2 changed files with 87 additions and 18 deletions.
42 changes: 24 additions & 18 deletions _internal/utils/mutate.ts
Expand Up @@ -42,8 +42,8 @@ export const internalMutate = async <Data>(
const [get, set] = createCacheHelper<
Data,
State<Data, any> & {
// The original data.
_o?: Data
// The previously committed data.
_c?: Data
}
>(cache, key)
const [EVENT_REVALIDATORS, MUTATION, FETCH] = SWRGlobalState.get(
Expand Down Expand Up @@ -80,21 +80,27 @@ export const internalMutate = async <Data>(

const hasOptimisticData = !isUndefined(optimisticData)
const state = get()
const currentData = state.data
const originalData = isUndefined(state._o) ? currentData : state._o

// `displayedData` is the current value on screen. It could be the optimistic value
// that is going to be overridden by a `committedData`, or get reverted back.
// `committedData` is the validated value that comes from a fetch or mutation.
const displayedData = state.data
const committedData = isUndefined(state._c) ? displayedData : state._c

// Do optimistic data update.
if (hasOptimisticData) {
optimisticData = isFunction(optimisticData)
? optimisticData(originalData)
? optimisticData(committedData)
: optimisticData
set({ data: optimisticData, _o: originalData })

// When we set optimistic data, backup the current committedData data in `_c`.
set({ data: optimisticData, _c: committedData })
}

if (isFunction(data)) {
// `data` is a function, call it passing current cache value.
try {
data = (data as MutatorCallback<Data>)(originalData)
data = (data as MutatorCallback<Data>)(committedData)
} catch (err) {
// If it throws an error synchronously, we shouldn't update the cache.
error = err
Expand All @@ -119,27 +125,27 @@ export const internalMutate = async <Data>(
// Rollback. Always populate the cache in this case but without
// transforming the data.
populateCache = true
data = originalData
set({ data: originalData })
data = committedData

// Reset data to be the latest committed data, and clear the `_c` value.
set({ data, _c: UNDEFINED })
}
}

// If we should write back the cache after request.
if (populateCache) {
// Always update or reset the error.
const newState: State = { error }

if (!error) {
// Transform the result into data.
if (isFunction(populateCache)) {
data = populateCache(data, originalData)
data = populateCache(data, committedData)
}

// Only update cached data if there's no error. Data can be `undefined` here.
newState.data = data
set({ data, _c: UNDEFINED })
}

set(newState)
// Always update error and original data here.
set({ error })
}

// Reset the timestamp to mark the mutation has ended.
Expand All @@ -148,9 +154,9 @@ export const internalMutate = async <Data>(
// Update existing SWR Hooks' internal states:
const res = await startRevalidate()

// The mutation and revalidation are ended, we can reset the original data since
// the data is not an optimistic value anymore.
set({ _o: UNDEFINED })
// The mutation and revalidation are ended, we can clear it since the data is
// not an optimistic value anymore.
set({ _c: UNDEFINED })

// Throw error or return data
if (error) throw error
Expand Down
63 changes: 63 additions & 0 deletions test/use-swr-local-mutation.test.tsx
Expand Up @@ -1280,6 +1280,69 @@ describe('useSWR - local mutation', () => {
expect(renderedData).toEqual([undefined, 'foo', 'bar', 'baz', 'bar'])
})

it('should rollback to the original value after multiple mutations (2)', async () => {
const key = createKey()
const renderedData = []
let mutate
let serverData = 'foo'

function Page() {
const { data, mutate: boundMutate } = useSWR(key, () =>
createResponse(serverData, { delay: 20 })
)
mutate = boundMutate
if (
!renderedData.length ||
renderedData[renderedData.length - 1] !== data
) {
renderedData.push(data)
}
return <div>data: {String(data)}</div>
}

// data == "foo"
renderWithConfig(<Page />)
await screen.findByText('data: foo')

// Here m1 and m2 have overlap and m1 will be discarded.
await executeWithoutBatching(async () => {
const m1 = mutate(
createResponse('bar', { delay: 30 }).then(r => (serverData = r)),
{
optimisticData: 'bar',
populateCache: false
}
)

await sleep(10)

const m2 = mutate(
createResponse('baz', { delay: 30 }).then(r => (serverData = r))
)

await m1
await m2
})

try {
// data == "qux", then reverted back to "baz"
await executeWithoutBatching(() =>
mutate(createResponse(new Error(), { delay: 20 }), {
optimisticData: 'qux',
revalidate: false
})
)
} catch (_) {
// Ignore
}

// data: "foo" -> "bar" -> "baz" -> "qux" -> "baz"
// ^ optimistic ^ error

await sleep(30)
expect(renderedData).toEqual([undefined, 'foo', 'bar', 'baz', 'qux', 'baz'])
})

it('should not rollback optimistic updates if `rollbackOnError`', async () => {
const key = createKey()
const renderedData = []
Expand Down

0 comments on commit db069b1

Please sign in to comment.