diff --git a/README.md b/README.md index 805b6eb1c9..43c933cf6c 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ library to handle that part. This library is created by the team behind [Next.js](https://nextjs.org), with contributions from our community: - Shu Ding ([@shuding\_](https://x.com/shuding_)) - [Vercel](https://vercel.com) -- Jiachi Liu ([@huozhi])(https://x.com/huozhi)) - [Vercel](https://vercel.com) +- Jiachi Liu ([@huozhi](https://x.com/huozhi)) - [Vercel](https://vercel.com) - Guillermo Rauch ([@rauchg](https://x.com/rauchg)) - [Vercel](https://vercel.com) - Joe Haddad ([@timer150](https://x.com/timer150)) - [Vercel](https://vercel.com) - Paco Coursey ([@pacocoursey](https://x.com/pacocoursey)) - [Vercel](https://vercel.com) diff --git a/e2e/site/app/perf/page.tsx b/e2e/site/app/perf/page.tsx new file mode 100644 index 0000000000..e536278969 --- /dev/null +++ b/e2e/site/app/perf/page.tsx @@ -0,0 +1,63 @@ +'use client' +import { useState } from 'react' +import useSWR from 'swr' + +const elementCount = 10_000 +const useData = () => { + return useSWR('1', async (url: string) => { + return 1 + }) +} + +const HookUser = () => { + const { data } = useData() + return
{data}
+} +/** + * This renders 10,000 divs and is used to compare against the render performance + * when using swr. + */ +const CheapComponent = () => { + const cheapComponents = Array.from({ length: elementCount }, (_, i) => ( +
{i}
+ )) + return ( +
+

Cheap Component

+ {cheapComponents} +
+ ) +} + +/** + * This renders 10,000 divs, each of which uses the same swr hook. + */ +const ExpensiveComponent = () => { + const hookComponents = Array.from({ length: elementCount }, (_, i) => ( + + )) + return ( +
+

Expensive Component

+ {hookComponents} +
+ ) +} + +export default function PerformancePage() { + const [renderExpensive, setRenderExpensive] = useState(false) + return ( +
+

Performance Page

+ + {!renderExpensive ? : } +
+ ) +} diff --git a/e2e/test/perf.test.ts b/e2e/test/perf.test.ts new file mode 100644 index 0000000000..38baddf90c --- /dev/null +++ b/e2e/test/perf.test.ts @@ -0,0 +1,115 @@ +import { test, expect } from '@playwright/test' + +test.describe('performance', () => { + test('should render expensive component within 1 second after checkbox click', async ({ + page + }) => { + // Navigate to the perf page + await page.goto('./perf', { waitUntil: 'load' }) + + // Inject performance measurement into the page + await page.evaluate(() => { + const checkboxInput = document.querySelector( + 'input[type="checkbox"]' + ) as HTMLInputElement + let expensiveComponentContainer: HTMLElement | null = null + const targetChildCount = 10_000 + let startTime = 0 + + // Track when React starts and completes rendering + const observer = new MutationObserver(mutations => { + for (const mutation of mutations) { + const addedNodes = Array.from(mutation.addedNodes) + for (const node of addedNodes) { + if (node instanceof HTMLElement) { + const h2 = node.querySelector?.('h2') + if (h2?.textContent === 'Expensive Component') { + expensiveComponentContainer = node + console.log('Found Expensive Component container') + } + } + } + + if (expensiveComponentContainer) { + const renderedComponents = + expensiveComponentContainer.querySelectorAll('div > div').length + + if (renderedComponents % 1000 === 0 && renderedComponents > 0) { + console.log(`Rendered ${renderedComponents} components...`) + } + + if (renderedComponents >= targetChildCount) { + console.log(`All ${renderedComponents} components rendered!`) + + // Use requestAnimationFrame to ensure the browser has painted + requestAnimationFrame(() => { + requestAnimationFrame(() => { + // Double RAF to ensure we're after the paint + window.performance.mark('expensive-component-painted') + const paintTime = performance.now() - startTime + console.log( + `Total time including paint: ${paintTime.toFixed(2)}ms` + ) + }) + }) + + observer.disconnect() + } + } + } + }) + + observer.observe(document.body, { childList: true, subtree: true }) + + // Capture more precise timing + checkboxInput.addEventListener( + 'click', + () => { + startTime = performance.now() + window.performance.mark('state-change-start') + console.log('Checkbox clicked, state change started') + }, + { once: true } + ) + }) + + // Find and click the checkbox + const checkbox = page.locator('input[type="checkbox"]') + await checkbox.click() + + // Wait for the expensive component to be fully rendered + const expensiveComponentHeading = page.locator( + 'h2:has-text("Expensive Component")' + ) + await expect(expensiveComponentHeading).toBeVisible({ timeout: 60_000 }) + + // Wait for all components and paint to complete + await page.waitForFunction( + () => { + return ( + window.performance.getEntriesByName('expensive-component-painted') + .length > 0 + ) + }, + { timeout: 5000 } + ) + + // Get the render time from state change to paint + const renderTime = await page.evaluate(() => { + const startMark = + window.performance.getEntriesByName('state-change-start')[0] + const paintMark = window.performance.getEntriesByName( + 'expensive-component-painted' + )[0] + + if (!startMark || !paintMark) { + throw new Error('Performance marks not found') + } + + return paintMark.startTime - startMark.startTime + }) + + // Assert that the rendering took less than 1 second (1000ms) + expect(renderTime).toBeLessThan(1000) + }) +}) diff --git a/examples/suspense-global/README.md b/examples/suspense-global/README.md new file mode 100644 index 0000000000..a5f4641f8d --- /dev/null +++ b/examples/suspense-global/README.md @@ -0,0 +1,30 @@ +# Basic + +## One-Click Deploy + +Deploy your own SWR project with Vercel. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?s=https://github.com/vercel/swr/tree/main/examples/suspense) + +## How to Use + +Download the example: + +```bash +curl https://codeload.github.com/vercel/swr/tar.gz/main | tar -xz --strip=2 swr-main/examples/suspense +cd suspense +``` + +Install it and run: + +```bash +yarn +yarn dev +# or +npm install +npm run dev +``` + +## The Idea behind the Example + +Show how to use the SWR suspense option with React suspense. diff --git a/examples/suspense-global/components/error-handling.ts b/examples/suspense-global/components/error-handling.ts new file mode 100644 index 0000000000..f604ae0dd1 --- /dev/null +++ b/examples/suspense-global/components/error-handling.ts @@ -0,0 +1,17 @@ +import React from 'react' + +export default class ErrorBoundary extends React.Component { + state = { hasError: false, error: null } + static getDerivedStateFromError(error: any) { + return { + hasError: true, + error + } + } + render() { + if (this.state.hasError) { + return this.props.fallback + } + return this.props.children + } +} diff --git a/examples/suspense-global/global-swr-config.tsx b/examples/suspense-global/global-swr-config.tsx new file mode 100644 index 0000000000..a8a8f89d23 --- /dev/null +++ b/examples/suspense-global/global-swr-config.tsx @@ -0,0 +1,24 @@ +'use client' + +import { SWRConfig } from 'swr' + +import fetcher from './libs/fetch' + +declare module 'swr' { + interface SWRGlobalConfig { + suspense: true + } +} + +export function GlobalSWRConfig({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/examples/suspense-global/libs/fetch.ts b/examples/suspense-global/libs/fetch.ts new file mode 100644 index 0000000000..86f1252fc5 --- /dev/null +++ b/examples/suspense-global/libs/fetch.ts @@ -0,0 +1,8 @@ +export default async function fetcher(...args: [any]) { + const res = await fetch(...args) + if (!res.ok) { + throw new Error('An error occurred while fetching the data.') + } else { + return res.json() + } +} diff --git a/examples/suspense-global/next-env.d.ts b/examples/suspense-global/next-env.d.ts new file mode 100644 index 0000000000..a4a7b3f5cf --- /dev/null +++ b/examples/suspense-global/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. diff --git a/examples/suspense-global/package.json b/examples/suspense-global/package.json new file mode 100644 index 0000000000..f2671e2100 --- /dev/null +++ b/examples/suspense-global/package.json @@ -0,0 +1,17 @@ +{ + "name": "suspense-global", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "next": "latest", + "react": "latest", + "react-dom": "latest", + "swr": "latest" + }, + "scripts": { + "dev": "next", + "start": "next start", + "build": "next build" + } +} diff --git a/examples/suspense-global/pages/[user]/[repo].tsx b/examples/suspense-global/pages/[user]/[repo].tsx new file mode 100644 index 0000000000..7e2fa7d85b --- /dev/null +++ b/examples/suspense-global/pages/[user]/[repo].tsx @@ -0,0 +1,30 @@ +import dynamic from 'next/dynamic' +import Link from 'next/link' +import { Suspense } from 'react' +import ErrorHandling from '../../components/error-handling' +import { useRouter } from 'next/router' + +const Detail = dynamic(() => import('./detail'), { + ssr: false +}) + +export default function Repo() { + const router = useRouter() + if (!router.isReady) return null + const { user, repo } = router.query + const id = `${user}/${repo}` + + return ( +
+

{id}

+ loading...
}> + oooops!}> + + + +
+
+ Back + + ) +} diff --git a/examples/suspense-global/pages/[user]/detail.tsx b/examples/suspense-global/pages/[user]/detail.tsx new file mode 100644 index 0000000000..5201fc2b94 --- /dev/null +++ b/examples/suspense-global/pages/[user]/detail.tsx @@ -0,0 +1,20 @@ +import useSWR from 'swr' +import { RepoData } from '../api/data' + +const Detail = ({ id }: { id: string }) => { + const { data } = useSWR('/api/data?id=' + id) + + return ( + <> + {data ? ( +
+

forks: {data.forks_count}

+

stars: {data.stargazers_count}

+

watchers: {data.watchers}

+
+ ) : null} + + ) +} + +export default Detail diff --git a/examples/suspense-global/pages/_app.tsx b/examples/suspense-global/pages/_app.tsx new file mode 100644 index 0000000000..0aae4cb910 --- /dev/null +++ b/examples/suspense-global/pages/_app.tsx @@ -0,0 +1,10 @@ +import type { AppProps } from 'next/app' +import { GlobalSWRConfig } from 'global-swr-config' + +export default function MyApp({ Component, pageProps }: AppProps) { + return ( + + + + ) +} diff --git a/examples/suspense-global/pages/api/data.ts b/examples/suspense-global/pages/api/data.ts new file mode 100644 index 0000000000..456a4cc59d --- /dev/null +++ b/examples/suspense-global/pages/api/data.ts @@ -0,0 +1,40 @@ +import { NextApiRequest, NextApiResponse } from 'next' + +const projects = [ + 'facebook/flipper', + 'vuejs/vuepress', + 'rust-lang/rust', + 'vercel/next.js', + 'emperor/clothes' +] as const + +export type ProjectsData = typeof projects + +export interface RepoData { + forks_count: number + stargazers_count: number + watchers: number +} + +export default function api(req: NextApiRequest, res: NextApiResponse) { + if (req.query.id) { + if (req.query.id === projects[4]) { + setTimeout(() => { + res.status(404).json({ msg: 'not found' }) + }) + } else { + // a slow endpoint for getting repo data + fetch(`https://api.github.com/repos/${req.query.id}`) + .then(res => res.json()) + .then(data => { + setTimeout(() => { + res.json(data) + }, 2000) + }) + } + } else { + setTimeout(() => { + res.json(projects) + }, 2000) + } +} diff --git a/examples/suspense-global/pages/index.tsx b/examples/suspense-global/pages/index.tsx new file mode 100644 index 0000000000..2da57a6885 --- /dev/null +++ b/examples/suspense-global/pages/index.tsx @@ -0,0 +1,17 @@ +import { Suspense } from 'react' +import dynamic from 'next/dynamic' + +const Repos = dynamic(() => import('./repos'), { + ssr: false +}) + +export default function Index() { + return ( +
+

Trending Projects

+ loading...
}> + + + + ) +} diff --git a/examples/suspense-global/pages/repos.tsx b/examples/suspense-global/pages/repos.tsx new file mode 100644 index 0000000000..c6a04e121f --- /dev/null +++ b/examples/suspense-global/pages/repos.tsx @@ -0,0 +1,22 @@ +import Link from 'next/link' +import { ProjectsData } from './api/data' + +import useSWR from 'swr' + +const Repos = () => { + const { data } = useSWR('/api/data') + + return ( + <> + {data.map(project => ( +

+ + {project} + +

+ ))} + + ) +} + +export default Repos diff --git a/examples/suspense-global/tsconfig.json b/examples/suspense-global/tsconfig.json new file mode 100644 index 0000000000..da520dfb18 --- /dev/null +++ b/examples/suspense-global/tsconfig.json @@ -0,0 +1,30 @@ +{ + "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, + "noImplicitAny": 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 1adf11b6e1..0184b6c5ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "swr", - "version": "2.3.3", + "version": "2.3.4", "description": "React Hooks library for remote data fetching", "keywords": [ "swr", @@ -108,7 +108,7 @@ "lint": "eslint . --ext .ts,.tsx --cache", "lint:fix": "pnpm lint --fix", "coverage": "jest --coverage", - "test-typing": "tsc --noEmit -p test/type/tsconfig.json && tsc --noEmit -p test/tsconfig.json", + "test-typing": "tsc -p test/tsconfig.json && tsc -p test/type/tsconfig.json && tsc -p test/type/suspense/tsconfig.json", "test": "jest", "test:build": "jest --config jest.config.build.js", "test:e2e": "playwright test", diff --git a/src/_internal/types.ts b/src/_internal/types.ts index 528dae9327..c3cb0dd227 100644 --- a/src/_internal/types.ts +++ b/src/_internal/types.ts @@ -1,3 +1,4 @@ +import type { SWRGlobalConfig } from '../index' import type * as revalidateEvents from './events' export type GlobalState = [ @@ -33,7 +34,9 @@ export type ReactUsePromise = Promise & { export type BlockingData< Data = any, Options = SWROptions -> = Options extends undefined +> = SWRGlobalConfig extends { suspense: true } + ? true + : Options extends undefined ? false : Options extends { suspense: true } ? true @@ -456,7 +459,11 @@ export type SWRConfiguration< export type IsLoadingResponse< Data = any, Options = SWROptions -> = Options extends { suspense: true } ? false : boolean +> = SWRGlobalConfig extends { suspense: true } + ? Options extends { suspense: true } + ? false + : false + : boolean type SWROptions = SWRConfiguration> type SWRConfigurationWithOptionalFallback = diff --git a/src/_internal/utils/hash.ts b/src/_internal/utils/hash.ts index c9528a6a84..7c992ca6ce 100644 --- a/src/_internal/utils/hash.ts +++ b/src/_internal/utils/hash.ts @@ -6,8 +6,10 @@ import { OBJECT, isUndefined } from './shared' // complexity is almost O(1). const table = new WeakMap() -const isObjectType = (value: any, type: string) => - OBJECT.prototype.toString.call(value) === `[object ${type}]` +const getTypeName = (value: any) => OBJECT.prototype.toString.call(value) + +const isObjectTypeName = (typeName: string, type: string) => + typeName === `[object ${type}]` // counter of the key let counter = 0 @@ -22,9 +24,10 @@ let counter = 0 // parsable. export const stableHash = (arg: any): string => { const type = typeof arg - const isDate = isObjectType(arg, 'Date') - const isRegex = isObjectType(arg, 'RegExp') - const isPlainObject = isObjectType(arg, 'Object') + const typeName = getTypeName(arg) + const isDate = isObjectTypeName(typeName, 'Date') + const isRegex = isObjectTypeName(typeName, 'RegExp') + const isPlainObject = isObjectTypeName(typeName, 'Object') let result: any let index: any diff --git a/src/_internal/utils/mutate.ts b/src/_internal/utils/mutate.ts index 6ea91bec08..071af7803e 100644 --- a/src/_internal/utils/mutate.ts +++ b/src/_internal/utils/mutate.ts @@ -123,6 +123,7 @@ export async function internalMutate( let data: any = _data let error: unknown + let isError = false // Update global timestamps. const beforeMutationTs = getTimestamp() @@ -155,6 +156,7 @@ export async function internalMutate( } catch (err) { // If it throws an error synchronously, we shouldn't update the cache. error = err + isError = true } } @@ -164,15 +166,16 @@ export async function internalMutate( // avoid race conditions. data = await (data as Promise).catch(err => { error = err + isError = true }) // Check if other mutations have occurred since we've started this mutation. // If there's a race we don't update cache or broadcast the change, // just return the data. if (beforeMutationTs !== MUTATION[key][0]) { - if (error) throw error + if (isError) throw error return data - } else if (error && hasOptimisticData && rollbackOnError(error)) { + } else if (isError && hasOptimisticData && rollbackOnError(error)) { // Rollback. Always populate the cache in this case but without // transforming the data. populateCache = true @@ -184,7 +187,7 @@ export async function internalMutate( // If we should write back the cache after request. if (populateCache) { - if (!error) { + if (!isError) { // Transform the result into data. if (isFunction(populateCache)) { const populateCachedData = populateCache(data, committedData) @@ -207,7 +210,7 @@ export async function internalMutate( }) // Throw error or return data - if (error) { + if (isError) { if (throwOnError) throw error return } diff --git a/src/index/index.ts b/src/index/index.ts index b5419ddb53..2ae26b1437 100644 --- a/src/index/index.ts +++ b/src/index/index.ts @@ -8,6 +8,12 @@ export { useSWRConfig } from '../_internal' export { mutate } from '../_internal' export { preload } from '../_internal' +// Config +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SWRGlobalConfig { + // suspense: true +} + // Types export type { SWRConfiguration, diff --git a/src/index/use-swr.ts b/src/index/use-swr.ts index 21c42a9c2e..91f3a471a9 100644 --- a/src/index/use-swr.ts +++ b/src/index/use-swr.ts @@ -278,8 +278,8 @@ export const useSWRHandler = ( const returnedData = keepPreviousData ? isUndefined(cachedData) - // checking undefined to avoid null being fallback as well - ? isUndefined(laggyDataRef.current) + ? // checking undefined to avoid null being fallback as well + isUndefined(laggyDataRef.current) ? data : laggyDataRef.current : cachedData @@ -639,13 +639,17 @@ export const useSWRHandler = ( // Trigger a revalidation if (shouldDoInitialRevalidation) { - if (isUndefined(data) || IS_SERVER) { - // Revalidate immediately. - softRevalidate() - } else { - // Delay the revalidate if we have data to return so we won't block - // rendering. - rAF(softRevalidate) + // Performance optimization: if a request is already in progress for this key, + // skip the revalidation to avoid redundant work + if (!FETCH[key]) { + if (isUndefined(data) || IS_SERVER) { + // Revalidate immediately. + softRevalidate() + } else { + // Delay the revalidate if we have data to return so we won't block + // rendering. + rAF(softRevalidate) + } } } diff --git a/test/tsconfig.json b/test/tsconfig.json index ba0b1f7a89..4cf0256290 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../tsconfig.json", "compilerOptions": { + "noEmit": true, "strict": false, "jsx": "react-jsx", "baseUrl": "..", @@ -10,8 +11,8 @@ "swr/immutable": ["./immutable/src/index.ts"], "swr/mutation": ["./mutation/src/index.ts"], "swr/_internal": ["./_internal/src/index.ts"], - "swr/subscription": ["subscription/src/index.ts"], - }, + "swr/subscription": ["subscription/src/index.ts"] + } }, "include": [".", "./jest-setup.ts"], "exclude": ["./type"] diff --git a/test/type/suspense/helper-types.tsx b/test/type/suspense/helper-types.tsx new file mode 100644 index 0000000000..0d571f58e6 --- /dev/null +++ b/test/type/suspense/helper-types.tsx @@ -0,0 +1,19 @@ +import type { BlockingData } from 'swr/_internal' +import { expectType } from '../utils' + +declare module 'swr' { + interface SWRGlobalConfig { + suspense: true + } +} + +export function testDataCached() { + expectType>(true) + expectType>(true) + expectType< + BlockingData + >(true) + expectType>( + true + ) +} diff --git a/test/type/suspense/suspense.ts b/test/type/suspense/suspense.ts new file mode 100644 index 0000000000..58323b9573 --- /dev/null +++ b/test/type/suspense/suspense.ts @@ -0,0 +1,13 @@ +import useSWR from 'swr' +import { expectType } from '../utils' + +declare module 'swr' { + interface SWRGlobalConfig { + suspense: true + } +} + +export function testSuspense() { + const { data } = useSWR('/api', (k: string) => Promise.resolve(k)) + expectType(data) +} diff --git a/test/type/suspense/tsconfig.json b/test/type/suspense/tsconfig.json new file mode 100644 index 0000000000..706abd8cd2 --- /dev/null +++ b/test/type/suspense/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "strict": true, + "jsx": "react-jsx" + }, + "include": ["./**/*.ts", "./**/*.tsx"], + "exclude": [] +} diff --git a/test/type/tsconfig.json b/test/type/tsconfig.json index 76044134d6..ed5b135090 100644 --- a/test/type/tsconfig.json +++ b/test/type/tsconfig.json @@ -1,8 +1,10 @@ { "extends": "../../tsconfig.json", - "compilerOptions": { + "compilerOptions": { + "noEmit": true, "strict": true, - "jsx": "react-jsx", + "jsx": "react-jsx" }, "include": ["./**/*.ts", "./**/*.tsx"], + "exclude": ["./suspense"] } diff --git a/test/use-swr-remote-mutation.test.tsx b/test/use-swr-remote-mutation.test.tsx index bcb7074e34..955d2b9b24 100644 --- a/test/use-swr-remote-mutation.test.tsx +++ b/test/use-swr-remote-mutation.test.tsx @@ -1112,4 +1112,32 @@ describe('useSWR - remote mutation', () => { expect(error.message).toBe('Can’t trigger the mutation: missing key.') }) + + it('should call `onError` and `onRejected` but do not call `onSuccess` if value an error is cast to false', async () => { + const key = createKey() + const onSuccess = jest.fn() + const onError = jest.fn() + const onRejected = jest.fn() + + const fetcher = () => { + return new Promise((_, reject) => reject('')); + }; + + function Page() { + const { trigger } = useSWRMutation(key, fetcher, { onError, onSuccess }) + + return + } + + render() + + await screen.findByText('trigger') + fireEvent.click(screen.getByText('trigger')) + + await nextTick() + + expect(onSuccess).not.toHaveBeenCalled() + expect(onError).toHaveBeenCalled() + expect(onRejected).toHaveBeenCalled() + }) })