From 64ecddc52727b8209254520fc874aafba8846a02 Mon Sep 17 00:00:00 2001 From: Yixuan Xu Date: Fri, 9 Jun 2023 16:32:54 +0800 Subject: [PATCH 01/12] refactor: improve preload and suspense integrate --- _internal/types.ts | 8 ++- _internal/utils/helper.ts | 2 + _internal/utils/mutate.ts | 5 +- _internal/utils/preload.ts | 34 ++++++++-- core/use-swr.ts | 64 +++++++++++-------- e2e/site/app/suspense-after-preload/page.tsx | 14 ++++ .../suspense-after-preload/remote-data.tsx | 55 ++++++++++++++++ e2e/test/initial-render.test.ts | 8 +++ 8 files changed, 154 insertions(+), 36 deletions(-) create mode 100644 e2e/site/app/suspense-after-preload/page.tsx create mode 100644 e2e/site/app/suspense-after-preload/remote-data.tsx diff --git a/_internal/types.ts b/_internal/types.ts index f2a8b750a..c74fa6be9 100644 --- a/_internal/types.ts +++ b/_internal/types.ts @@ -5,7 +5,7 @@ export type GlobalState = [ Record, // EVENT_REVALIDATORS Record, // MUTATION: [ts, end_ts] Record, // FETCH: [data, ts] - Record>, // PRELOAD + Record | ReactUsePromise>, // PRELOAD ScopedMutator, // Mutator (key: string, value: any, prev: any) => void, // Setter (key: string, callback: (current: any, prev: any) => void) => () => void // Subscriber @@ -25,6 +25,12 @@ export type Fetcher< ? (arg: Arg) => FetcherResponse : never +export type ReactUsePromise = Promise & { + status?: 'pending' | 'fulfilled' | 'rejected' + value?: T + reason?: Error +} + export type BlockingData< Data = any, Options = SWROptions diff --git a/_internal/utils/helper.ts b/_internal/utils/helper.ts index 7356b9426..3c18cb763 100644 --- a/_internal/utils/helper.ts +++ b/_internal/utils/helper.ts @@ -20,6 +20,8 @@ export const isFunction = < v: unknown ): v is T => typeof v == 'function' export const mergeObjects = (a: any, b?: any) => ({ ...a, ...b }) +export const isPromiseLike = (x: unknown): x is PromiseLike => + isFunction((x as any).then) const STR_UNDEFINED = 'undefined' diff --git a/_internal/utils/mutate.ts b/_internal/utils/mutate.ts index b75274db3..36bb7b90d 100644 --- a/_internal/utils/mutate.ts +++ b/_internal/utils/mutate.ts @@ -4,7 +4,8 @@ import { isFunction, isUndefined, UNDEFINED, - mergeObjects + mergeObjects, + isPromiseLike } from './helper' import { SWRGlobalState } from './global-state' import { getTimestamp } from './timestamp' @@ -156,7 +157,7 @@ export async function internalMutate( } // `data` is a promise/thenable, resolve the final data first. - if (data && isFunction((data as Promise).then)) { + if (data && isPromiseLike(data)) { // This means that the mutation is async, we need to check timestamps to // avoid race conditions. data = await (data as Promise).catch(err => { diff --git a/_internal/utils/preload.ts b/_internal/utils/preload.ts index cdcbf2751..905f32799 100644 --- a/_internal/utils/preload.ts +++ b/_internal/utils/preload.ts @@ -3,12 +3,13 @@ import type { Key, BareFetcher, GlobalState, - FetcherResponse + FetcherResponse, + ReactUsePromise } from '../types' import { serialize } from './serialize' import { cache } from './config' import { SWRGlobalState } from './global-state' - +import { isPromiseLike, isUndefined } from './helper' // Basically same as Fetcher but without Conditional Fetching type PreloadFetcher< Data = unknown, @@ -26,15 +27,24 @@ export const preload = < >( key_: SWRKey, fetcher: Fetcher -): ReturnType => { +): FetcherResponse => { const [key, fnArg] = serialize(key_) const [, , , PRELOAD] = SWRGlobalState.get(cache) as GlobalState // Prevent preload to be called multiple times before used. if (PRELOAD[key]) return PRELOAD[key] - const req = fetcher(fnArg) as ReturnType + const req = fetcher(fnArg) as FetcherResponse PRELOAD[key] = req + if (!isUndefined(req) && isPromiseLike(req)) { + return req.then(data => { + const promise = Promise.resolve(data) as ReactUsePromise + promise.value = data + promise.status = 'fulfilled' + PRELOAD[key] = promise + return data + }) + } return req } @@ -47,11 +57,23 @@ export const middleware: Middleware = const [key] = serialize(key_) const [, , , PRELOAD] = SWRGlobalState.get(cache) as GlobalState const req = PRELOAD[key] - if (req) { + if (isUndefined(req)) return fetcher_(...args) + if ( + !isPromiseLike(req) || + (req as ReactUsePromise).status === 'fulfilled' + ) { delete PRELOAD[key] return req } - return fetcher_(...args) + return ( + req.then(data => { + delete PRELOAD[key] + return data + }) as Promise + ).catch(err => { + delete PRELOAD[key] + throw err + }) }) return useSWRNext(key_, fetcher, config) } diff --git a/core/use-swr.ts b/core/use-swr.ts index 1677d36ac..c658c9620 100644 --- a/core/use-swr.ts +++ b/core/use-swr.ts @@ -26,7 +26,8 @@ import { getTimestamp, internalMutate, revalidateEvents, - mergeObjects + mergeObjects, + isPromiseLike } from 'swr/_internal' import type { State, @@ -39,7 +40,8 @@ import type { SWRHook, RevalidateEvent, StateDependencies, - GlobalState + GlobalState, + ReactUsePromise } from 'swr/_internal' const use = @@ -105,7 +107,7 @@ export const useSWRHandler = ( keepPreviousData } = config - const [EVENT_REVALIDATORS, MUTATION, FETCH] = SWRGlobalState.get( + const [EVENT_REVALIDATORS, MUTATION, FETCH, PRELOAD] = SWRGlobalState.get( cache ) as GlobalState @@ -682,6 +684,25 @@ export const useSWRHandler = ( // Display debug info in React DevTools. useDebugValue(returnedData) + const result = { + mutate: boundMutate, + get data() { + stateDependencies.data = true + return returnedData + }, + get error() { + stateDependencies.error = true + return error + }, + get isValidating() { + stateDependencies.isValidating = true + return isValidating + }, + get isLoading() { + stateDependencies.isLoading = true + return isLoading + } + } as SWRResponse // In Suspense mode, we can't return the empty `data` state. // If there is an `error`, the `error` needs to be thrown to the error boundary. // If there is no `error`, the `revalidation` promise needs to be thrown to @@ -698,13 +719,20 @@ export const useSWRHandler = ( fetcherRef.current = fetcher configRef.current = config unmountedRef.current = false + const req = PRELOAD[key] + if (!isUndefined(req) && isPromiseLike(req)) { + const promise: ReactUsePromise = boundMutate(req as Promise) + if ((req as ReactUsePromise).status === 'fulfilled') { + promise.status = 'fulfilled' + promise.value = (req as ReactUsePromise).value + } + use(promise as Promise) + delete PRELOAD[key] + return result + } if (isUndefined(error)) { - const promise: Promise & { - status?: 'pending' | 'fulfilled' | 'rejected' - value?: boolean - reason?: unknown - } = revalidate(WITH_DEDUPE) + const promise: ReactUsePromise = revalidate(WITH_DEDUPE) if (!isUndefined(returnedData)) { promise.status = 'fulfilled' promise.value = true @@ -715,25 +743,7 @@ export const useSWRHandler = ( } } - return { - mutate: boundMutate, - get data() { - stateDependencies.data = true - return returnedData - }, - get error() { - stateDependencies.error = true - return error - }, - get isValidating() { - stateDependencies.isValidating = true - return isValidating - }, - get isLoading() { - stateDependencies.isLoading = true - return isLoading - } - } as SWRResponse + return result } export const SWRConfig = OBJECT.defineProperty(ConfigProvider, 'defaultValue', { diff --git a/e2e/site/app/suspense-after-preload/page.tsx b/e2e/site/app/suspense-after-preload/page.tsx new file mode 100644 index 000000000..9e0ceaa82 --- /dev/null +++ b/e2e/site/app/suspense-after-preload/page.tsx @@ -0,0 +1,14 @@ +import { Suspense } from 'react' +import dynamic from 'next/dynamic' + +const RemoteData = dynamic(() => import('./remote-data'), { + ssr: false +}) + +export default function HomePage() { + return ( + loading component}> + + + ) +} diff --git a/e2e/site/app/suspense-after-preload/remote-data.tsx b/e2e/site/app/suspense-after-preload/remote-data.tsx new file mode 100644 index 000000000..0e8d1e51f --- /dev/null +++ b/e2e/site/app/suspense-after-preload/remote-data.tsx @@ -0,0 +1,55 @@ +'use client' +import { Suspense, useState, Profiler } from 'react' +import useSWR from 'swr' +import { preload } from 'swr' + +const fetcher = ([key, delay]: [key: string, delay: number]) => + new Promise(r => { + setTimeout(r, delay, key) + }) + +const key = ['suspense-after-preload', 300] as const +const useRemoteData = () => + useSWR(key, fetcher, { + suspense: true + }) + +const Demo = () => { + const { data } = useRemoteData() + return
{data}
+} + +function Comp() { + const [show, toggle] = useState(false) + + return ( +
+ + {show ? ( + { + ;(window as any).onRender('render') + }} + > +
loading
+ + } + > + +
+ ) : null} +
+ ) +} + +export default Comp diff --git a/e2e/test/initial-render.test.ts b/e2e/test/initial-render.test.ts index ce5b4e879..81df89d24 100644 --- a/e2e/test/initial-render.test.ts +++ b/e2e/test/initial-render.test.ts @@ -15,4 +15,12 @@ test.describe('rendering', () => { await sleep(1200) expect(log).toHaveLength(1) }) + test('should not suspense when using resolved preload', async ({ page }) => { + const log: any[] = [] + await page.exposeFunction('onRender', (msg: any) => log.push(msg)) + await page.goto('./suspense-after-preload', { waitUntil: 'commit' }) + await page.getByRole('button', { name: 'preload' }).click() + await expect(page.getByText('suspense-after-preload')).toBeVisible() + expect(log).toHaveLength(0) + }) }) From ad85d4e71f4a756b22a1b6b43f63c16e2e9ce3bd Mon Sep 17 00:00:00 2001 From: Yixuan Xu Date: Fri, 9 Jun 2023 17:16:36 +0800 Subject: [PATCH 02/12] fix: preload return type --- _internal/utils/preload.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/_internal/utils/preload.ts b/_internal/utils/preload.ts index 905f32799..5b047c87f 100644 --- a/_internal/utils/preload.ts +++ b/_internal/utils/preload.ts @@ -27,7 +27,7 @@ export const preload = < >( key_: SWRKey, fetcher: Fetcher -): FetcherResponse => { +): ReturnType => { const [key, fnArg] = serialize(key_) const [, , , PRELOAD] = SWRGlobalState.get(cache) as GlobalState @@ -43,9 +43,9 @@ export const preload = < promise.status = 'fulfilled' PRELOAD[key] = promise return data - }) + }) as ReturnType } - return req + return req as ReturnType } export const middleware: Middleware = From 59e1093cdf98e5e59ad37b2aec2f482d3dacf162 Mon Sep 17 00:00:00 2001 From: Yixuan Xu Date: Fri, 9 Jun 2023 18:48:07 +0800 Subject: [PATCH 03/12] examples: add suspense error retry example --- core/use-swr.ts | 2 +- examples/suspense-retry/app/api/route.ts | 11 +++++ examples/suspense-retry/app/favicon.ico | Bin 0 -> 39535 bytes examples/suspense-retry/app/layout.tsx | 11 +++++ examples/suspense-retry/app/manual-retry.tsx | 44 ++++++++++++++++++ examples/suspense-retry/app/page.tsx | 14 ++++++ .../suspense-retry/app/use-remote-data.ts | 21 +++++++++ examples/suspense-retry/next-env.d.ts | 5 ++ examples/suspense-retry/next.config.js | 8 ++++ examples/suspense-retry/package.json | 21 +++++++++ examples/suspense-retry/tsconfig.json | 29 ++++++++++++ package.json | 1 + pnpm-lock.yaml | 12 +++++ 13 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 examples/suspense-retry/app/api/route.ts create mode 100644 examples/suspense-retry/app/favicon.ico create mode 100644 examples/suspense-retry/app/layout.tsx create mode 100644 examples/suspense-retry/app/manual-retry.tsx create mode 100644 examples/suspense-retry/app/page.tsx create mode 100644 examples/suspense-retry/app/use-remote-data.ts create mode 100644 examples/suspense-retry/next-env.d.ts create mode 100644 examples/suspense-retry/next.config.js create mode 100644 examples/suspense-retry/package.json create mode 100644 examples/suspense-retry/tsconfig.json diff --git a/core/use-swr.ts b/core/use-swr.ts index c658c9620..21abdc301 100644 --- a/core/use-swr.ts +++ b/core/use-swr.ts @@ -726,8 +726,8 @@ export const useSWRHandler = ( promise.status = 'fulfilled' promise.value = (req as ReactUsePromise).value } - use(promise as Promise) delete PRELOAD[key] + use(promise as Promise) return result } diff --git a/examples/suspense-retry/app/api/route.ts b/examples/suspense-retry/app/api/route.ts new file mode 100644 index 000000000..1447da32a --- /dev/null +++ b/examples/suspense-retry/app/api/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server' + +export const GET = () => { + return Math.random() < 0.5 + ? NextResponse.json({ + data: 'success' + }) + : new Response('Bad', { + status: 500 + }) +} diff --git a/examples/suspense-retry/app/favicon.ico b/examples/suspense-retry/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4570eb8d9269ad58b17fecbec6d630cded56f507 GIT binary patch literal 39535 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 literal 0 HcmV?d00001 diff --git a/examples/suspense-retry/app/layout.tsx b/examples/suspense-retry/app/layout.tsx new file mode 100644 index 000000000..73a55974b --- /dev/null +++ b/examples/suspense-retry/app/layout.tsx @@ -0,0 +1,11 @@ +export default function RootLayout({ + children +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/examples/suspense-retry/app/manual-retry.tsx b/examples/suspense-retry/app/manual-retry.tsx new file mode 100644 index 000000000..71e53c8d2 --- /dev/null +++ b/examples/suspense-retry/app/manual-retry.tsx @@ -0,0 +1,44 @@ +'use client' +import { Suspense } from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { useRemoteData, preloadRemote } from './use-remote-data' + +const Demo = () => { + const { data } = useRemoteData() + return
{data}
+} +preloadRemote() + +function Fallback({ resetErrorBoundary }: any) { + return ( +
+

Something went wrong:

+ +
+ ) +} + +function RemoteData() { + return ( +
+ { + preloadRemote() + }} + > + loading
}> + + + + + ) +} + +export default RemoteData diff --git a/examples/suspense-retry/app/page.tsx b/examples/suspense-retry/app/page.tsx new file mode 100644 index 000000000..b590f9df8 --- /dev/null +++ b/examples/suspense-retry/app/page.tsx @@ -0,0 +1,14 @@ +import { Suspense } from 'react' +import dynamic from 'next/dynamic' + +const RemoteData = dynamic(() => import('./manual-retry'), { + ssr: false +}) + +export default function HomePage() { + return ( + loading component}> + + + ) +} diff --git a/examples/suspense-retry/app/use-remote-data.ts b/examples/suspense-retry/app/use-remote-data.ts new file mode 100644 index 000000000..952927992 --- /dev/null +++ b/examples/suspense-retry/app/use-remote-data.ts @@ -0,0 +1,21 @@ +'use client' +import useSWR from 'swr' +import { preload } from 'swr' + +let count = 0 +const fetcher = () => { + count++ + if (count === 1) return Promise.reject('wrong') + return fetch('/api') + .then(r => r.json()) + .then(r => r.data) +} + +const key = 'manual-retry' + +export const useRemoteData = () => + useSWR(key, fetcher, { + suspense: true + }) + +export const preloadRemote = () => preload(key, fetcher) diff --git a/examples/suspense-retry/next-env.d.ts b/examples/suspense-retry/next-env.d.ts new file mode 100644 index 000000000..4f11a03dc --- /dev/null +++ b/examples/suspense-retry/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/examples/suspense-retry/next.config.js b/examples/suspense-retry/next.config.js new file mode 100644 index 000000000..950e2f42e --- /dev/null +++ b/examples/suspense-retry/next.config.js @@ -0,0 +1,8 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + serverActions: true, + }, +} + +module.exports = nextConfig diff --git a/examples/suspense-retry/package.json b/examples/suspense-retry/package.json new file mode 100644 index 000000000..241b88d99 --- /dev/null +++ b/examples/suspense-retry/package.json @@ -0,0 +1,21 @@ +{ + "name": "site", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@types/node": "^20.2.5", + "@types/react": "^18.2.8", + "@types/react-dom": "18.2.4", + "next": "^13.4.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "5.1.3", + "swr": "*" + } +} diff --git a/examples/suspense-retry/tsconfig.json b/examples/suspense-retry/tsconfig.json new file mode 100644 index 000000000..ebd7bfca4 --- /dev/null +++ b/examples/suspense-retry/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "baseUrl": ".", + "paths": { + "~/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/package.json b/package.json index caa1383ad..02408c87f 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "prettier": "2.8.8", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-error-boundary": "^4.0.9", "rimraf": "5.0.1", "semver": "^7.5.1", "swr": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21a40cc49..73c6ba502 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-error-boundary: + specifier: ^4.0.9 + version: 4.0.9(react@18.2.0) rimraf: specifier: 5.0.1 version: 5.0.1 @@ -4504,6 +4507,15 @@ packages: scheduler: 0.23.0 dev: true + /react-error-boundary@4.0.9(react@18.2.0): + resolution: {integrity: sha512-f6DcHVdTDZmc9ixmRmuLDZpkdghYR/HKZdUzMLHD58s4cR2C4R6y4ktYztCosM6pyeK4/C8IofwqxgID25W6kw==} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.18.9 + react: 18.2.0 + dev: true + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: true From 9704af7b2a3c6b7e01c43731555cd11b3a66942d Mon Sep 17 00:00:00 2001 From: Yixuan Xu Date: Fri, 9 Jun 2023 21:04:55 +0800 Subject: [PATCH 04/12] test: add suspense retry e2e test --- _internal/utils/mutate.ts | 3 +- core/use-swr.ts | 3 +- e2e/site/app/head.tsx | 10 --- .../app/suspense-retry-18-3/manual-retry.tsx | 43 +++++++++++ e2e/site/app/suspense-retry-18-3/page.tsx | 14 ++++ .../suspense-retry-18-3/use-remote-data.ts | 21 ++++++ e2e/site/component/manual-retry.tsx | 42 +++++++++++ e2e/site/component/use-remote-data.ts | 20 +++++ e2e/site/pages/api/retry.ts | 12 +++ e2e/site/pages/suspense-retry-18-2.tsx | 14 ++++ e2e/test/initial-render.test.ts | 18 +++++ examples/suspense-retry/next-env.d.ts | 1 + examples/suspense-retry/pages/retry.tsx | 14 ++++ test/use-swr-suspense.test.tsx | 75 ++++++++++++++----- 14 files changed, 257 insertions(+), 33 deletions(-) delete mode 100644 e2e/site/app/head.tsx create mode 100644 e2e/site/app/suspense-retry-18-3/manual-retry.tsx create mode 100644 e2e/site/app/suspense-retry-18-3/page.tsx create mode 100644 e2e/site/app/suspense-retry-18-3/use-remote-data.ts create mode 100644 e2e/site/component/manual-retry.tsx create mode 100644 e2e/site/component/use-remote-data.ts create mode 100644 e2e/site/pages/api/retry.ts create mode 100644 e2e/site/pages/suspense-retry-18-2.tsx create mode 100644 examples/suspense-retry/pages/retry.tsx diff --git a/_internal/utils/mutate.ts b/_internal/utils/mutate.ts index 36bb7b90d..46a5a464a 100644 --- a/_internal/utils/mutate.ts +++ b/_internal/utils/mutate.ts @@ -94,7 +94,7 @@ export async function internalMutate( const [key] = serialize(_k) if (!key) return const [get, set] = createCacheHelper>(cache, key) - const [EVENT_REVALIDATORS, MUTATION, FETCH] = SWRGlobalState.get( + const [EVENT_REVALIDATORS, MUTATION, FETCH, PRELOAD] = SWRGlobalState.get( cache ) as GlobalState @@ -104,6 +104,7 @@ export async function internalMutate( // Invalidate the key by deleting the concurrent request markers so new // requests will not be deduped. delete FETCH[key] + delete PRELOAD[key] if (revalidators && revalidators[0]) { return revalidators[0](revalidateEvents.MUTATE_EVENT).then( () => get().data diff --git a/core/use-swr.ts b/core/use-swr.ts index 21abdc301..8683eacfa 100644 --- a/core/use-swr.ts +++ b/core/use-swr.ts @@ -617,7 +617,7 @@ export const useSWRHandler = ( // Keep the original key in the cache. setCache({ _k: fnArg }) - // Trigger a revalidation. + // Trigger a revalidation if (shouldDoInitialRevalidation) { if (isUndefined(data) || IS_SERVER) { // Revalidate immediately. @@ -726,7 +726,6 @@ export const useSWRHandler = ( promise.status = 'fulfilled' promise.value = (req as ReactUsePromise).value } - delete PRELOAD[key] use(promise as Promise) return result } diff --git a/e2e/site/app/head.tsx b/e2e/site/app/head.tsx deleted file mode 100644 index 153f334ce..000000000 --- a/e2e/site/app/head.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export default function Head() { - return ( - <> - SWR E2E Test - - - - - ) -} diff --git a/e2e/site/app/suspense-retry-18-3/manual-retry.tsx b/e2e/site/app/suspense-retry-18-3/manual-retry.tsx new file mode 100644 index 000000000..2621ba752 --- /dev/null +++ b/e2e/site/app/suspense-retry-18-3/manual-retry.tsx @@ -0,0 +1,43 @@ +'use client' +import { Suspense } from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { useRemoteData, preloadRemote } from './use-remote-data' + +const Demo = () => { + const { data } = useRemoteData() + return
data: {data}
+} + +function Fallback({ resetErrorBoundary }: any) { + return ( +
+

Something went wrong

+ +
+ ) +} + +function RemoteData() { + return ( +
+ { + preloadRemote() + }} + > + loading
}> + + + + + ) +} + +export default RemoteData diff --git a/e2e/site/app/suspense-retry-18-3/page.tsx b/e2e/site/app/suspense-retry-18-3/page.tsx new file mode 100644 index 000000000..b590f9df8 --- /dev/null +++ b/e2e/site/app/suspense-retry-18-3/page.tsx @@ -0,0 +1,14 @@ +import { Suspense } from 'react' +import dynamic from 'next/dynamic' + +const RemoteData = dynamic(() => import('./manual-retry'), { + ssr: false +}) + +export default function HomePage() { + return ( + loading component}> + + + ) +} diff --git a/e2e/site/app/suspense-retry-18-3/use-remote-data.ts b/e2e/site/app/suspense-retry-18-3/use-remote-data.ts new file mode 100644 index 000000000..335518410 --- /dev/null +++ b/e2e/site/app/suspense-retry-18-3/use-remote-data.ts @@ -0,0 +1,21 @@ +'use client' +import useSWR from 'swr' +import { preload } from 'swr' + +let count = 0 +const fetcher = () => { + count++ + if (count === 1) return Promise.reject('wrong') + return fetch('/api/retry') + .then(r => r.json()) + .then(r => r.name) +} + +const key = 'manual-retry' + +export const useRemoteData = () => + useSWR(key, fetcher, { + suspense: true + }) + +export const preloadRemote = () => preload(key, fetcher) diff --git a/e2e/site/component/manual-retry.tsx b/e2e/site/component/manual-retry.tsx new file mode 100644 index 000000000..7de7e8a79 --- /dev/null +++ b/e2e/site/component/manual-retry.tsx @@ -0,0 +1,42 @@ +import { Suspense } from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { useRemoteData, preloadRemote } from './use-remote-data' + +const Demo = () => { + const { data } = useRemoteData() + return
data: {data}
+} + +function Fallback({ resetErrorBoundary }: any) { + return ( +
+

Something went wrong

+ +
+ ) +} + +function RemoteData() { + return ( +
+ { + preloadRemote() + }} + > + loading
}> + + + + + ) +} + +export default RemoteData diff --git a/e2e/site/component/use-remote-data.ts b/e2e/site/component/use-remote-data.ts new file mode 100644 index 000000000..3a40e3a7f --- /dev/null +++ b/e2e/site/component/use-remote-data.ts @@ -0,0 +1,20 @@ +import useSWR from 'swr' +import { preload } from 'swr' + +let count = 0 +const fetcher = () => { + count++ + if (count === 1) return Promise.reject('wrong') + return fetch('/api/retry') + .then(r => r.json()) + .then(r => r.name) +} + +const key = 'manual-retry' + +export const useRemoteData = () => + useSWR(key, fetcher, { + suspense: true + }) + +export const preloadRemote = () => preload(key, fetcher) diff --git a/e2e/site/pages/api/retry.ts b/e2e/site/pages/api/retry.ts new file mode 100644 index 000000000..96b959b3a --- /dev/null +++ b/e2e/site/pages/api/retry.ts @@ -0,0 +1,12 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +type Data = { + name: string +} + +export default function handler( + req: NextApiRequest, + res: NextApiResponse +) { + res.status(200).json({ name: 'SWR suspense retry works' }) +} diff --git a/e2e/site/pages/suspense-retry-18-2.tsx b/e2e/site/pages/suspense-retry-18-2.tsx new file mode 100644 index 000000000..032295a6c --- /dev/null +++ b/e2e/site/pages/suspense-retry-18-2.tsx @@ -0,0 +1,14 @@ +import { Suspense } from 'react' +import dynamic from 'next/dynamic' + +const RemoteData = dynamic(() => import('../component/manual-retry'), { + ssr: false +}) + +export default function HomePage() { + return ( + loading component}> + + + ) +} diff --git a/e2e/test/initial-render.test.ts b/e2e/test/initial-render.test.ts index 81df89d24..23fbe1ec1 100644 --- a/e2e/test/initial-render.test.ts +++ b/e2e/test/initial-render.test.ts @@ -23,4 +23,22 @@ test.describe('rendering', () => { await expect(page.getByText('suspense-after-preload')).toBeVisible() expect(log).toHaveLength(0) }) + test('should be able to retry in suspense with react 18.3', async ({ + page + }) => { + await page.goto('./suspense-retry-18-3', { waitUntil: 'commit' }) + await expect(page.getByText('Something went wrong')).toBeVisible() + await page.getByRole('button', { name: 'retry' }).click() + await expect(page.getByText('loading')).toBeVisible() + await expect(page.getByText('data: SWR suspense retry works')).toBeVisible() + }) + test('should be able to retry in suspense with react 18.2', async ({ + page + }) => { + await page.goto('./suspense-retry-18-2', { waitUntil: 'commit' }) + await expect(page.getByText('Something went wrong')).toBeVisible() + await page.getByRole('button', { name: 'retry' }).click() + await expect(page.getByText('loading')).toBeVisible() + await expect(page.getByText('data: SWR suspense retry works')).toBeVisible() + }) }) diff --git a/examples/suspense-retry/next-env.d.ts b/examples/suspense-retry/next-env.d.ts index 4f11a03dc..fd36f9494 100644 --- a/examples/suspense-retry/next-env.d.ts +++ b/examples/suspense-retry/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/examples/suspense-retry/pages/retry.tsx b/examples/suspense-retry/pages/retry.tsx new file mode 100644 index 000000000..97c01e8c8 --- /dev/null +++ b/examples/suspense-retry/pages/retry.tsx @@ -0,0 +1,14 @@ +import { Suspense } from 'react' +import dynamic from 'next/dynamic' + +const RemoteData = dynamic(() => import('../app/manual-retry'), { + ssr: false +}) + +export default function HomePage() { + return ( + loading component}> + + + ) +} diff --git a/test/use-swr-suspense.test.tsx b/test/use-swr-suspense.test.tsx index 9487c315a..d0617113b 100644 --- a/test/use-swr-suspense.test.tsx +++ b/test/use-swr-suspense.test.tsx @@ -1,8 +1,7 @@ import { act, fireEvent, screen } from '@testing-library/react' -import type { ReactNode, PropsWithChildren } from 'react' import { Profiler } from 'react' -import React, { Suspense, useReducer, useState } from 'react' -import useSWR, { mutate } from 'swr' +import { Suspense, useReducer, useState } from 'react' +import useSWR, { mutate, preload } from 'swr' import { createKey, createResponse, @@ -10,23 +9,8 @@ import { renderWithGlobalCache, sleep } from './utils' - -class ErrorBoundary extends React.Component< - PropsWithChildren<{ fallback: ReactNode }> -> { - state = { hasError: false } - static getDerivedStateFromError() { - return { - hasError: true - } - } - render() { - if (this.state.hasError) { - return this.props.fallback - } - return this.props.children - } -} +import type { FallbackProps } from 'react-error-boundary' +import { ErrorBoundary } from 'react-error-boundary' describe('useSWR - suspense', () => { afterEach(() => { @@ -439,4 +423,55 @@ describe('useSWR - suspense', () => { await screen.findByText(`data: ${newKey}`) expect(onRender).toHaveBeenCalledTimes(1) }) + + it('should be able to do retry when suspense falied', async () => { + const key = createKey() + let count = 0 + + const fetcher = jest.fn(() => { + count++ + if (count === 1) { + return Promise.reject('error') + } + return createResponse('SWR Suspense Retry') + }) + + const Page = () => { + const { data } = useSWR(key, fetcher, { + suspense: true + }) + return
data: {data}
+ } + + const Fallback = ({ resetErrorBoundary }: FallbackProps) => { + return ( +
+

Something went wrong

+ +
+ ) + } + + const App = () => { + return ( + { + preload(key, fetcher) + }} + FallbackComponent={Fallback} + > + loading}> + + + + ) + } + + renderWithConfig() + await screen.findByText('Something went wrong') + expect(fetcher).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByText('retry')) + expect(fetcher).toHaveBeenCalledTimes(2) + await screen.findByText('data: SWR Suspense Retry') + }) }) From 9f5b60fbb02eb193e5aabcd24655cdf835c2c777 Mon Sep 17 00:00:00 2001 From: Yixuan Xu Date: Fri, 9 Jun 2023 21:25:20 +0800 Subject: [PATCH 05/12] test: add mutate retry e2e test --- .../suspense-retry-18-3/use-remote-data.ts | 2 +- e2e/site/component/manual-retry-mutate.tsx | 54 +++++++++++++++++++ e2e/site/component/use-remote-data.ts | 4 +- e2e/site/pages/suspense-retry-mutate.tsx | 14 +++++ e2e/test/initial-render.test.ts | 8 ++- test/use-swr-suspense.test.tsx | 53 +----------------- 6 files changed, 78 insertions(+), 57 deletions(-) create mode 100644 e2e/site/component/manual-retry-mutate.tsx create mode 100644 e2e/site/pages/suspense-retry-mutate.tsx diff --git a/e2e/site/app/suspense-retry-18-3/use-remote-data.ts b/e2e/site/app/suspense-retry-18-3/use-remote-data.ts index 335518410..271810e9b 100644 --- a/e2e/site/app/suspense-retry-18-3/use-remote-data.ts +++ b/e2e/site/app/suspense-retry-18-3/use-remote-data.ts @@ -11,7 +11,7 @@ const fetcher = () => { .then(r => r.name) } -const key = 'manual-retry' +const key = 'manual-retry-18-3' export const useRemoteData = () => useSWR(key, fetcher, { diff --git a/e2e/site/component/manual-retry-mutate.tsx b/e2e/site/component/manual-retry-mutate.tsx new file mode 100644 index 000000000..aba78ba0b --- /dev/null +++ b/e2e/site/component/manual-retry-mutate.tsx @@ -0,0 +1,54 @@ +import { Suspense } from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import useSWR from 'swr' +import { mutate } from 'swr' + +let count = 0 +export const fetcher = () => { + count++ + if (count === 1) return Promise.reject('wrong') + return fetch('/api/retry') + .then(r => r.json()) + .then(r => r.name) +} + +const key = 'manual-retry-mutate' + +export const useRemoteData = () => + useSWR(key, fetcher, { + suspense: true + }) +const Demo = () => { + const { data } = useRemoteData() + return
data: {data}
+} + +function Fallback({ resetErrorBoundary }: any) { + return ( +
+

Something went wrong

+ +
+ ) +} + +function RemoteData() { + return ( +
+ + loading
}> + + + + + ) +} + +export default RemoteData diff --git a/e2e/site/component/use-remote-data.ts b/e2e/site/component/use-remote-data.ts index 3a40e3a7f..8cf96ab57 100644 --- a/e2e/site/component/use-remote-data.ts +++ b/e2e/site/component/use-remote-data.ts @@ -2,7 +2,7 @@ import useSWR from 'swr' import { preload } from 'swr' let count = 0 -const fetcher = () => { +export const fetcher = () => { count++ if (count === 1) return Promise.reject('wrong') return fetch('/api/retry') @@ -10,7 +10,7 @@ const fetcher = () => { .then(r => r.name) } -const key = 'manual-retry' +const key = 'manual-retry-18-2' export const useRemoteData = () => useSWR(key, fetcher, { diff --git a/e2e/site/pages/suspense-retry-mutate.tsx b/e2e/site/pages/suspense-retry-mutate.tsx new file mode 100644 index 000000000..43b26fd8c --- /dev/null +++ b/e2e/site/pages/suspense-retry-mutate.tsx @@ -0,0 +1,14 @@ +import { Suspense } from 'react' +import dynamic from 'next/dynamic' + +const RemoteData = dynamic(() => import('../component/manual-retry-mutate'), { + ssr: false +}) + +export default function HomePage() { + return ( + loading component}> + + + ) +} diff --git a/e2e/test/initial-render.test.ts b/e2e/test/initial-render.test.ts index 23fbe1ec1..5680e8fac 100644 --- a/e2e/test/initial-render.test.ts +++ b/e2e/test/initial-render.test.ts @@ -29,7 +29,6 @@ test.describe('rendering', () => { await page.goto('./suspense-retry-18-3', { waitUntil: 'commit' }) await expect(page.getByText('Something went wrong')).toBeVisible() await page.getByRole('button', { name: 'retry' }).click() - await expect(page.getByText('loading')).toBeVisible() await expect(page.getByText('data: SWR suspense retry works')).toBeVisible() }) test('should be able to retry in suspense with react 18.2', async ({ @@ -38,7 +37,12 @@ test.describe('rendering', () => { await page.goto('./suspense-retry-18-2', { waitUntil: 'commit' }) await expect(page.getByText('Something went wrong')).toBeVisible() await page.getByRole('button', { name: 'retry' }).click() - await expect(page.getByText('loading')).toBeVisible() + await expect(page.getByText('data: SWR suspense retry works')).toBeVisible() + }) + test('should be able to retry in suspense with mutate', async ({ page }) => { + await page.goto('./suspense-retry-mutate', { waitUntil: 'commit' }) + await expect(page.getByText('Something went wrong')).toBeVisible() + await page.getByRole('button', { name: 'retry' }).click() await expect(page.getByText('data: SWR suspense retry works')).toBeVisible() }) }) diff --git a/test/use-swr-suspense.test.tsx b/test/use-swr-suspense.test.tsx index d0617113b..bbe22512d 100644 --- a/test/use-swr-suspense.test.tsx +++ b/test/use-swr-suspense.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, screen } from '@testing-library/react' import { Profiler } from 'react' import { Suspense, useReducer, useState } from 'react' -import useSWR, { mutate, preload } from 'swr' +import useSWR, { mutate } from 'swr' import { createKey, createResponse, @@ -423,55 +423,4 @@ describe('useSWR - suspense', () => { await screen.findByText(`data: ${newKey}`) expect(onRender).toHaveBeenCalledTimes(1) }) - - it('should be able to do retry when suspense falied', async () => { - const key = createKey() - let count = 0 - - const fetcher = jest.fn(() => { - count++ - if (count === 1) { - return Promise.reject('error') - } - return createResponse('SWR Suspense Retry') - }) - - const Page = () => { - const { data } = useSWR(key, fetcher, { - suspense: true - }) - return
data: {data}
- } - - const Fallback = ({ resetErrorBoundary }: FallbackProps) => { - return ( -
-

Something went wrong

- -
- ) - } - - const App = () => { - return ( - { - preload(key, fetcher) - }} - FallbackComponent={Fallback} - > - loading}> - - - - ) - } - - renderWithConfig() - await screen.findByText('Something went wrong') - expect(fetcher).toHaveBeenCalledTimes(1) - fireEvent.click(screen.getByText('retry')) - expect(fetcher).toHaveBeenCalledTimes(2) - await screen.findByText('data: SWR Suspense Retry') - }) }) From 2a5491ffc0418f5f230c3e51d7b4f2c13b6b8417 Mon Sep 17 00:00:00 2001 From: Yixuan Xu Date: Fri, 9 Jun 2023 21:29:36 +0800 Subject: [PATCH 06/12] chore: fix lint error --- test/use-swr-suspense.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/test/use-swr-suspense.test.tsx b/test/use-swr-suspense.test.tsx index bbe22512d..7aaba6f69 100644 --- a/test/use-swr-suspense.test.tsx +++ b/test/use-swr-suspense.test.tsx @@ -9,7 +9,6 @@ import { renderWithGlobalCache, sleep } from './utils' -import type { FallbackProps } from 'react-error-boundary' import { ErrorBoundary } from 'react-error-boundary' describe('useSWR - suspense', () => { From 17785cde5c4ff176eb1da9001b7277aaacb48c16 Mon Sep 17 00:00:00 2001 From: Yixuan Xu Date: Fri, 9 Jun 2023 23:55:28 +0800 Subject: [PATCH 07/12] test: remove profiler since `next build --profile` does not work --- .../suspense-after-preload/remote-data.tsx | 17 ++-------- e2e/site/pages/initial-render.tsx | 31 ------------------- e2e/test/initial-render.test.ts | 18 +---------- package.json | 2 +- 4 files changed, 5 insertions(+), 63 deletions(-) delete mode 100644 e2e/site/pages/initial-render.tsx diff --git a/e2e/site/app/suspense-after-preload/remote-data.tsx b/e2e/site/app/suspense-after-preload/remote-data.tsx index 0e8d1e51f..1d32818e4 100644 --- a/e2e/site/app/suspense-after-preload/remote-data.tsx +++ b/e2e/site/app/suspense-after-preload/remote-data.tsx @@ -1,5 +1,5 @@ 'use client' -import { Suspense, useState, Profiler } from 'react' +import { Suspense, useState } from 'react' import useSWR from 'swr' import { preload } from 'swr' @@ -26,25 +26,14 @@ function Comp() {
{show ? ( - { - ;(window as any).onRender('render') - }} - > -
loading
- - } - > + loading
}> ) : null} diff --git a/e2e/site/pages/initial-render.tsx b/e2e/site/pages/initial-render.tsx deleted file mode 100644 index 6ed1700a8..000000000 --- a/e2e/site/pages/initial-render.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import useSWR from 'swr' -import { Profiler } from 'react' - -const useFetchUser = () => - useSWR( - '/users/100', - url => - new Promise(resolve => { - setTimeout(() => { - resolve(url) - }, 1000) - }) - ) - -function UserSWR() { - useFetchUser() - return
SWRTest
-} - -export default function SWRTest() { - return ( - { - ;(window as any).onRender('UserSWR rendered') - }} - > - - - ) -} diff --git a/e2e/test/initial-render.test.ts b/e2e/test/initial-render.test.ts index 5680e8fac..fa0f430bd 100644 --- a/e2e/test/initial-render.test.ts +++ b/e2e/test/initial-render.test.ts @@ -1,27 +1,11 @@ /* eslint-disable testing-library/prefer-screen-queries */ import { test, expect } from '@playwright/test' -const sleep = async (ms: number) => - new Promise(resolve => setTimeout(resolve, ms)) - test.describe('rendering', () => { - test('should only render once if the result of swr is not used', async ({ - page - }) => { - const log: any[] = [] - await page.exposeFunction('onRender', (msg: any) => log.push(msg)) - await page.goto('./initial-render', { waitUntil: 'commit' }) - await expect(page.getByText('SWRTest')).toBeVisible() - await sleep(1200) - expect(log).toHaveLength(1) - }) - test('should not suspense when using resolved preload', async ({ page }) => { - const log: any[] = [] - await page.exposeFunction('onRender', (msg: any) => log.push(msg)) + test('suspense with preload', async ({ page }) => { await page.goto('./suspense-after-preload', { waitUntil: 'commit' }) await page.getByRole('button', { name: 'preload' }).click() await expect(page.getByText('suspense-after-preload')).toBeVisible() - expect(log).toHaveLength(0) }) test('should be able to retry in suspense with react 18.3', async ({ page diff --git a/package.json b/package.json index 02408c87f..1f8b14f27 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "clean": "pnpm -r run clean && rimraf playwright-report test-result", "watch": "pnpm -r run watch", "build": "pnpm build-package _internal && pnpm build-package core && pnpm build-package infinite && pnpm build-package immutable && pnpm build-package mutation && pnpm build-package subscription", - "build:e2e": "pnpm next build e2e/site -- --profile", + "build:e2e": "pnpm next build e2e/site", "build-package": "bunchee index.ts --cwd", "types:check": "pnpm -r run types:check", "prepublishOnly": "pnpm clean && pnpm build", From 202de850fe6e90af6c5e173ba585d327a65967d4 Mon Sep 17 00:00:00 2001 From: Yixuan Xu Date: Sat, 10 Jun 2023 00:00:50 +0800 Subject: [PATCH 08/12] refactor: use promise directly --- _internal/types.ts | 2 +- _internal/utils/preload.ts | 36 ++++++------------------------------ core/use-swr.ts | 13 ++++--------- 3 files changed, 11 insertions(+), 40 deletions(-) diff --git a/_internal/types.ts b/_internal/types.ts index c74fa6be9..3b399b392 100644 --- a/_internal/types.ts +++ b/_internal/types.ts @@ -5,7 +5,7 @@ export type GlobalState = [ Record, // EVENT_REVALIDATORS Record, // MUTATION: [ts, end_ts] Record, // FETCH: [data, ts] - Record | ReactUsePromise>, // PRELOAD + Record>, // PRELOAD ScopedMutator, // Mutator (key: string, value: any, prev: any) => void, // Setter (key: string, callback: (current: any, prev: any) => void) => () => void // Subscriber diff --git a/_internal/utils/preload.ts b/_internal/utils/preload.ts index 5b047c87f..0843f3833 100644 --- a/_internal/utils/preload.ts +++ b/_internal/utils/preload.ts @@ -3,13 +3,12 @@ import type { Key, BareFetcher, GlobalState, - FetcherResponse, - ReactUsePromise + FetcherResponse } from '../types' import { serialize } from './serialize' import { cache } from './config' import { SWRGlobalState } from './global-state' -import { isPromiseLike, isUndefined } from './helper' +import { isUndefined } from './helper' // Basically same as Fetcher but without Conditional Fetching type PreloadFetcher< Data = unknown, @@ -34,18 +33,9 @@ export const preload = < // Prevent preload to be called multiple times before used. if (PRELOAD[key]) return PRELOAD[key] - const req = fetcher(fnArg) as FetcherResponse + const req = fetcher(fnArg) as ReturnType PRELOAD[key] = req - if (!isUndefined(req) && isPromiseLike(req)) { - return req.then(data => { - const promise = Promise.resolve(data) as ReactUsePromise - promise.value = data - promise.status = 'fulfilled' - PRELOAD[key] = promise - return data - }) as ReturnType - } - return req as ReturnType + return req } export const middleware: Middleware = @@ -58,22 +48,8 @@ export const middleware: Middleware = const [, , , PRELOAD] = SWRGlobalState.get(cache) as GlobalState const req = PRELOAD[key] if (isUndefined(req)) return fetcher_(...args) - if ( - !isPromiseLike(req) || - (req as ReactUsePromise).status === 'fulfilled' - ) { - delete PRELOAD[key] - return req - } - return ( - req.then(data => { - delete PRELOAD[key] - return data - }) as Promise - ).catch(err => { - delete PRELOAD[key] - throw err - }) + delete PRELOAD[key] + return req }) return useSWRNext(key_, fetcher, config) } diff --git a/core/use-swr.ts b/core/use-swr.ts index 8683eacfa..51c4f4488 100644 --- a/core/use-swr.ts +++ b/core/use-swr.ts @@ -26,8 +26,7 @@ import { getTimestamp, internalMutate, revalidateEvents, - mergeObjects, - isPromiseLike + mergeObjects } from 'swr/_internal' import type { State, @@ -720,13 +719,9 @@ export const useSWRHandler = ( configRef.current = config unmountedRef.current = false const req = PRELOAD[key] - if (!isUndefined(req) && isPromiseLike(req)) { - const promise: ReactUsePromise = boundMutate(req as Promise) - if ((req as ReactUsePromise).status === 'fulfilled') { - promise.status = 'fulfilled' - promise.value = (req as ReactUsePromise).value - } - use(promise as Promise) + if (!isUndefined(req)) { + const promise = boundMutate(req) + use(promise) return result } From d69091ae2b13b360e5e7d21f03e1c75dbe1f77c5 Mon Sep 17 00:00:00 2001 From: Yixuan Xu Date: Sat, 10 Jun 2023 00:12:40 +0800 Subject: [PATCH 09/12] refactor --- core/use-swr.ts | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/core/use-swr.ts b/core/use-swr.ts index 51c4f4488..f04b27259 100644 --- a/core/use-swr.ts +++ b/core/use-swr.ts @@ -683,25 +683,6 @@ export const useSWRHandler = ( // Display debug info in React DevTools. useDebugValue(returnedData) - const result = { - mutate: boundMutate, - get data() { - stateDependencies.data = true - return returnedData - }, - get error() { - stateDependencies.error = true - return error - }, - get isValidating() { - stateDependencies.isValidating = true - return isValidating - }, - get isLoading() { - stateDependencies.isLoading = true - return isLoading - } - } as SWRResponse // In Suspense mode, we can't return the empty `data` state. // If there is an `error`, the `error` needs to be thrown to the error boundary. // If there is no `error`, the `revalidation` promise needs to be thrown to @@ -722,7 +703,6 @@ export const useSWRHandler = ( if (!isUndefined(req)) { const promise = boundMutate(req) use(promise) - return result } if (isUndefined(error)) { @@ -737,7 +717,25 @@ export const useSWRHandler = ( } } - return result + return { + mutate: boundMutate, + get data() { + stateDependencies.data = true + return returnedData + }, + get error() { + stateDependencies.error = true + return error + }, + get isValidating() { + stateDependencies.isValidating = true + return isValidating + }, + get isLoading() { + stateDependencies.isLoading = true + return isLoading + } + } as SWRResponse } export const SWRConfig = OBJECT.defineProperty(ConfigProvider, 'defaultValue', { From 8b27d8989752d028a0193bfd0b543a56f05e5cf3 Mon Sep 17 00:00:00 2001 From: Yixuan Xu Date: Thu, 15 Jun 2023 15:40:50 +0800 Subject: [PATCH 10/12] refactor: use for of since the build target is es2018 now --- _internal/utils/mutate.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/_internal/utils/mutate.ts b/_internal/utils/mutate.ts index 46a5a464a..36331933d 100644 --- a/_internal/utils/mutate.ts +++ b/_internal/utils/mutate.ts @@ -74,8 +74,7 @@ export async function internalMutate( const keyFilter = _key const matchedKeys: Key[] = [] const it = cache.keys() - for (let keyIt = it.next(); !keyIt.done; keyIt = it.next()) { - const key = keyIt.value + for (const key of it) { if ( // Skip the special useSWRInfinite and useSWRSubscription keys. !/^\$(inf|sub)\$/.test(key) && From 3b42c6f87842fafb067f656906600b36f542f8b4 Mon Sep 17 00:00:00 2001 From: Yixuan Xu Date: Thu, 15 Jun 2023 21:36:39 +0800 Subject: [PATCH 11/12] chore: fix build error --- tsconfig.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tsconfig.json b/tsconfig.json index 22bed18b1..7b49dcc32 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,8 @@ "target": "ES2018", "baseUrl": ".", "noEmitOnError": true, + /** FIXME: only use this to skip build error */ + "downlevelIteration": true, "paths": { "swr": ["./core/index.ts"], "swr/infinite": ["./infinite/index.ts"], From 80b685668c5f237bb70478bf84059846759a95c0 Mon Sep 17 00:00:00 2001 From: Yixuan Xu Date: Fri, 16 Jun 2023 04:13:15 +0800 Subject: [PATCH 12/12] chore: remove comment --- tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 7b49dcc32..30c150f1c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,6 @@ "target": "ES2018", "baseUrl": ".", "noEmitOnError": true, - /** FIXME: only use this to skip build error */ "downlevelIteration": true, "paths": { "swr": ["./core/index.ts"],