Skip to content

[RFC] feat: Use a status enum to label the current stage of a SWR hook #1215

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export interface SWRResponse<Data, Error> {
revalidate: () => Promise<boolean>
mutate: KeyedMutator<Data>
isValidating: boolean
status: 'loading' | 'validating' | 'error' | 'stale'
}

export type KeyLoader<Data = any> =
Expand Down
35 changes: 35 additions & 0 deletions src/use-swr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ function useSWR<Data = any, Error = any>(
delete CONCURRENT_PROMISES[key]
delete CONCURRENT_PROMISES_TS[key]
if (configRef.current.isPaused()) {
cache.set(keyValidating, false)
setState({
isValidating: false
})
Expand All @@ -458,6 +459,7 @@ function useSWR<Data = any, Error = any>(

// events and retry
safeCallback(() => configRef.current.onError(err, key, config))

if (config.shouldRetryOnError) {
// when retrying, we always enable deduping
safeCallback(() =>
Expand All @@ -466,6 +468,17 @@ function useSWR<Data = any, Error = any>(
dedupe: true
})
)

// The default exponential backoff expands to count 8
const { errorRetryCount = 8 } = configRef.current

if (errorRetryCount === retryCount) {
cache.set(keyValidating, false)
setState({ isValidating: false, error: err })
}
} else {
cache.set(keyValidating, false)
setState({ isValidating: false, error: err })
}
}

Expand Down Expand Up @@ -659,7 +672,9 @@ function useSWR<Data = any, Error = any>(
revalidate,
mutate: boundMutate
} as SWRResponse<Data, Error>

const currentStateDependencies = stateDependenciesRef.current

Object.defineProperties(state, {
data: {
get: function() {
Expand All @@ -681,6 +696,26 @@ function useSWR<Data = any, Error = any>(
return isValidating
},
enumerable: true
},
status: {
get: function() {
currentStateDependencies.data = true
currentStateDependencies.error = true
currentStateDependencies.isValidating = true

switch (true) {
case isUndefined(data) && isUndefined(error):
return 'loading'
case isUndefined(data) && !isUndefined(error):
return 'error'
case isValidating:
return 'validating'
case data:
default:
return 'stale'
}
},
enumerable: true
}
})

Expand Down
282 changes: 282 additions & 0 deletions test/use-swr-status.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import React from 'react'
import useSWR, { createCache, SWRConfig } from '../src'
import { createKey } from './utils'

describe('useSWR - status', () => {
it('should update `status` when initial and revalidate requests are successful', async () => {
const fetcher = _key => new Promise(resolve => resolve('data'))
const key = createKey()

function Section() {
const { status, mutate } = useSWR(key, fetcher)

return (
<div onClick={() => mutate()} data-testid="status">
{status}
</div>
)
}

const customCache = new Map()
const { cache } = createCache(customCache)

render(
<SWRConfig value={{ cache }}>
<Section />
</SWRConfig>
)

expect(screen.getByText('loading')).toBeInTheDocument()

await screen.findByText('stale')

expect(screen.getByTestId('status')).toHaveTextContent('stale')

act(() => {
fireEvent.click(screen.getByTestId('status'))
})

expect(screen.getByText('validating')).toBeInTheDocument()

await screen.findByText('stale')

expect(screen.getByTestId('status')).toHaveTextContent('stale')
})

it('should update `status` when request fails but mutate resolves, no retry on error', async () => {
let init = false

const fetcher = _key => {
if (init) return new Promise(resolve => resolve('data'))
init = true

return new Promise((_, reject) => reject('reason'))
}

const key = createKey()

function Section() {
const { status, error, mutate, isValidating } = useSWR(key, fetcher)

return (
<div>
<div onClick={() => mutate()} data-testid="status">
{status}
</div>
{error && isValidating ? (
<span>Healing from error</span>
) : (
<span>Gave up</span>
)}
</div>
)
}

const customCache = new Map()
const { cache } = createCache(customCache)

render(
<SWRConfig value={{ cache, shouldRetryOnError: false }}>
<Section />
</SWRConfig>
)

expect(screen.getByText('loading')).toBeInTheDocument()

await screen.findByText('error')

expect(screen.getByTestId('status')).toHaveTextContent('error')

expect(screen.getByText('Gave up')).toBeInTheDocument()

act(() => {
fireEvent.click(screen.getByTestId('status'))
})

expect(screen.getByText('Healing from error')).toBeInTheDocument()

await screen.findByText('stale')

expect(screen.getByTestId('status')).toHaveTextContent('stale')
})

it('should update `status` when a request fails but mutate is successful, with retry on error', async () => {
let init = false

const fetcher = _key => {
if (init) return new Promise(resolve => resolve('data'))
init = true

return new Promise((_, reject) => reject('reason'))
}

const key = createKey()

function Section() {
const { status, error, mutate, isValidating } = useSWR(key, fetcher)

return (
<div>
<div onClick={() => mutate()} data-testid="status">
{status}
</div>
{error && isValidating ? (
<span>Healing from error</span>
) : (
<span>Gave up</span>
)}
</div>
)
}

const customCache = new Map()
const { cache } = createCache(customCache)

render(
<SWRConfig value={{ cache }}>
<Section />
</SWRConfig>
)

expect(screen.getByText('loading')).toBeInTheDocument()

await screen.findByText('error')

expect(screen.getByTestId('status')).toHaveTextContent('error')

expect(screen.getByText('Healing from error')).toBeInTheDocument()

act(() => {
fireEvent.click(screen.getByTestId('status'))
})

expect(screen.getByText('Healing from error')).toBeInTheDocument()

await screen.findByText('stale')

expect(screen.getByTestId('status')).toHaveTextContent('stale')
})

it('should update `status` when a request always fails, no mutate and with retry on error', async () => {
const fetcher = _key => {
return new Promise((_, reject) => reject('reason'))
}

const key = createKey()

function Section() {
const { status, error, isValidating } = useSWR(key, fetcher)

return (
<div>
<div data-testid="status">{status}</div>
{error && isValidating ? (
<span>Healing from error</span>
) : (
<span>Gave up</span>
)}
</div>
)
}

const customCache = new Map()
const { cache } = createCache(customCache)

render(
<SWRConfig
value={{
cache,
errorRetryInterval: 20,
errorRetryCount: 3
}}
>
<Section />
</SWRConfig>
)

expect(screen.getByText('loading')).toBeInTheDocument()

await screen.findByText('error')

expect(screen.getByTestId('status')).toHaveTextContent('error')

expect(screen.getByText('Healing from error')).toBeInTheDocument()

await screen.findByText('Gave up')

expect(screen.getByText('Gave up')).toBeInTheDocument()

expect(screen.getByTestId('status')).toHaveTextContent('error')
})

it('should update `status` when a request fails, fails to retry 3 times, and then mutate succeeds', async () => {
const initial = 1
const retries = 3
let count = 0
const fetcher = _key => {
if (count === retries + initial)
return new Promise(resolve => resolve('data'))
count += 1
return new Promise((_, reject) => reject('reason'))
}

const key = createKey()

function Section() {
const { status, error, mutate, isValidating } = useSWR(key, fetcher)

return (
<div>
<div onClick={() => mutate()} data-testid="status">
{status}
</div>
{error && isValidating ? (
<span>Healing from error</span>
) : (
<span>Gave up</span>
)}
</div>
)
}

const customCache = new Map()
const { cache } = createCache(customCache)

render(
<SWRConfig
value={{
cache,
errorRetryInterval: 20,
errorRetryCount: retries
}}
>
<Section />
</SWRConfig>
)

expect(screen.getByText('loading')).toBeInTheDocument()

await screen.findByText('error')

expect(screen.getByTestId('status')).toHaveTextContent('error')

expect(screen.getByText('Healing from error')).toBeInTheDocument()

await screen.findByText('Gave up')

expect(screen.getByText('Gave up')).toBeInTheDocument()

expect(screen.getByTestId('status')).toHaveTextContent('error')

act(() => {
fireEvent.click(screen.getByTestId('status'))
})

expect(screen.getByText('Healing from error')).toBeInTheDocument()

await screen.findByText('stale')

expect(screen.getByTestId('status')).toHaveTextContent('stale')
})
})