Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/_internal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@ import type * as revalidateEvents from './events'
export type GlobalState = [
Record<string, RevalidateCallback[]>, // EVENT_REVALIDATORS
Record<string, [number, number]>, // MUTATION: [ts, end_ts]
Record<string, [any, number]>, // FETCH: [data, ts]
Record<string, [any, number, AbortController?]>, // FETCH: [data, ts, abort_controller]
Record<string, FetcherResponse<any>>, // PRELOAD
ScopedMutator, // Mutator
(key: string, value: any, prev: any) => void, // Setter
(key: string, callback: (current: any, prev: any) => void) => () => void // Subscriber
]
// Extra options passed to the fetcher
export type FetcherOptions = {
/** An AbortSignal to support request cancellation */
signal: AbortSignal
}
export type FetcherResponse<Data = unknown> = Data | Promise<Data>
export type BareFetcher<Data = unknown> = (
...args: any[]
Expand All @@ -18,11 +23,11 @@ export type Fetcher<
Data = unknown,
SWRKey extends Key = Key
> = SWRKey extends () => infer Arg | null | undefined | false
? (arg: Arg) => FetcherResponse<Data>
? (arg: Arg, options: FetcherOptions) => FetcherResponse<Data>
: SWRKey extends null | undefined | false
? never
: SWRKey extends infer Arg
? (arg: Arg) => FetcherResponse<Data>
? (arg: Arg, options: FetcherOptions) => FetcherResponse<Data>
: never

export type BlockingData<
Expand Down
6 changes: 6 additions & 0 deletions src/_internal/utils/mutate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ export async function internalMutate<Data>(
? options.revalidate(get().data, _k)
: options.revalidate !== false
if (revalidate) {
// Cancel ongoing fetches
const maybeCurrentFetchController = FETCH[key]?.[2]
if (maybeCurrentFetchController) {
maybeCurrentFetchController.abort()
}

// Invalidate the key by deleting the concurrent request markers so new
// requests will not be deduped.
delete FETCH[key]
Expand Down
26 changes: 20 additions & 6 deletions src/index/use-swr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,9 +440,21 @@ export const useSWRHandler = <Data = any, Error = any>(

// Start the request and save the timestamp.
// Key must be truthy if entering here.

// We also need to abort the current fetch as its result must be
// discarded anyway.
const maybeCurrentFetchController = FETCH[key]?.[2]
if (maybeCurrentFetchController) {
// Abort the ongoing fetch request if any.
maybeCurrentFetchController.abort()
}

const abortController = new AbortController()
const signal = abortController.signal
FETCH[key] = [
currentFetcher(fnArg as DefinitelyTruthy<Key>),
getTimestamp()
currentFetcher(fnArg as DefinitelyTruthy<Key>, { signal }),
getTimestamp(),
abortController
]
}

Expand All @@ -452,17 +464,18 @@ export const useSWRHandler = <Data = any, Error = any>(
newData = await newData

if (shouldStartNewRequest) {
// If the request isn't interrupted, clean it up after the
// If the request wasn't interrupted, clean it up after the
// deduplication interval.
setTimeout(cleanupState, config.dedupingInterval)
}

// If there're other ongoing request(s), started after the current one,
// If there're other new request(s) started after the current one,
// we need to ignore the current one to avoid possible race conditions:
// req1------------------>res1 (current one)
// req2---------------->res2
// the request that fired later will always be kept.
// The timestamp maybe be `undefined` or a number
// Requests fired later will always be kept.

// The timestamp maybe be `undefined` or a number:
if (!FETCH[key] || FETCH[key][1] !== startAt) {
if (shouldStartNewRequest) {
if (callbackSafeguard()) {
Expand Down Expand Up @@ -505,6 +518,7 @@ export const useSWRHandler = <Data = any, Error = any>(
}
return false
}

// Deep compare with the latest state to avoid extra re-renders.
// For local state, compare and assign.
const cacheData = getCache().data
Expand Down
218 changes: 218 additions & 0 deletions test/use-swr-auto-abort.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { act, screen } from '@testing-library/react'
import useSWR from 'swr'
import { createKey, renderWithConfig, sleep } from './utils'

describe('useSWR - auto abort', () => {
it('should abort previous request when a new request starts', async () => {
const key = createKey()
let abortedCount = 0
let fetchCount = 0

const fetcher = async (
_key: string,
{ signal }: { signal: AbortSignal }
) => {
fetchCount++
const currentFetch = fetchCount

signal.addEventListener('abort', () => {
abortedCount++
})

await sleep(100)

if (signal.aborted) {
throw new Error(`aborted-${currentFetch}`)
}

return `response-${currentFetch}`
}

let mutate: any

function Page() {
const { data, mutate: boundMutate, error } = useSWR(key, fetcher)
mutate = boundMutate

return (
<div>
data:{data},error:{error?.message}
</div>
)
}

renderWithConfig(<Page />)

// Make sure the first request is ongoing
await sleep(20)

// Immediately trigger a mutation
await act(() => mutate())

// Final state should be from the mutation
await screen.findByText(/data:response-2/)

// The subsequent requests should have aborted previous ones
expect(fetchCount).toBe(2)
expect(abortedCount).toBe(1)
})

it('should pass AbortSignal to fetcher', async () => {
const key = createKey()
let receivedSignal: AbortSignal | undefined

const fetcher = async (
_key: string,
{ signal }: { signal: AbortSignal }
) => {
receivedSignal = signal
await sleep(10)
return 'data'
}

function Page() {
const { data } = useSWR(key, fetcher)
return <div>data:{data}</div>
}

renderWithConfig(<Page />)

await screen.findByText('data:data')

// Verify that an AbortSignal was passed to the fetcher
expect(receivedSignal).toBeInstanceOf(AbortSignal)
expect(receivedSignal.aborted).toBe(false)
})

it('should not abort request during deduplication', async () => {
const key = createKey()
let abortedCount = 0
let fetchCount = 0

const fetcher = async (
_key: string,
{ signal }: { signal?: AbortSignal }
) => {
fetchCount++

signal?.addEventListener('abort', () => {
abortedCount++
})

await sleep(100)
return 'data'
}

function Page1() {
const { data } = useSWR(key, fetcher)
return <div>page1:{data}</div>
}

function Page2() {
const { data } = useSWR(key, fetcher)
return <div>page2:{data}</div>
}

renderWithConfig(
<>
<Page1 />
<Page2 />
</>
)

await screen.findByText('page1:data')
await screen.findByText('page2:data')

// Should only fetch once due to deduplication
expect(fetchCount).toBe(1)
// No aborts should have occurred
expect(abortedCount).toBe(0)
})

it('should handle fetch errors gracefully when aborted', async () => {
const key = createKey()
let fetchCount = 0

const fetcher = async (
_key: string,
{ signal }: { signal?: AbortSignal }
) => {
fetchCount++

await sleep(50)

if (signal?.aborted) {
const error = new Error('Aborted')
error.name = 'AbortError'
throw error
}

return 'data'
}

let mutate: any

function Page() {
const {
data,
error,
mutate: boundMutate
} = useSWR(key, fetcher, {
// Disable error retry to make the test faster
shouldRetryOnError: false
})
mutate = boundMutate

return (
<div>
<div>data:{data}</div>
{error && <div>error:{error.message}</div>}
</div>
)
}

renderWithConfig(<Page />)

// Wait for initial fetch
await screen.findByText('data:data')

// Trigger rapid revalidations
await act(() => mutate())

await sleep(10)

await act(() => mutate())

// Wait a bit for things to settle
await sleep(200)

// Should eventually show data (not error)
expect(screen.getByText(/data:data/)).toBeInTheDocument()

Check failure on line 190 in test/use-swr-auto-abort.test.tsx

View workflow job for this annotation

GitHub Actions / test

Property 'toBeInTheDocument' does not exist on type 'JestMatchers<HTMLElement>'.
expect(fetchCount).toBeGreaterThan(1)
})

it('should cleanup abort controller after request completes', async () => {
const key = createKey()
let signal: AbortSignal | undefined

const fetcher = async (_key: string, opts: { signal: AbortSignal }) => {
signal = opts.signal
await sleep(50)
return 'data'
}

function Page() {
const { data } = useSWR(key, fetcher)
return <div>data:{data}</div>
}

renderWithConfig(<Page />)

await screen.findByText('data:data')

// After the request completes, the signal should not be aborted
// (it's cleaned up properly)
expect(signal).toBeDefined()
expect(signal?.aborted).toBe(false)
})
})
Loading