From f98503e230e5aaa5fa7940e31d44356dd1372f65 Mon Sep 17 00:00:00 2001 From: Joseph Chamochumbi Date: Tue, 15 Jun 2021 10:37:11 +0200 Subject: [PATCH 1/2] feat: Use a `status` enum to label the current stage of a SWR hook --- src/types.ts | 1 + src/use-swr.ts | 36 +++++ test/use-swr-status.test.tsx | 282 +++++++++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 test/use-swr-status.test.tsx diff --git a/src/types.ts b/src/types.ts index d768cf50d..46ec88c45 100644 --- a/src/types.ts +++ b/src/types.ts @@ -151,6 +151,7 @@ export interface SWRResponse { revalidate: () => Promise mutate: KeyedMutator isValidating: boolean + status: 'loading' | 'validating' | 'error' | 'stale' } export type KeyLoader = diff --git a/src/use-swr.ts b/src/use-swr.ts index 27bf9452d..f6c86554a 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -434,6 +434,7 @@ function useSWR( delete CONCURRENT_PROMISES[key] delete CONCURRENT_PROMISES_TS[key] if (configRef.current.isPaused()) { + cache.set(keyValidating, false) setState({ isValidating: false }) @@ -446,6 +447,7 @@ function useSWR( if (stateRef.current.error !== err) { // we keep the stale data + // cache.set(keyValidating, false) setState({ isValidating: false, error: err @@ -458,6 +460,7 @@ function useSWR( // events and retry safeCallback(() => configRef.current.onError(err, key, config)) + if (config.shouldRetryOnError) { // when retrying, we always enable deduping safeCallback(() => @@ -466,6 +469,17 @@ function useSWR( 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 }) } } @@ -659,7 +673,9 @@ function useSWR( revalidate, mutate: boundMutate } as SWRResponse + const currentStateDependencies = stateDependenciesRef.current + Object.defineProperties(state, { data: { get: function() { @@ -681,6 +697,26 @@ function useSWR( 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 } }) diff --git a/test/use-swr-status.test.tsx b/test/use-swr-status.test.tsx new file mode 100644 index 000000000..aa89f8ec2 --- /dev/null +++ b/test/use-swr-status.test.tsx @@ -0,0 +1,282 @@ +import { act, fireEvent, render, screen, waitFor } 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 ( +
mutate()} data-testid="status"> + {status} +
+ ) + } + + const customCache = new Map() + const { cache } = createCache(customCache) + + render( + +
+ + ) + + 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 ( +
+
mutate()} data-testid="status"> + {status} +
+ {error && isValidating ? ( + Healing from error + ) : ( + Gave up + )} +
+ ) + } + + const customCache = new Map() + const { cache } = createCache(customCache) + + render( + +
+ + ) + + 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 ( +
+
mutate()} data-testid="status"> + {status} +
+ {error && isValidating ? ( + Healing from error + ) : ( + Gave up + )} +
+ ) + } + + const customCache = new Map() + const { cache } = createCache(customCache) + + render( + +
+ + ) + + 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 ( +
+
{status}
+ {error && isValidating ? ( + Healing from error + ) : ( + Gave up + )} +
+ ) + } + + const customCache = new Map() + const { cache } = createCache(customCache) + + render( + +
+ + ) + + 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 ( +
+
mutate()} data-testid="status"> + {status} +
+ {error && isValidating ? ( + Healing from error + ) : ( + Gave up + )} +
+ ) + } + + const customCache = new Map() + const { cache } = createCache(customCache) + + render( + +
+ + ) + + 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') + }) +}) From b75282d09623f03912b4edf7032c64f99124a2bf Mon Sep 17 00:00:00 2001 From: Joseph Chamochumbi Date: Tue, 15 Jun 2021 10:50:48 +0200 Subject: [PATCH 2/2] chore: Clean up unused code --- src/use-swr.ts | 1 - test/use-swr-status.test.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/use-swr.ts b/src/use-swr.ts index f6c86554a..55da187b9 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -447,7 +447,6 @@ function useSWR( if (stateRef.current.error !== err) { // we keep the stale data - // cache.set(keyValidating, false) setState({ isValidating: false, error: err diff --git a/test/use-swr-status.test.tsx b/test/use-swr-status.test.tsx index aa89f8ec2..88c5f361e 100644 --- a/test/use-swr-status.test.tsx +++ b/test/use-swr-status.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen } from '@testing-library/react' import React from 'react' import useSWR, { createCache, SWRConfig } from '../src' import { createKey } from './utils'