From 3daff3843a6ffe1c7587365b360fe2f639f540bd Mon Sep 17 00:00:00 2001 From: Vedanta Somnathe Date: Mon, 1 Dec 2025 12:27:14 -0600 Subject: [PATCH] initial setup -- duplicated + package.json edited --- examples/preact/simple/.gitignore | 24 + examples/preact/simple/README.md | 15 + examples/preact/simple/index.html | 14 + examples/preact/simple/package.json | 22 + examples/preact/simple/public/vite.svg | 15 + examples/preact/simple/src/assets/preact.svg | 6 + examples/preact/simple/src/index.tsx | 43 + examples/preact/simple/src/style.css | 82 + examples/preact/simple/tsconfig.json | 20 + examples/preact/simple/vite.config.ts | 7 + packages/preact-query/CHANGELOG.md | 68 + packages/preact-query/README.md | 48 + packages/preact-query/eslint.config.js | 29 + packages/preact-query/package.json | 87 + packages/preact-query/root.eslint.config.js | 56 + packages/preact-query/root.tsup.config.js | 39 + .../preact-query/src/HydrationBoundary.tsx | 111 + .../preact-query/src/IsRestoringProvider.ts | 7 + .../preact-query/src/QueryClientProvider.tsx | 45 + .../src/QueryErrorResetBoundary.tsx | 56 + .../src/__tests__/HydrationBoundary.test.tsx | 483 ++ .../__tests__/QueryClientProvider.test.tsx | 165 + .../QueryResetErrorBoundary.test.tsx | 866 +++ .../__tests__/fine-grained-persister.test.tsx | 179 + .../__tests__/infiniteQueryOptions.test-d.tsx | 251 + .../__tests__/infiniteQueryOptions.test.tsx | 17 + .../src/__tests__/mutationOptions.test-d.tsx | 217 + .../src/__tests__/mutationOptions.test.tsx | 526 ++ .../src/__tests__/queryOptions.test-d.tsx | 286 + .../src/__tests__/queryOptions.test.tsx | 14 + .../src/__tests__/ssr-hydration.test.tsx | 269 + .../preact-query/src/__tests__/ssr.test.tsx | 176 + .../src/__tests__/suspense.test.tsx | 184 + .../src/__tests__/useInfiniteQuery.test-d.tsx | 142 + .../src/__tests__/useInfiniteQuery.test.tsx | 1864 +++++ .../src/__tests__/useIsFetching.test.tsx | 246 + .../src/__tests__/useMutation.test.tsx | 1182 +++ .../src/__tests__/useMutationState.test-d.tsx | 23 + .../src/__tests__/useMutationState.test.tsx | 238 + .../usePrefetchInfiniteQuery.test-d.tsx | 60 + .../usePrefetchInfiniteQuery.test.tsx | 201 + .../src/__tests__/usePrefetchQuery.test-d.tsx | 59 + .../src/__tests__/usePrefetchQuery.test.tsx | 288 + .../src/__tests__/useQueries.test-d.tsx | 170 + .../src/__tests__/useQueries.test.tsx | 1814 +++++ .../src/__tests__/useQuery.promise.test.tsx | 1431 ++++ .../src/__tests__/useQuery.test-d.tsx | 341 + .../src/__tests__/useQuery.test.tsx | 6778 +++++++++++++++++ .../useSuspenseInfiniteQuery.test-d.tsx | 93 + .../useSuspenseInfiniteQuery.test.tsx | 118 + .../__tests__/useSuspenseQueries.test-d.tsx | 256 + .../src/__tests__/useSuspenseQueries.test.tsx | 836 ++ .../src/__tests__/useSuspenseQuery.test-d.tsx | 88 + .../src/__tests__/useSuspenseQuery.test.tsx | 996 +++ packages/preact-query/src/__tests__/utils.tsx | 72 + .../preact-query/src/errorBoundaryUtils.ts | 76 + packages/preact-query/src/index.ts | 56 + .../preact-query/src/infiniteQueryOptions.ts | 149 + packages/preact-query/src/mutationOptions.ts | 41 + packages/preact-query/src/queryOptions.ts | 87 + packages/preact-query/src/suspense.ts | 80 + packages/preact-query/src/types.ts | 242 + packages/preact-query/src/useBaseQuery.ts | 170 + packages/preact-query/src/useInfiniteQuery.ts | 81 + packages/preact-query/src/useIsFetching.ts | 24 + packages/preact-query/src/useMutation.ts | 69 + packages/preact-query/src/useMutationState.ts | 75 + .../src/usePrefetchInfiniteQuery.tsx | 30 + .../preact-query/src/usePrefetchQuery.tsx | 19 + packages/preact-query/src/useQueries.ts | 332 + packages/preact-query/src/useQuery.ts | 52 + .../src/useSuspenseInfiniteQuery.ts | 50 + .../preact-query/src/useSuspenseQueries.ts | 211 + packages/preact-query/src/useSuspenseQuery.ts | 34 + packages/preact-query/test-setup.ts | 16 + packages/preact-query/tsconfig.json | 9 + packages/preact-query/tsconfig.legacy.json | 10 + packages/preact-query/tsconfig.prod.json | 8 + packages/preact-query/tsup.config.ts | 7 + packages/preact-query/vite.config.ts | 30 + pnpm-lock.yaml | 1545 +++- 81 files changed, 25221 insertions(+), 5 deletions(-) create mode 100644 examples/preact/simple/.gitignore create mode 100644 examples/preact/simple/README.md create mode 100644 examples/preact/simple/index.html create mode 100644 examples/preact/simple/package.json create mode 100644 examples/preact/simple/public/vite.svg create mode 100644 examples/preact/simple/src/assets/preact.svg create mode 100644 examples/preact/simple/src/index.tsx create mode 100644 examples/preact/simple/src/style.css create mode 100644 examples/preact/simple/tsconfig.json create mode 100644 examples/preact/simple/vite.config.ts create mode 100644 packages/preact-query/CHANGELOG.md create mode 100644 packages/preact-query/README.md create mode 100644 packages/preact-query/eslint.config.js create mode 100644 packages/preact-query/package.json create mode 100644 packages/preact-query/root.eslint.config.js create mode 100644 packages/preact-query/root.tsup.config.js create mode 100644 packages/preact-query/src/HydrationBoundary.tsx create mode 100644 packages/preact-query/src/IsRestoringProvider.ts create mode 100644 packages/preact-query/src/QueryClientProvider.tsx create mode 100644 packages/preact-query/src/QueryErrorResetBoundary.tsx create mode 100644 packages/preact-query/src/__tests__/HydrationBoundary.test.tsx create mode 100644 packages/preact-query/src/__tests__/QueryClientProvider.test.tsx create mode 100644 packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx create mode 100644 packages/preact-query/src/__tests__/fine-grained-persister.test.tsx create mode 100644 packages/preact-query/src/__tests__/infiniteQueryOptions.test-d.tsx create mode 100644 packages/preact-query/src/__tests__/infiniteQueryOptions.test.tsx create mode 100644 packages/preact-query/src/__tests__/mutationOptions.test-d.tsx create mode 100644 packages/preact-query/src/__tests__/mutationOptions.test.tsx create mode 100644 packages/preact-query/src/__tests__/queryOptions.test-d.tsx create mode 100644 packages/preact-query/src/__tests__/queryOptions.test.tsx create mode 100644 packages/preact-query/src/__tests__/ssr-hydration.test.tsx create mode 100644 packages/preact-query/src/__tests__/ssr.test.tsx create mode 100644 packages/preact-query/src/__tests__/suspense.test.tsx create mode 100644 packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx create mode 100644 packages/preact-query/src/__tests__/useInfiniteQuery.test.tsx create mode 100644 packages/preact-query/src/__tests__/useIsFetching.test.tsx create mode 100644 packages/preact-query/src/__tests__/useMutation.test.tsx create mode 100644 packages/preact-query/src/__tests__/useMutationState.test-d.tsx create mode 100644 packages/preact-query/src/__tests__/useMutationState.test.tsx create mode 100644 packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test-d.tsx create mode 100644 packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx create mode 100644 packages/preact-query/src/__tests__/usePrefetchQuery.test-d.tsx create mode 100644 packages/preact-query/src/__tests__/usePrefetchQuery.test.tsx create mode 100644 packages/preact-query/src/__tests__/useQueries.test-d.tsx create mode 100644 packages/preact-query/src/__tests__/useQueries.test.tsx create mode 100644 packages/preact-query/src/__tests__/useQuery.promise.test.tsx create mode 100644 packages/preact-query/src/__tests__/useQuery.test-d.tsx create mode 100644 packages/preact-query/src/__tests__/useQuery.test.tsx create mode 100644 packages/preact-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx create mode 100644 packages/preact-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx create mode 100644 packages/preact-query/src/__tests__/useSuspenseQueries.test-d.tsx create mode 100644 packages/preact-query/src/__tests__/useSuspenseQueries.test.tsx create mode 100644 packages/preact-query/src/__tests__/useSuspenseQuery.test-d.tsx create mode 100644 packages/preact-query/src/__tests__/useSuspenseQuery.test.tsx create mode 100644 packages/preact-query/src/__tests__/utils.tsx create mode 100644 packages/preact-query/src/errorBoundaryUtils.ts create mode 100644 packages/preact-query/src/index.ts create mode 100644 packages/preact-query/src/infiniteQueryOptions.ts create mode 100644 packages/preact-query/src/mutationOptions.ts create mode 100644 packages/preact-query/src/queryOptions.ts create mode 100644 packages/preact-query/src/suspense.ts create mode 100644 packages/preact-query/src/types.ts create mode 100644 packages/preact-query/src/useBaseQuery.ts create mode 100644 packages/preact-query/src/useInfiniteQuery.ts create mode 100644 packages/preact-query/src/useIsFetching.ts create mode 100644 packages/preact-query/src/useMutation.ts create mode 100644 packages/preact-query/src/useMutationState.ts create mode 100644 packages/preact-query/src/usePrefetchInfiniteQuery.tsx create mode 100644 packages/preact-query/src/usePrefetchQuery.tsx create mode 100644 packages/preact-query/src/useQueries.ts create mode 100644 packages/preact-query/src/useQuery.ts create mode 100644 packages/preact-query/src/useSuspenseInfiniteQuery.ts create mode 100644 packages/preact-query/src/useSuspenseQueries.ts create mode 100644 packages/preact-query/src/useSuspenseQuery.ts create mode 100644 packages/preact-query/test-setup.ts create mode 100644 packages/preact-query/tsconfig.json create mode 100644 packages/preact-query/tsconfig.legacy.json create mode 100644 packages/preact-query/tsconfig.prod.json create mode 100644 packages/preact-query/tsup.config.ts create mode 100644 packages/preact-query/vite.config.ts diff --git a/examples/preact/simple/.gitignore b/examples/preact/simple/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/examples/preact/simple/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/preact/simple/README.md b/examples/preact/simple/README.md new file mode 100644 index 0000000000..db14356a6c --- /dev/null +++ b/examples/preact/simple/README.md @@ -0,0 +1,15 @@ +# `create-preact` + +

+ +

+ +

Get started using Preact and Vite!

+ +## Getting Started + +- `pnpm dev` - Starts a dev server at http://localhost:5173/ + +- `pnpm build` - Builds for production, emitting to `dist/` + +- `pnpm preview` - Starts a server at http://localhost:4173/ to test production build locally diff --git a/examples/preact/simple/index.html b/examples/preact/simple/index.html new file mode 100644 index 0000000000..cbc1f5adf8 --- /dev/null +++ b/examples/preact/simple/index.html @@ -0,0 +1,14 @@ + + + + + + + + Vite + Preact + + +
+ + + diff --git a/examples/preact/simple/package.json b/examples/preact/simple/package.json new file mode 100644 index 0000000000..2d4f070a32 --- /dev/null +++ b/examples/preact/simple/package.json @@ -0,0 +1,22 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "preact": "^10.26.9" + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.2", + "eslint": "^9.36.0", + "eslint-config-preact": "^2.0.0", + "typescript": "^5.9.3", + "vite": "^7.0.4" + }, + "eslintConfig": { + "extends": "preact" + } +} \ No newline at end of file diff --git a/examples/preact/simple/public/vite.svg b/examples/preact/simple/public/vite.svg new file mode 100644 index 0000000000..ffcb6bcf53 --- /dev/null +++ b/examples/preact/simple/public/vite.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/examples/preact/simple/src/assets/preact.svg b/examples/preact/simple/src/assets/preact.svg new file mode 100644 index 0000000000..f34e939f68 --- /dev/null +++ b/examples/preact/simple/src/assets/preact.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/examples/preact/simple/src/index.tsx b/examples/preact/simple/src/index.tsx new file mode 100644 index 0000000000..4dbad055a3 --- /dev/null +++ b/examples/preact/simple/src/index.tsx @@ -0,0 +1,43 @@ +import { render } from 'preact'; + +import preactLogo from './assets/preact.svg'; +import './style.css'; + +export function App() { + return ( +
+ + Preact logo + +

Get Started building Vite-powered Preact Apps

+
+ + + +
+
+ ); +} + +function Resource(props) { + return ( + +

{props.title}

+

{props.description}

+
+ ); +} + +render(, document.getElementById('app')); diff --git a/examples/preact/simple/src/style.css b/examples/preact/simple/src/style.css new file mode 100644 index 0000000000..cb14c0c196 --- /dev/null +++ b/examples/preact/simple/src/style.css @@ -0,0 +1,82 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color: #222; + background-color: #ffffff; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + display: flex; + align-items: center; + min-height: 100vh; +} + +#app { + max-width: 1280px; + margin: 0 auto; + text-align: center; +} + +img { + margin-bottom: 1.5rem; +} + +img:hover { + filter: drop-shadow(0 0 2em #673ab8aa); +} + +section { + margin-top: 5rem; + display: grid; + grid-template-columns: repeat(3, 1fr); + column-gap: 1.5rem; +} + +.resource { + padding: 0.75rem 1.5rem; + border-radius: 0.5rem; + text-align: left; + text-decoration: none; + color: #222; + background-color: #f1f1f1; + border: 1px solid transparent; +} + +.resource:hover { + border: 1px solid #000; + box-shadow: 0 25px 50px -12px #673ab888; +} + +@media (max-width: 639px) { + #app { + margin: 2rem; + } + section { + margin-top: 5rem; + grid-template-columns: 1fr; + row-gap: 1rem; + } +} + +@media (prefers-color-scheme: dark) { + :root { + color: #ccc; + background-color: #1a1a1a; + } + .resource { + color: #ccc; + background-color: #161616; + } + .resource:hover { + border: 1px solid #bbb; + } +} diff --git a/examples/preact/simple/tsconfig.json b/examples/preact/simple/tsconfig.json new file mode 100644 index 0000000000..12bb30b41f --- /dev/null +++ b/examples/preact/simple/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "allowJs": true, + "checkJs": true, + + /* Preact Config */ + "jsx": "react-jsx", + "jsxImportSource": "preact", + "skipLibCheck": true, + "paths": { + "react": ["./node_modules/preact/compat/"], + "react-dom": ["./node_modules/preact/compat/"] + } + }, + "include": ["node_modules/vite/client.d.ts", "**/*"] +} diff --git a/examples/preact/simple/vite.config.ts b/examples/preact/simple/vite.config.ts new file mode 100644 index 0000000000..0e309b2b42 --- /dev/null +++ b/examples/preact/simple/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import preact from '@preact/preset-vite'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [preact()], +}); diff --git a/packages/preact-query/CHANGELOG.md b/packages/preact-query/CHANGELOG.md new file mode 100644 index 0000000000..0df81ebaf7 --- /dev/null +++ b/packages/preact-query/CHANGELOG.md @@ -0,0 +1,68 @@ +# @tanstack/react-query + +## 5.90.11 + +### Patch Changes + +- Prevent infinite render loops when useSuspenseQueries has duplicate queryKeys ([#9886](https://github.com/TanStack/query/pull/9886)) + +- Updated dependencies [[`c01b150`](https://github.com/TanStack/query/commit/c01b150e3673e11d6533768529a5e6fe3ebee68c)]: + - @tanstack/query-core@5.90.11 + +## 5.90.10 + +### Patch Changes + +- Updated dependencies [[`8e2e174`](https://github.com/TanStack/query/commit/8e2e174e9fd2e7b94cd232041e49c9d014d74e26), [`eb559a6`](https://github.com/TanStack/query/commit/eb559a66dc0d77dd46435f624fa64fc068bef9ae)]: + - @tanstack/query-core@5.90.10 + +## 5.90.9 + +### Patch Changes + +- Updated dependencies [[`08b211f`](https://github.com/TanStack/query/commit/08b211f8aa475e05d2f13a36517fc556861ef962)]: + - @tanstack/query-core@5.90.9 + +## 5.90.8 + +### Patch Changes + +- Updated dependencies [[`c0ec9fe`](https://github.com/TanStack/query/commit/c0ec9fe0d1426fe3f233adda3ebf23989ffaa110)]: + - @tanstack/query-core@5.90.8 + +## 5.90.7 + +### Patch Changes + +- Updated dependencies [[`b4cd121`](https://github.com/TanStack/query/commit/b4cd121a39d07cefaa3a3411136d342cc54ce8fb)]: + - @tanstack/query-core@5.90.7 + +## 5.90.6 + +### Patch Changes + +- Updated dependencies [[`1638c02`](https://github.com/TanStack/query/commit/1638c028df55648995d04431179904371a189772)]: + - @tanstack/query-core@5.90.6 + +## 5.90.5 + +### Patch Changes + +- Updated dependencies [[`e42ddfe`](https://github.com/TanStack/query/commit/e42ddfe919f34f847ca101aeef162c69845f9a1e)]: + - @tanstack/query-core@5.90.5 + +## 5.90.4 + +### Patch Changes + +- Updated dependencies [[`20ef922`](https://github.com/TanStack/query/commit/20ef922a0a7c3aee00150bf69123c338b0922922)]: + - @tanstack/query-core@5.90.4 + +## 5.90.3 + +### Patch Changes + +- Avoid unhandled promise rejection errors during de/rehydration of pending queries. ([#9752](https://github.com/TanStack/query/pull/9752)) + +- Updated dependencies [[`4e1c433`](https://github.com/TanStack/query/commit/4e1c4338a72f7384600bbda99e44bc1891695df4)]: + - @tanstack/query-core@5.90.3 diff --git a/packages/preact-query/README.md b/packages/preact-query/README.md new file mode 100644 index 0000000000..96bffea2f5 --- /dev/null +++ b/packages/preact-query/README.md @@ -0,0 +1,48 @@ + + +![TanStack Query Header](https://github.com/TanStack/query/raw/main/media/repo-header.png) + +Hooks for fetching, caching and updating asynchronous data in React + + + #TanStack + + + + + + + + + + semantic-release + + Join the discussion on Github +Best of JS + + + + + Gitpod Ready-to-Code + + +Enjoy this library? Try the entire [TanStack](https://tanstack.com)! [TanStack Table](https://github.com/TanStack/table), [TanStack Router](https://github.com/tanstack/router), [TanStack Virtual](https://github.com/tanstack/virtual), [React Charts](https://github.com/TanStack/react-charts), [React Ranger](https://github.com/TanStack/ranger) + +## Visit [tanstack.com/query](https://tanstack.com/query) for docs, guides, API and more! + +## Quick Features + +- Transport/protocol/backend agnostic data fetching (REST, GraphQL, promises, whatever!) +- Auto Caching + Refetching (stale-while-revalidate, Window Refocus, Polling/Realtime) +- Parallel + Dependent Queries +- Mutations + Reactive Query Refetching +- Multi-layer Cache + Automatic Garbage Collection +- Paginated + Cursor-based Queries +- Load-More + Infinite Scroll Queries w/ Scroll Recovery +- Request Cancellation +- [React Suspense](https://react.dev/reference/react/Suspense) + Fetch-As-You-Render Query Prefetching +- Dedicated Devtools + +### [Become a Sponsor!](https://github.com/sponsors/tannerlinsley/) + + diff --git a/packages/preact-query/eslint.config.js b/packages/preact-query/eslint.config.js new file mode 100644 index 0000000000..edacda9938 --- /dev/null +++ b/packages/preact-query/eslint.config.js @@ -0,0 +1,29 @@ +// @ts-check + +import pluginReact from '@eslint-react/eslint-plugin' +import reactHooks from 'eslint-plugin-react-hooks' +import rootConfig from './root.eslint.config.js' + +export default [ + ...rootConfig, + // @ts-expect-error wtf + ...reactHooks.configs['recommended-latest'], + { + files: ['**/*.{ts,tsx}'], + ...pluginReact.configs.recommended, + rules: { + '@eslint-react/no-context-provider': 'off', // We need to be React 18 compatible + 'react-hooks/exhaustive-deps': 'error', + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/unsupported-syntax': 'error', + 'react-hooks/incompatible-library': 'error', + }, + }, + { + files: ['**/__tests__/**'], + rules: { + '@eslint-react/dom/no-missing-button-type': 'off', + '@typescript-eslint/no-unnecessary-condition': 'off', + }, + }, +] diff --git a/packages/preact-query/package.json b/packages/preact-query/package.json new file mode 100644 index 0000000000..05ea1368b6 --- /dev/null +++ b/packages/preact-query/package.json @@ -0,0 +1,87 @@ +{ + "name": "@tanstack/preact-query", + "version": "5.90.11", + "description": "Hooks for managing, caching and syncing asynchronous and remote data in preact", + "author": "tannerlinsley", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/query.git", + "directory": "packages/preact-query" + }, + "homepage": "https://tanstack.com/query", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "scripts": { + "clean": "premove ./build ./coverage ./dist-ts", + "compile": "tsc --build", + "test:eslint": "eslint --concurrency=auto ./src", + "test:types": "npm-run-all --serial test:types:*", + "test:types:ts50": "node ../../node_modules/typescript50/lib/tsc.js --build tsconfig.legacy.json", + "test:types:ts51": "node ../../node_modules/typescript51/lib/tsc.js --build tsconfig.legacy.json", + "test:types:ts52": "node ../../node_modules/typescript52/lib/tsc.js --build tsconfig.legacy.json", + "test:types:ts53": "node ../../node_modules/typescript53/lib/tsc.js --build tsconfig.legacy.json", + "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js --build tsconfig.legacy.json", + "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js --build tsconfig.legacy.json", + "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js --build tsconfig.legacy.json", + "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js --build tsconfig.legacy.json", + "test:types:tscurrent": "tsc --build", + "test:lib": "vitest", + "test:lib:dev": "pnpm run test:lib --watch", + "test:build": "publint --strict && attw --pack", + "build": "pnpm build:tsup && pnpm build:codemods", + "build:tsup": "tsup --tsconfig tsconfig.prod.json", + "build:codemods": "cpy ../query-codemods/* ./build/codemods" + }, + "type": "module", + "types": "build/legacy/index.d.ts", + "main": "build/legacy/index.cjs", + "module": "build/legacy/index.js", + "react-native": "src/index.ts", + "exports": { + ".": { + "@tanstack/custom-condition": "./src/index.ts", + "import": { + "types": "./build/modern/index.d.ts", + "default": "./build/modern/index.js" + }, + "require": { + "types": "./build/modern/index.d.cts", + "default": "./build/modern/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "build", + "src", + "!src/__tests__", + "!build/codemods/node_modules", + "!build/codemods/vite.config.ts", + "!build/codemods/**/__testfixtures__", + "!build/codemods/**/__tests__" + ], + "dependencies": { + "@tanstack/query-core": "workspace:*" + }, + "devDependencies": { + "@tanstack/query-persist-client-core": "workspace:*", + "@tanstack/query-test-utils": "workspace:*", + "@testing-library/react": "^16.1.0", + "@testing-library/react-render-stream": "^2.0.0", + "@types/react": "^19.0.1", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^4.3.4", + "cpy-cli": "^5.0.0", + "npm-run-all2": "^5.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-error-boundary": "^4.1.2" + }, + "peerDependencies": { + "react": "^18 || ^19" + } +} diff --git a/packages/preact-query/root.eslint.config.js b/packages/preact-query/root.eslint.config.js new file mode 100644 index 0000000000..8b07108d25 --- /dev/null +++ b/packages/preact-query/root.eslint.config.js @@ -0,0 +1,56 @@ +// @ts-check + +// @ts-ignore Needed due to moduleResolution Node vs Bundler +import { tanstackConfig } from '@tanstack/eslint-config' +import pluginCspell from '@cspell/eslint-plugin' +import vitest from '@vitest/eslint-plugin' + +export default [ + ...tanstackConfig, + { + name: 'tanstack/temp', + plugins: { + cspell: pluginCspell, + }, + rules: { + 'cspell/spellchecker': [ + 'warn', + { + cspell: { + words: [ + 'Promisable', // Our public interface + 'TSES', // @typescript-eslint package's interface + 'codemod', // We support our codemod + 'combinate', // Library name + 'datatag', // Query options tagging + 'extralight', // Our public interface + 'jscodeshift', + 'refetches', // Query refetch operations + 'retryer', // Our public interface + 'solidjs', // Our target framework + 'tabular-nums', // https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-numeric + 'tanstack', // Our package scope + 'todos', // Too general word to be caught as error + 'tsqd', // Our public interface (TanStack Query Devtools shorthand) + 'tsup', // We use tsup as builder + 'typecheck', // Field of vite.config.ts + 'vue-demi', // dependency of @tanstack/vue-query + 'ɵkind', // Angular specific + 'ɵproviders', // Angular specific + ], + }, + }, + ], + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-unsafe-function-type': 'off', + 'no-case-declarations': 'off', + 'prefer-const': 'off', + }, + }, + { + files: ['**/*.spec.ts*', '**/*.test.ts*', '**/*.test-d.ts*'], + plugins: { vitest }, + rules: vitest.configs.recommended.rules, + settings: { vitest: { typecheck: true } }, + }, +] diff --git a/packages/preact-query/root.tsup.config.js b/packages/preact-query/root.tsup.config.js new file mode 100644 index 0000000000..28fd7edde9 --- /dev/null +++ b/packages/preact-query/root.tsup.config.js @@ -0,0 +1,39 @@ +// @ts-check + +import { esbuildPluginFilePathExtensions } from 'esbuild-plugin-file-path-extensions' + +/** + * @param {Object} opts - Options for building configurations. + * @param {string[]} opts.entry - The entry array. + * @returns {import('tsup').Options} + */ +export function modernConfig(opts) { + return { + entry: opts.entry, + format: ['cjs', 'esm'], + target: ['chrome91', 'firefox90', 'edge91', 'safari15', 'ios15', 'opera77'], + outDir: 'build/modern', + dts: true, + sourcemap: true, + clean: true, + esbuildPlugins: [esbuildPluginFilePathExtensions({ esmExtension: 'js' })], + } +} + +/** + * @param {Object} opts - Options for building configurations. + * @param {string[]} opts.entry - The entry array. + * @returns {import('tsup').Options} + */ +export function legacyConfig(opts) { + return { + entry: opts.entry, + format: ['cjs', 'esm'], + target: ['es2020', 'node16'], + outDir: 'build/legacy', + dts: true, + sourcemap: true, + clean: true, + esbuildPlugins: [esbuildPluginFilePathExtensions({ esmExtension: 'js' })], + } +} diff --git a/packages/preact-query/src/HydrationBoundary.tsx b/packages/preact-query/src/HydrationBoundary.tsx new file mode 100644 index 0000000000..901c8e9686 --- /dev/null +++ b/packages/preact-query/src/HydrationBoundary.tsx @@ -0,0 +1,111 @@ +'use client' +import * as React from 'react' + +import { hydrate } from '@tanstack/query-core' +import { useQueryClient } from './QueryClientProvider' +import type { + DehydratedState, + HydrateOptions, + OmitKeyof, + QueryClient, +} from '@tanstack/query-core' + +export interface HydrationBoundaryProps { + state: DehydratedState | null | undefined + options?: OmitKeyof & { + defaultOptions?: OmitKeyof< + Exclude, + 'mutations' + > + } + children?: React.ReactNode + queryClient?: QueryClient +} + +export const HydrationBoundary = ({ + children, + options = {}, + state, + queryClient, +}: HydrationBoundaryProps) => { + const client = useQueryClient(queryClient) + + const optionsRef = React.useRef(options) + React.useEffect(() => { + optionsRef.current = options + }) + + // This useMemo is for performance reasons only, everything inside it must + // be safe to run in every render and code here should be read as "in render". + // + // This code needs to happen during the render phase, because after initial + // SSR, hydration needs to happen _before_ children render. Also, if hydrating + // during a transition, we want to hydrate as much as is safe in render so + // we can prerender as much as possible. + // + // For any queries that already exist in the cache, we want to hold back on + // hydrating until _after_ the render phase. The reason for this is that during + // transitions, we don't want the existing queries and observers to update to + // the new data on the current page, only _after_ the transition is committed. + // If the transition is aborted, we will have hydrated any _new_ queries, but + // we throw away the fresh data for any existing ones to avoid unexpectedly + // updating the UI. + const hydrationQueue: DehydratedState['queries'] | undefined = + React.useMemo(() => { + if (state) { + if (typeof state !== 'object') { + return + } + + const queryCache = client.getQueryCache() + // State is supplied from the outside and we might as well fail + // gracefully if it has the wrong shape, so while we type `queries` + // as required, we still provide a fallback. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const queries = state.queries || [] + + const newQueries: DehydratedState['queries'] = [] + const existingQueries: DehydratedState['queries'] = [] + for (const dehydratedQuery of queries) { + const existingQuery = queryCache.get(dehydratedQuery.queryHash) + + if (!existingQuery) { + newQueries.push(dehydratedQuery) + } else { + const hydrationIsNewer = + dehydratedQuery.state.dataUpdatedAt > + existingQuery.state.dataUpdatedAt || + (dehydratedQuery.promise && + existingQuery.state.status !== 'pending' && + existingQuery.state.fetchStatus !== 'fetching' && + dehydratedQuery.dehydratedAt !== undefined && + dehydratedQuery.dehydratedAt > + existingQuery.state.dataUpdatedAt) + + if (hydrationIsNewer) { + existingQueries.push(dehydratedQuery) + } + } + } + + if (newQueries.length > 0) { + // It's actually fine to call this with queries/state that already exists + // in the cache, or is older. hydrate() is idempotent for queries. + // eslint-disable-next-line react-hooks/refs + hydrate(client, { queries: newQueries }, optionsRef.current) + } + if (existingQueries.length > 0) { + return existingQueries + } + } + return undefined + }, [client, state]) + + React.useEffect(() => { + if (hydrationQueue) { + hydrate(client, { queries: hydrationQueue }, optionsRef.current) + } + }, [client, hydrationQueue]) + + return children as React.ReactElement +} diff --git a/packages/preact-query/src/IsRestoringProvider.ts b/packages/preact-query/src/IsRestoringProvider.ts new file mode 100644 index 0000000000..7d59c72507 --- /dev/null +++ b/packages/preact-query/src/IsRestoringProvider.ts @@ -0,0 +1,7 @@ +'use client' +import * as React from 'react' + +const IsRestoringContext = React.createContext(false) + +export const useIsRestoring = () => React.useContext(IsRestoringContext) +export const IsRestoringProvider = IsRestoringContext.Provider diff --git a/packages/preact-query/src/QueryClientProvider.tsx b/packages/preact-query/src/QueryClientProvider.tsx new file mode 100644 index 0000000000..7fa1df9798 --- /dev/null +++ b/packages/preact-query/src/QueryClientProvider.tsx @@ -0,0 +1,45 @@ +'use client' +import * as React from 'react' + +import type { QueryClient } from '@tanstack/query-core' + +export const QueryClientContext = React.createContext( + undefined, +) + +export const useQueryClient = (queryClient?: QueryClient) => { + const client = React.useContext(QueryClientContext) + + if (queryClient) { + return queryClient + } + + if (!client) { + throw new Error('No QueryClient set, use QueryClientProvider to set one') + } + + return client +} + +export type QueryClientProviderProps = { + client: QueryClient + children?: React.ReactNode +} + +export const QueryClientProvider = ({ + client, + children, +}: QueryClientProviderProps): React.JSX.Element => { + React.useEffect(() => { + client.mount() + return () => { + client.unmount() + } + }, [client]) + + return ( + + {children} + + ) +} diff --git a/packages/preact-query/src/QueryErrorResetBoundary.tsx b/packages/preact-query/src/QueryErrorResetBoundary.tsx new file mode 100644 index 0000000000..910215bcb6 --- /dev/null +++ b/packages/preact-query/src/QueryErrorResetBoundary.tsx @@ -0,0 +1,56 @@ +'use client' +import * as React from 'react' + +// CONTEXT +export type QueryErrorResetFunction = () => void +export type QueryErrorIsResetFunction = () => boolean +export type QueryErrorClearResetFunction = () => void + +export interface QueryErrorResetBoundaryValue { + clearReset: QueryErrorClearResetFunction + isReset: QueryErrorIsResetFunction + reset: QueryErrorResetFunction +} + +function createValue(): QueryErrorResetBoundaryValue { + let isReset = false + return { + clearReset: () => { + isReset = false + }, + reset: () => { + isReset = true + }, + isReset: () => { + return isReset + }, + } +} + +const QueryErrorResetBoundaryContext = React.createContext(createValue()) + +// HOOK + +export const useQueryErrorResetBoundary = () => + React.useContext(QueryErrorResetBoundaryContext) + +// COMPONENT + +export type QueryErrorResetBoundaryFunction = ( + value: QueryErrorResetBoundaryValue, +) => React.ReactNode + +export interface QueryErrorResetBoundaryProps { + children: QueryErrorResetBoundaryFunction | React.ReactNode +} + +export const QueryErrorResetBoundary = ({ + children, +}: QueryErrorResetBoundaryProps) => { + const [value] = React.useState(() => createValue()) + return ( + + {typeof children === 'function' ? children(value) : children} + + ) +} diff --git a/packages/preact-query/src/__tests__/HydrationBoundary.test.tsx b/packages/preact-query/src/__tests__/HydrationBoundary.test.tsx new file mode 100644 index 0000000000..8611c4c40d --- /dev/null +++ b/packages/preact-query/src/__tests__/HydrationBoundary.test.tsx @@ -0,0 +1,483 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import * as React from 'react' +import { render } from '@testing-library/react' +import * as coreModule from '@tanstack/query-core' +import { sleep } from '@tanstack/query-test-utils' +import { + HydrationBoundary, + QueryClient, + QueryClientProvider, + dehydrate, + useQuery, +} from '..' +import type { hydrate } from '@tanstack/query-core' + +describe('React hydration', () => { + let stringifiedState: string + + beforeEach(async () => { + vi.useFakeTimers() + const queryClient = new QueryClient() + queryClient.prefetchQuery({ + queryKey: ['string'], + queryFn: () => sleep(10).then(() => ['stringCached']), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydrated = dehydrate(queryClient) + stringifiedState = JSON.stringify(dehydrated) + queryClient.clear() + }) + afterEach(() => { + vi.useRealTimers() + }) + + test('should hydrate queries to the cache on context', async () => { + const dehydratedState = JSON.parse(stringifiedState) + const queryClient = new QueryClient() + + function Page() { + const { data } = useQuery({ + queryKey: ['string'], + queryFn: () => sleep(20).then(() => ['string']), + }) + return ( +
+

{data}

+
+ ) + } + + const rendered = render( + + + + + , + ) + + expect(rendered.getByText('stringCached')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(21) + expect(rendered.getByText('string')).toBeInTheDocument() + queryClient.clear() + }) + + test('should hydrate queries to the cache on custom context', async () => { + const queryClientInner = new QueryClient() + const queryClientOuter = new QueryClient() + + const dehydratedState = JSON.parse(stringifiedState) + + function Page() { + const { data } = useQuery({ + queryKey: ['string'], + queryFn: () => sleep(20).then(() => ['string']), + }) + return ( +
+

{data}

+
+ ) + } + + const rendered = render( + + + + + + + , + ) + + expect(rendered.getByText('stringCached')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(21) + expect(rendered.getByText('string')).toBeInTheDocument() + + queryClientInner.clear() + queryClientOuter.clear() + }) + + describe('ReactQueryCacheProvider with hydration support', () => { + test('should hydrate new queries if queries change', async () => { + const dehydratedState = JSON.parse(stringifiedState) + const queryClient = new QueryClient() + + function Page({ queryKey }: { queryKey: [string] }) { + const { data } = useQuery({ + queryKey, + queryFn: () => sleep(20).then(() => queryKey), + }) + return ( +
+

{data}

+
+ ) + } + + const rendered = render( + + + + + , + ) + + expect(rendered.getByText('stringCached')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(21) + expect(rendered.getByText('string')).toBeInTheDocument() + + const intermediateClient = new QueryClient() + + intermediateClient.prefetchQuery({ + queryKey: ['string'], + queryFn: () => sleep(20).then(() => ['should change']), + }) + intermediateClient.prefetchQuery({ + queryKey: ['added'], + queryFn: () => sleep(20).then(() => ['added']), + }) + await vi.advanceTimersByTimeAsync(20) + const dehydrated = dehydrate(intermediateClient) + intermediateClient.clear() + + rendered.rerender( + + + + + + , + ) + + // Existing observer should not have updated at this point, + // as that would indicate a side effect in the render phase + expect(rendered.getByText('string')).toBeInTheDocument() + // New query data should be available immediately + expect(rendered.getByText('added')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(0) + // After effects phase has had time to run, the observer should have updated + expect(rendered.queryByText('string')).not.toBeInTheDocument() + expect(rendered.getByText('should change')).toBeInTheDocument() + + queryClient.clear() + }) + + // When we hydrate in transitions that are later aborted, it could be + // confusing to both developers and users if we suddenly updated existing + // state on the screen (why did this update when it was not stale, nothing + // remounted, I didn't change tabs etc?). + // Any queries that does not exist in the cache yet can still be hydrated + // since they don't have any observers on the current page that would update. + test('should hydrate new but not existing queries if transition is aborted', async () => { + const initialDehydratedState = JSON.parse(stringifiedState) + const queryClient = new QueryClient() + + function Page({ queryKey }: { queryKey: [string] }) { + const { data } = useQuery({ + queryKey, + queryFn: () => sleep(20).then(() => queryKey), + }) + return ( +
+

{data}

+
+ ) + } + + const rendered = render( + + + + + , + ) + + expect(rendered.getByText('stringCached')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(21) + expect(rendered.getByText('string')).toBeInTheDocument() + + const intermediateClient = new QueryClient() + intermediateClient.prefetchQuery({ + queryKey: ['string'], + queryFn: () => sleep(20).then(() => ['should not change']), + }) + intermediateClient.prefetchQuery({ + queryKey: ['added'], + queryFn: () => sleep(20).then(() => ['added']), + }) + await vi.advanceTimersByTimeAsync(20) + + const newDehydratedState = dehydrate(intermediateClient) + intermediateClient.clear() + + function Thrower(): never { + throw new Promise(() => { + // Never resolve + }) + } + + React.startTransition(() => { + rendered.rerender( + + + + + + + + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + }) + + React.startTransition(() => { + rendered.rerender( + + + + + + , + ) + + // This query existed before the transition so it should stay the same + expect(rendered.getByText('string')).toBeInTheDocument() + expect( + rendered.queryByText('should not change'), + ).not.toBeInTheDocument() + // New query data should be available immediately because it was + // hydrated in the previous transition, even though the new dehydrated + // state did not contain it + expect(rendered.getByText('added')).toBeInTheDocument() + }) + + await vi.advanceTimersByTimeAsync(20) + // It should stay the same even after effects have had a chance to run + expect(rendered.getByText('string')).toBeInTheDocument() + expect(rendered.queryByText('should not change')).not.toBeInTheDocument() + + queryClient.clear() + }) + + test('should hydrate queries to new cache if cache changes', async () => { + const dehydratedState = JSON.parse(stringifiedState) + const queryClient = new QueryClient() + + function Page() { + const { data } = useQuery({ + queryKey: ['string'], + queryFn: () => sleep(20).then(() => ['string']), + }) + return ( +
+

{data}

+
+ ) + } + + const rendered = render( + + + + + , + ) + + expect(rendered.getByText('stringCached')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(21) + expect(rendered.getByText('string')).toBeInTheDocument() + const newClientQueryClient = new QueryClient() + + rendered.rerender( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(20) + expect(rendered.getByText('string')).toBeInTheDocument() + + queryClient.clear() + newClientQueryClient.clear() + }) + }) + + test('should not hydrate queries if state is null', async () => { + const queryClient = new QueryClient() + + const hydrateSpy = vi.spyOn(coreModule, 'hydrate') + + function Page() { + return null + } + + render( + + + + + , + ) + + await Promise.all( + Array.from({ length: 1000 }).map(async (_, index) => { + await vi.advanceTimersByTimeAsync(index) + expect(hydrateSpy).toHaveBeenCalledTimes(0) + }), + ) + + hydrateSpy.mockRestore() + queryClient.clear() + }) + + test('should not hydrate queries if state is undefined', async () => { + const queryClient = new QueryClient() + + const hydrateSpy = vi.spyOn(coreModule, 'hydrate') + + function Page() { + return null + } + + render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + expect(hydrateSpy).toHaveBeenCalledTimes(0) + + hydrateSpy.mockRestore() + queryClient.clear() + }) + + test('should not hydrate queries if state is not an object', async () => { + const queryClient = new QueryClient() + + const hydrateSpy = vi.spyOn(coreModule, 'hydrate') + + function Page() { + return null + } + + render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + expect(hydrateSpy).toHaveBeenCalledTimes(0) + + hydrateSpy.mockRestore() + queryClient.clear() + }) + + test('should handle state without queries property gracefully', async () => { + const queryClient = new QueryClient() + + const hydrateSpy = vi.spyOn(coreModule, 'hydrate') + + function Page() { + return null + } + + render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + expect(hydrateSpy).toHaveBeenCalledTimes(0) + + hydrateSpy.mockRestore() + queryClient.clear() + }) + + // https://github.com/TanStack/query/issues/8677 + test('should not infinite loop when hydrating promises that resolve to errors', async () => { + const originalHydrate = coreModule.hydrate + const hydrateSpy = vi.spyOn(coreModule, 'hydrate') + let hydrationCount = 0 + hydrateSpy.mockImplementation((...args: Parameters) => { + hydrationCount++ + // Arbitrary number + if (hydrationCount > 10) { + // This is a rough way to detect it. Calling hydrate multiple times with + // the same data is usually fine, but in this case it indicates the + // logic in HydrationBoundary is not working as expected. + throw new Error('Too many hydrations detected') + } + return originalHydrate(...args) + }) + + // For the bug to trigger, there needs to already be a query in the cache, + // with a dataUpdatedAt earlier than the dehydratedAt of the next query + const clientQueryClient = new QueryClient() + clientQueryClient.prefetchQuery({ + queryKey: ['promise'], + queryFn: () => sleep(20).then(() => 'existing'), + }) + await vi.advanceTimersByTimeAsync(20) + + const prefetchQueryClient = new QueryClient({ + defaultOptions: { + dehydrate: { + shouldDehydrateQuery: () => true, + }, + }, + }) + prefetchQueryClient.prefetchQuery({ + queryKey: ['promise'], + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('Query failed'))), + }) + + const dehydratedState = dehydrate(prefetchQueryClient) + + // Mimic what React/our synchronous thenable does for already rejected promises + // @ts-expect-error + dehydratedState.queries[0].promise.status = 'failure' + + function Page() { + const { data } = useQuery({ + queryKey: ['promise'], + queryFn: () => sleep(20).then(() => ['new']), + }) + return ( +
+

{data}

+
+ ) + } + + const rendered = render( + + + + + , + ) + + expect(rendered.getByText('existing')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(21) + expect(rendered.getByText('new')).toBeInTheDocument() + + hydrateSpy.mockRestore() + prefetchQueryClient.clear() + clientQueryClient.clear() + }) +}) diff --git a/packages/preact-query/src/__tests__/QueryClientProvider.test.tsx b/packages/preact-query/src/__tests__/QueryClientProvider.test.tsx new file mode 100644 index 0000000000..be942a320c --- /dev/null +++ b/packages/preact-query/src/__tests__/QueryClientProvider.test.tsx @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { render } from '@testing-library/react' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + QueryClientProvider, + useQuery, + useQueryClient, +} from '..' + +describe('QueryClientProvider', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('sets a specific cache for all queries to use', async () => { + const key = queryKey() + + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + + function Page() { + const { data } = useQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'test'), + }) + + return ( +
+

{data}

+
+ ) + } + + const rendered = render( + + + , + ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('test')).toBeInTheDocument() + + expect(queryCache.find({ queryKey: key })).toBeDefined() + }) + + test('allows multiple caches to be partitioned', async () => { + const key1 = queryKey() + const key2 = queryKey() + + const queryCache1 = new QueryCache() + const queryCache2 = new QueryCache() + + const queryClient1 = new QueryClient({ queryCache: queryCache1 }) + const queryClient2 = new QueryClient({ queryCache: queryCache2 }) + + function Page1() { + const { data } = useQuery({ + queryKey: key1, + queryFn: () => sleep(10).then(() => 'test1'), + }) + + return ( +
+

{data}

+
+ ) + } + function Page2() { + const { data } = useQuery({ + queryKey: key2, + queryFn: () => sleep(10).then(() => 'test2'), + }) + + return ( +
+

{data}

+
+ ) + } + + const rendered = render( + <> + + + + + + + , + ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('test1')).toBeInTheDocument() + expect(rendered.getByText('test2')).toBeInTheDocument() + + expect(queryCache1.find({ queryKey: key1 })).toBeDefined() + expect(queryCache1.find({ queryKey: key2 })).not.toBeDefined() + expect(queryCache2.find({ queryKey: key1 })).not.toBeDefined() + expect(queryCache2.find({ queryKey: key2 })).toBeDefined() + }) + + test("uses defaultOptions for queries when they don't provide their own config", async () => { + const key = queryKey() + + const queryCache = new QueryCache() + const queryClient = new QueryClient({ + queryCache, + defaultOptions: { + queries: { + gcTime: Infinity, + }, + }, + }) + + function Page() { + const { data } = useQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'test'), + }) + + return ( +
+

{data}

+
+ ) + } + + const rendered = render( + + + , + ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('test')).toBeInTheDocument() + + expect(queryCache.find({ queryKey: key })).toBeDefined() + expect(queryCache.find({ queryKey: key })?.options.gcTime).toBe(Infinity) + }) + + describe('useQueryClient', () => { + test('should throw an error if no query client has been set', () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + function Page() { + useQueryClient() + return null + } + + expect(() => render()).toThrow( + 'No QueryClient set, use QueryClientProvider to set one', + ) + + consoleMock.mockRestore() + }) + }) +}) diff --git a/packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx b/packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx new file mode 100644 index 0000000000..c02adeeece --- /dev/null +++ b/packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx @@ -0,0 +1,866 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent } from '@testing-library/react' +import { ErrorBoundary } from 'react-error-boundary' +import * as React from 'react' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + QueryErrorResetBoundary, + useQueries, + useQuery, + useSuspenseQueries, + useSuspenseQuery, +} from '..' +import { renderWithClient } from './utils' + +describe('QueryErrorResetBoundary', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + + describe('useQuery', () => { + it('should retry fetch if the reset error boundary has been reset', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + let succeed = false + + function Page() { + const { data } = useQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Error') + return 'data' + }), + retry: false, + throwOnError: true, + }) + + return
{data}
+ } + + const rendered = renderWithClient( + queryClient, + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + +
+ )} +
, + ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + succeed = true + + fireEvent.click(rendered.getByText('retry')) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + + it('should not throw error if query is disabled', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + let succeed = false + + function Page() { + const { data, status } = useQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Error') + return 'data' + }), + retry: false, + enabled: !succeed, + throwOnError: true, + }) + + return ( +
+
status: {status}
+
{data}
+
+ ) + } + + const rendered = renderWithClient( + queryClient, + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + +
+ )} +
, + ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + succeed = true + + fireEvent.click(rendered.getByText('retry')) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('status: error')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + + it('should not throw error if query is disabled, and refetch if query becomes enabled again', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const key = queryKey() + + let succeed = false + + function Page() { + const [enabled, setEnabled] = React.useState(false) + const { data } = useQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Error') + return 'data' + }), + retry: false, + enabled, + throwOnError: true, + }) + + React.useEffect(() => { + setEnabled(true) + }, []) + + return
{data}
+ } + + const rendered = renderWithClient( + queryClient, + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + +
+ )} +
, + ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + succeed = true + + fireEvent.click(rendered.getByText('retry')) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + + it('should throw error if query is disabled and manually refetch', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const key = queryKey() + + function Page() { + const { data, refetch, status, fetchStatus } = useQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('Error'))), + retry: false, + enabled: false, + throwOnError: true, + }) + + return ( +
+ +
+ status: {status}, fetchStatus: {fetchStatus} +
+
{data}
+
+ ) + } + + const rendered = renderWithClient( + queryClient, + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + +
+ )} +
, + ) + + expect( + rendered.getByText('status: pending, fetchStatus: idle'), + ).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect( + rendered.getByText('status: pending, fetchStatus: idle'), + ).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + + it('should not retry fetch if the reset error boundary has not been reset', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const key = queryKey() + + let succeed = false + + function Page() { + const { data } = useQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Error') + return 'data' + }), + retry: false, + throwOnError: true, + }) + + return
{data}
+ } + + const rendered = renderWithClient( + queryClient, + + {() => ( + ( +
+
error boundary
+ +
+ )} + > + +
+ )} +
, + ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + succeed = true + + fireEvent.click(rendered.getByText('retry')) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + + it('should retry fetch if the reset error boundary has been reset and the query contains data from a previous fetch', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const key = queryKey() + + let succeed = false + + function Page() { + const { data } = useQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Error') + return 'data' + }), + retry: false, + throwOnError: true, + initialData: 'initial', + }) + + return
{data}
+ } + + const rendered = renderWithClient( + queryClient, + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + +
+ )} +
, + ) + + expect(rendered.getByText('initial')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + succeed = true + + fireEvent.click(rendered.getByText('retry')) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + + it('should not retry fetch if the reset error boundary has not been reset after a previous reset', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const key = queryKey() + + let succeed = false + let shouldReset = false + + function Page() { + const { data } = useQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Error') + return 'data' + }), + retry: false, + throwOnError: true, + }) + + return
{data}
+ } + + const rendered = renderWithClient( + queryClient, + + {({ reset }) => ( + { + if (shouldReset) { + reset() + } + }} + fallbackRender={({ resetErrorBoundary }) => ( +
+
error boundary
+ +
+ )} + > + +
+ )} +
, + ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + succeed = false + shouldReset = true + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + succeed = true + shouldReset = false + + fireEvent.click(rendered.getByText('retry')) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + + succeed = true + shouldReset = true + + fireEvent.click(rendered.getByText('retry')) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + + it('should throw again on error after the reset error boundary has been reset', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const key = queryKey() + let fetchCount = 0 + + function Page() { + const { data } = useQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => { + fetchCount++ + throw new Error('Error') + }), + retry: false, + throwOnError: true, + }) + + return
{data}
+ } + + const rendered = renderWithClient( + queryClient, + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + +
+ )} +
, + ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('retry')) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('retry')) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + + expect(fetchCount).toBe(3) + + consoleMock.mockRestore() + }) + + it('should never render the component while the query is in error state', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const key = queryKey() + let fetchCount = 0 + let renders = 0 + + function Page() { + const { data } = useSuspenseQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => { + fetchCount++ + if (fetchCount > 2) return 'data' + throw new Error('Error') + }), + retry: false, + }) + + renders++ + + return
{data}
+ } + + const rendered = renderWithClient( + queryClient, + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + loading}> + + +
+ )} +
, + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('retry')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('retry')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data')).toBeInTheDocument() + + expect(fetchCount).toBe(3) + expect(renders).toBe(1) + + consoleMock.mockRestore() + }) + + it('should render children', () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + function Page() { + return ( +
+ page +
+ ) + } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + expect(rendered.queryByText('page')).not.toBeNull() + + consoleMock.mockRestore() + }) + + it('should show error boundary when using tracked queries even though we do not track the error field', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const key = queryKey() + + let succeed = false + + function Page() { + const { data } = useQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Error') + return 'data' + }), + retry: false, + throwOnError: true, + }) + + return
{data}
+ } + + const rendered = renderWithClient( + queryClient, + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + +
+ )} +
, + ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + succeed = true + + fireEvent.click(rendered.getByText('retry')) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + }) + + describe('useQueries', () => { + it('should retry fetch if the reset error boundary has been reset', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + let succeed = false + + function Page() { + const [{ data }] = useQueries({ + queries: [ + { + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Error') + return 'data' + }), + retry: false, + throwOnError: true, + retryOnMount: true, + }, + ], + }) + + return
{data}
+ } + + const rendered = renderWithClient( + queryClient, + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + +
+ )} +
, + ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + succeed = true + + fireEvent.click(rendered.getByText('retry')) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + + it('with suspense should retry fetch if the reset error boundary has been reset', async () => { + const key = queryKey() + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + let succeed = false + + function Page() { + const [{ data }] = useSuspenseQueries({ + queries: [ + { + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Error') + return 'data' + }), + retry: false, + retryOnMount: true, + }, + ], + }) + + return
{data}
+ } + + const rendered = renderWithClient( + queryClient, + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + + + +
+ )} +
, + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + succeed = true + + fireEvent.click(rendered.getByText('retry')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + }) +}) diff --git a/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx b/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx new file mode 100644 index 0000000000..e2b76a4c42 --- /dev/null +++ b/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as React from 'react' +import { + PERSISTER_KEY_PREFIX, + experimental_createQueryPersister, +} from '@tanstack/query-persist-client-core' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { QueryCache, QueryClient, hashKey, useQuery } from '..' +import { renderWithClient } from './utils' + +describe('fine grained persister', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + + it('should restore query state from persister and not refetch', async () => { + const key = queryKey() + const hash = hashKey(key) + const spy = vi.fn(() => Promise.resolve('Works from queryFn')) + + const mapStorage = new Map() + const storage = { + getItem: (itemKey: string) => Promise.resolve(mapStorage.get(itemKey)), + setItem: (itemKey: string, value: unknown) => { + mapStorage.set(itemKey, value) + return Promise.resolve() + }, + removeItem: (itemKey: string) => { + mapStorage.delete(itemKey) + return Promise.resolve() + }, + } + + await storage.setItem( + `${PERSISTER_KEY_PREFIX}-${hash}`, + JSON.stringify({ + buster: '', + queryHash: hash, + queryKey: key, + state: { + dataUpdatedAt: Date.now(), + data: 'Works from persister', + }, + }), + ) + + function Test() { + const [_, setRef] = React.useState() + + const { data } = useQuery({ + queryKey: key, + queryFn: spy, + persister: experimental_createQueryPersister({ + storage, + }).persisterFn, + staleTime: 5000, + }) + + return
setRef(value)}>{data}
+ } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('Works from persister')).toBeInTheDocument() + expect(spy).not.toHaveBeenCalled() + }) + + it('should restore query state from persister and refetch', async () => { + const key = queryKey() + const hash = hashKey(key) + const spy = vi.fn(async () => { + await sleep(5) + + return 'Works from queryFn' + }) + + const mapStorage = new Map() + const storage = { + getItem: (itemKey: string) => Promise.resolve(mapStorage.get(itemKey)), + setItem: (itemKey: string, value: unknown) => { + mapStorage.set(itemKey, value) + return Promise.resolve() + }, + removeItem: (itemKey: string) => { + mapStorage.delete(itemKey) + return Promise.resolve() + }, + } + + await storage.setItem( + `${PERSISTER_KEY_PREFIX}-${hash}`, + JSON.stringify({ + buster: '', + queryHash: hash, + queryKey: key, + state: { + dataUpdatedAt: Date.now(), + data: 'Works from persister', + }, + }), + ) + + function Test() { + const [_, setRef] = React.useState() + + const { data } = useQuery({ + queryKey: key, + queryFn: spy, + persister: experimental_createQueryPersister({ + storage, + }).persisterFn, + }) + + return
setRef(value)}>{data}
+ } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('Works from persister')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(6) + expect(rendered.getByText('Works from queryFn')).toBeInTheDocument() + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should store query state to persister after fetch', async () => { + const key = queryKey() + const hash = hashKey(key) + const spy = vi.fn(() => Promise.resolve('Works from queryFn')) + + const mapStorage = new Map() + const storage = { + getItem: (itemKey: string) => Promise.resolve(mapStorage.get(itemKey)), + setItem: (itemKey: string, value: unknown) => { + mapStorage.set(itemKey, value) + return Promise.resolve() + }, + removeItem: (itemKey: string) => { + mapStorage.delete(itemKey) + return Promise.resolve() + }, + } + + function Test() { + const [_, setRef] = React.useState() + + const { data } = useQuery({ + queryKey: key, + queryFn: spy, + persister: experimental_createQueryPersister({ + storage, + }).persisterFn, + }) + + return
setRef(value)}>{data}
+ } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('Works from queryFn')).toBeInTheDocument() + expect(spy).toHaveBeenCalledTimes(1) + + const storedItem = await storage.getItem(`${PERSISTER_KEY_PREFIX}-${hash}`) + expect(JSON.parse(storedItem)).toMatchObject({ + state: { + data: 'Works from queryFn', + }, + }) + }) +}) diff --git a/packages/preact-query/src/__tests__/infiniteQueryOptions.test-d.tsx b/packages/preact-query/src/__tests__/infiniteQueryOptions.test-d.tsx new file mode 100644 index 0000000000..a1d97bf092 --- /dev/null +++ b/packages/preact-query/src/__tests__/infiniteQueryOptions.test-d.tsx @@ -0,0 +1,251 @@ +import { assertType, describe, expectTypeOf, it, test } from 'vitest' +import { QueryClient, dataTagSymbol, skipToken } from '@tanstack/query-core' +import { infiniteQueryOptions } from '../infiniteQueryOptions' +import { useInfiniteQuery } from '../useInfiniteQuery' +import { useSuspenseInfiniteQuery } from '../useSuspenseInfiniteQuery' +import { useQuery } from '../useQuery' +import type { + DataTag, + InfiniteData, + InitialDataFunction, +} from '@tanstack/query-core' + +describe('infiniteQueryOptions', () => { + it('should not allow excess properties', () => { + assertType( + infiniteQueryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve('data'), + getNextPageParam: () => 1, + initialPageParam: 1, + // @ts-expect-error this is a good error, because stallTime does not exist! + stallTime: 1000, + }), + ) + }) + it('should infer types for callbacks', () => { + infiniteQueryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve('data'), + staleTime: 1000, + getNextPageParam: () => 1, + initialPageParam: 1, + select: (data) => { + expectTypeOf(data).toEqualTypeOf>() + }, + }) + }) + it('should work when passed to useInfiniteQuery', () => { + const options = infiniteQueryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + getNextPageParam: () => 1, + initialPageParam: 1, + }) + + const { data } = useInfiniteQuery(options) + + // known issue: type of pageParams is unknown when returned from useInfiniteQuery + expectTypeOf(data).toEqualTypeOf< + InfiniteData | undefined + >() + }) + it('should work when passed to useSuspenseInfiniteQuery', () => { + const options = infiniteQueryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + getNextPageParam: () => 1, + initialPageParam: 1, + }) + + const { data } = useSuspenseInfiniteQuery(options) + + expectTypeOf(data).toEqualTypeOf>() + }) + it('should work when passed to fetchInfiniteQuery', async () => { + const options = infiniteQueryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + getNextPageParam: () => 1, + initialPageParam: 1, + }) + + const data = await new QueryClient().fetchInfiniteQuery(options) + + expectTypeOf(data).toEqualTypeOf>() + }) + it('should tag the queryKey with the result type of the QueryFn', () => { + const { queryKey } = infiniteQueryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + getNextPageParam: () => 1, + initialPageParam: 1, + }) + + expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf>() + }) + it('should tag the queryKey even if no promise is returned', () => { + const { queryKey } = infiniteQueryOptions({ + queryKey: ['key'], + queryFn: () => 'string', + getNextPageParam: () => 1, + initialPageParam: 1, + }) + + expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf>() + }) + it('should tag the queryKey with the result type of the QueryFn if select is used', () => { + const { queryKey } = infiniteQueryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + select: (data) => data.pages, + getNextPageParam: () => 1, + initialPageParam: 1, + }) + + expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf>() + }) + it('should return the proper type when passed to getQueryData', () => { + const { queryKey } = infiniteQueryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + getNextPageParam: () => 1, + initialPageParam: 1, + }) + + const queryClient = new QueryClient() + const data = queryClient.getQueryData(queryKey) + + expectTypeOf(data).toEqualTypeOf< + InfiniteData | undefined + >() + }) + it('should properly type when passed to setQueryData', () => { + const { queryKey } = infiniteQueryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + getNextPageParam: () => 1, + initialPageParam: 1, + }) + + const queryClient = new QueryClient() + const data = queryClient.setQueryData(queryKey, (prev) => { + expectTypeOf(prev).toEqualTypeOf< + InfiniteData | undefined + >() + return prev + }) + + expectTypeOf(data).toEqualTypeOf< + InfiniteData | undefined + >() + }) + it('should throw a type error when using queryFn with skipToken in a suspense query', () => { + const options = infiniteQueryOptions({ + queryKey: ['key'], + queryFn: + Math.random() > 0.5 ? skipToken : () => Promise.resolve('string'), + getNextPageParam: () => 1, + initialPageParam: 1, + }) + // @ts-expect-error TS2345 + const { data } = useSuspenseInfiniteQuery(options) + expectTypeOf(data).toEqualTypeOf>() + }) + + test('should not be allowed to be passed to non-infinite query functions', () => { + const queryClient = new QueryClient() + const options = infiniteQueryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + getNextPageParam: () => 1, + initialPageParam: 1, + }) + assertType( + // @ts-expect-error cannot pass infinite options to non-infinite query functions + useQuery(options), + ) + assertType( + // @ts-expect-error cannot pass infinite options to non-infinite query functions + queryClient.ensureQueryData(options), + ) + assertType( + // @ts-expect-error cannot pass infinite options to non-infinite query functions + queryClient.fetchQuery(options), + ) + assertType( + // @ts-expect-error cannot pass infinite options to non-infinite query functions + queryClient.prefetchQuery(options), + ) + }) + + test('allow optional initialData function', () => { + const initialData: { example: boolean } | undefined = { example: true } + const queryOptions = infiniteQueryOptions({ + queryKey: ['example'], + queryFn: () => initialData, + initialData: initialData + ? () => ({ pages: [initialData], pageParams: [] }) + : undefined, + getNextPageParam: () => 1, + initialPageParam: 1, + }) + expectTypeOf(queryOptions.initialData).toMatchTypeOf< + | InitialDataFunction> + | InfiniteData<{ example: boolean }, number> + | undefined + >() + }) + + test('allow optional initialData object', () => { + const initialData: { example: boolean } | undefined = { example: true } + const queryOptions = infiniteQueryOptions({ + queryKey: ['example'], + queryFn: () => initialData, + initialData: initialData + ? { pages: [initialData], pageParams: [] } + : undefined, + getNextPageParam: () => 1, + initialPageParam: 1, + }) + expectTypeOf(queryOptions.initialData).toMatchTypeOf< + | InitialDataFunction> + | InfiniteData<{ example: boolean }, number> + | undefined + >() + }) + + it('should return a custom query key type', () => { + type MyQueryKey = [Array, { type: 'foo' }] + + const options = infiniteQueryOptions({ + queryKey: [['key'], { type: 'foo' }] as MyQueryKey, + queryFn: () => Promise.resolve(1), + getNextPageParam: () => 1, + initialPageParam: 1, + }) + + expectTypeOf(options.queryKey).toEqualTypeOf< + DataTag, Error> + >() + }) + + it('should return a custom query key type with datatag', () => { + type MyQueryKey = DataTag< + [Array, { type: 'foo' }], + number, + Error & { myMessage: string } + > + + const options = infiniteQueryOptions({ + queryKey: [['key'], { type: 'foo' }] as MyQueryKey, + queryFn: () => Promise.resolve(1), + getNextPageParam: () => 1, + initialPageParam: 1, + }) + + expectTypeOf(options.queryKey).toEqualTypeOf< + DataTag, Error & { myMessage: string }> + >() + }) +}) diff --git a/packages/preact-query/src/__tests__/infiniteQueryOptions.test.tsx b/packages/preact-query/src/__tests__/infiniteQueryOptions.test.tsx new file mode 100644 index 0000000000..3e876fd5d0 --- /dev/null +++ b/packages/preact-query/src/__tests__/infiniteQueryOptions.test.tsx @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' + +import { infiniteQueryOptions } from '../infiniteQueryOptions' +import type { UseInfiniteQueryOptions } from '../types' + +describe('infiniteQueryOptions', () => { + it('should return the object received as a parameter without any modification.', () => { + const object: UseInfiniteQueryOptions = { + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + getNextPageParam: () => null, + initialPageParam: null, + } + + expect(infiniteQueryOptions(object)).toStrictEqual(object) + }) +}) diff --git a/packages/preact-query/src/__tests__/mutationOptions.test-d.tsx b/packages/preact-query/src/__tests__/mutationOptions.test-d.tsx new file mode 100644 index 0000000000..2988426d65 --- /dev/null +++ b/packages/preact-query/src/__tests__/mutationOptions.test-d.tsx @@ -0,0 +1,217 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest' +import { QueryClient } from '@tanstack/query-core' +import { useIsMutating, useMutation, useMutationState } from '..' +import { mutationOptions } from '../mutationOptions' +import type { + DefaultError, + MutationFunctionContext, + MutationState, + WithRequired, +} from '@tanstack/query-core' +import type { UseMutationOptions, UseMutationResult } from '../types' + +describe('mutationOptions', () => { + it('should not allow excess properties', () => { + // @ts-expect-error this is a good error, because onMutates does not exist! + mutationOptions({ + mutationFn: () => Promise.resolve(5), + mutationKey: ['key'], + onMutates: 1000, + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }) + }) + + it('should infer types for callbacks', () => { + mutationOptions({ + mutationFn: () => Promise.resolve(5), + mutationKey: ['key'], + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }) + }) + + it('should infer types for onError callback', () => { + mutationOptions({ + mutationFn: () => { + throw new Error('fail') + }, + mutationKey: ['key'], + onError: (error) => { + expectTypeOf(error).toEqualTypeOf() + }, + }) + }) + + it('should infer types for variables', () => { + mutationOptions({ + mutationFn: (vars) => { + expectTypeOf(vars).toEqualTypeOf<{ id: string }>() + return Promise.resolve(5) + }, + mutationKey: ['with-vars'], + }) + }) + + it('should infer result type correctly', () => { + mutationOptions({ + mutationFn: () => Promise.resolve(5), + mutationKey: ['key'], + onMutate: () => { + return { name: 'onMutateResult' } + }, + onSuccess: (_data, _variables, onMutateResult) => { + expectTypeOf(onMutateResult).toEqualTypeOf<{ name: string }>() + }, + }) + }) + + it('should infer context type correctly', () => { + mutationOptions({ + mutationFn: (_variables, context) => { + expectTypeOf(context).toEqualTypeOf() + return Promise.resolve(5) + }, + mutationKey: ['key'], + onMutate: (_variables, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onSuccess: (_data, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onError: (_error, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onSettled: (_data, _error, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + }) + }) + + it('should error if mutationFn return type mismatches TData', () => { + assertType( + mutationOptions({ + // @ts-expect-error this is a good error, because return type is string, not number + mutationFn: async () => Promise.resolve('wrong return'), + }), + ) + }) + + it('should allow mutationKey to be omitted', () => { + return mutationOptions({ + mutationFn: () => Promise.resolve(123), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }) + }) + + it('should infer all types when not explicitly provided', () => { + expectTypeOf( + mutationOptions({ + mutationFn: (id: string) => Promise.resolve(id.length), + mutationKey: ['key'], + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ).toEqualTypeOf< + WithRequired< + UseMutationOptions, + 'mutationKey' + > + >() + expectTypeOf( + mutationOptions({ + mutationFn: (id: string) => Promise.resolve(id.length), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ).toEqualTypeOf< + Omit, 'mutationKey'> + >() + }) + + it('should infer types when used with useMutation', () => { + const mutation = useMutation( + mutationOptions({ + mutationKey: ['key'], + mutationFn: () => Promise.resolve('data'), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ) + expectTypeOf(mutation).toEqualTypeOf< + UseMutationResult + >() + + useMutation( + // should allow when used with useMutation without mutationKey + mutationOptions({ + mutationFn: () => Promise.resolve('data'), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ) + }) + + it('should infer types when used with useIsMutating', () => { + const isMutating = useIsMutating( + mutationOptions({ + mutationKey: ['key'], + mutationFn: () => Promise.resolve(5), + }), + ) + expectTypeOf(isMutating).toEqualTypeOf() + + useIsMutating( + // @ts-expect-error filters should have mutationKey + mutationOptions({ + mutationFn: () => Promise.resolve(5), + }), + ) + }) + + it('should infer types when used with queryClient.isMutating', () => { + const queryClient = new QueryClient() + + const isMutating = queryClient.isMutating( + mutationOptions({ + mutationKey: ['key'], + mutationFn: () => Promise.resolve(5), + }), + ) + expectTypeOf(isMutating).toEqualTypeOf() + + queryClient.isMutating( + // @ts-expect-error filters should have mutationKey + mutationOptions({ + mutationFn: () => Promise.resolve(5), + }), + ) + }) + + it('should infer types when used with useMutationState', () => { + const mutationState = useMutationState({ + filters: mutationOptions({ + mutationKey: ['key'], + mutationFn: () => Promise.resolve(5), + }), + }) + expectTypeOf(mutationState).toEqualTypeOf< + Array> + >() + + useMutationState({ + // @ts-expect-error filters should have mutationKey + filters: mutationOptions({ + mutationFn: () => Promise.resolve(5), + }), + }) + }) +}) diff --git a/packages/preact-query/src/__tests__/mutationOptions.test.tsx b/packages/preact-query/src/__tests__/mutationOptions.test.tsx new file mode 100644 index 0000000000..36ee1090a0 --- /dev/null +++ b/packages/preact-query/src/__tests__/mutationOptions.test.tsx @@ -0,0 +1,526 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient } from '@tanstack/query-core' +import { sleep } from '@tanstack/query-test-utils' +import { fireEvent } from '@testing-library/react' +import { mutationOptions } from '../mutationOptions' +import { useIsMutating, useMutation, useMutationState } from '..' +import { renderWithClient } from './utils' +import type { MutationState } from '@tanstack/query-core' + +describe('mutationOptions', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should return the object received as a parameter without any modification (with mutationKey in mutationOptions)', () => { + const object = { + mutationKey: ['key'], + mutationFn: () => sleep(10).then(() => 5), + } as const + + expect(mutationOptions(object)).toStrictEqual(object) + }) + + it('should return the object received as a parameter without any modification (without mutationKey in mutationOptions)', () => { + const object = { + mutationFn: () => sleep(10).then(() => 5), + } as const + + expect(mutationOptions(object)).toStrictEqual(object) + }) + + it('should return the number of fetching mutations when used with useIsMutating (with mutationKey in mutationOptions)', async () => { + const isMutatingArray: Array = [] + const queryClient = new QueryClient() + const mutationOpts = mutationOptions({ + mutationKey: ['key'], + mutationFn: () => sleep(50).then(() => 'data'), + }) + + function IsMutating() { + const isMutating = useIsMutating() + + isMutatingArray.push(isMutating) + + return null + } + + function Mutation() { + const { mutate } = useMutation(mutationOpts) + + return ( +
+ +
+ ) + } + + function Page() { + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + expect(isMutatingArray[0]).toEqual(0) + await vi.advanceTimersByTimeAsync(0) + expect(isMutatingArray[1]).toEqual(1) + await vi.advanceTimersByTimeAsync(51) + expect(isMutatingArray[2]).toEqual(0) + expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0) + }) + + it('should return the number of fetching mutations when used with useIsMutating (without mutationKey in mutationOptions)', async () => { + const isMutatingArray: Array = [] + const queryClient = new QueryClient() + const mutationOpts = mutationOptions({ + mutationFn: () => sleep(50).then(() => 'data'), + }) + + function IsMutating() { + const isMutating = useIsMutating() + + isMutatingArray.push(isMutating) + + return null + } + + function Mutation() { + const { mutate } = useMutation(mutationOpts) + + return ( +
+ +
+ ) + } + + function Page() { + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + expect(isMutatingArray[0]).toEqual(0) + await vi.advanceTimersByTimeAsync(0) + expect(isMutatingArray[1]).toEqual(1) + await vi.advanceTimersByTimeAsync(51) + expect(isMutatingArray[2]).toEqual(0) + expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0) + }) + + it('should return the number of fetching mutations when used with useIsMutating', async () => { + const isMutatingArray: Array = [] + const queryClient = new QueryClient() + const mutationOpts1 = mutationOptions({ + mutationKey: ['key'], + mutationFn: () => sleep(50).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(50).then(() => 'data2'), + }) + + function IsMutating() { + const isMutating = useIsMutating() + + isMutatingArray.push(isMutating) + + return null + } + + function Mutation() { + const { mutate: mutate1 } = useMutation(mutationOpts1) + const { mutate: mutate2 } = useMutation(mutationOpts2) + + return ( +
+ + +
+ ) + } + + function Page() { + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) + fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + expect(isMutatingArray[0]).toEqual(0) + await vi.advanceTimersByTimeAsync(0) + expect(isMutatingArray[1]).toEqual(2) + await vi.advanceTimersByTimeAsync(51) + expect(isMutatingArray[2]).toEqual(0) + expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0) + }) + + it('should return the number of fetching mutations when used with useIsMutating (filter mutationOpts1.mutationKey)', async () => { + const isMutatingArray: Array = [] + const queryClient = new QueryClient() + const mutationOpts1 = mutationOptions({ + mutationKey: ['key'], + mutationFn: () => sleep(50).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(50).then(() => 'data2'), + }) + + function IsMutating() { + const isMutating = useIsMutating({ + mutationKey: mutationOpts1.mutationKey, + }) + + isMutatingArray.push(isMutating) + + return null + } + + function Mutation() { + const { mutate: mutate1 } = useMutation(mutationOpts1) + const { mutate: mutate2 } = useMutation(mutationOpts2) + + return ( +
+ + +
+ ) + } + + function Page() { + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) + fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + expect(isMutatingArray[0]).toEqual(0) + await vi.advanceTimersByTimeAsync(0) + expect(isMutatingArray[1]).toEqual(1) + await vi.advanceTimersByTimeAsync(51) + expect(isMutatingArray[2]).toEqual(0) + expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0) + }) + + it('should return the number of fetching mutations when used with queryClient.isMutating (with mutationKey in mutationOptions)', async () => { + const isMutatingArray: Array = [] + const queryClient = new QueryClient() + const mutationOpts = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(500).then(() => 'data'), + }) + + function Mutation() { + const isMutating = queryClient.isMutating(mutationOpts) + const { mutate } = useMutation(mutationOpts) + + isMutatingArray.push(isMutating) + + return ( +
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + expect(isMutatingArray[0]).toEqual(0) + await vi.advanceTimersByTimeAsync(0) + expect(isMutatingArray[1]).toEqual(1) + await vi.advanceTimersByTimeAsync(501) + expect(isMutatingArray[2]).toEqual(0) + expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0) + }) + + it('should return the number of fetching mutations when used with queryClient.isMutating (without mutationKey in mutationOptions)', async () => { + const isMutatingArray: Array = [] + const queryClient = new QueryClient() + const mutationOpts = mutationOptions({ + mutationFn: () => sleep(500).then(() => 'data'), + }) + + function Mutation() { + const isMutating = queryClient.isMutating() + const { mutate } = useMutation(mutationOpts) + + isMutatingArray.push(isMutating) + + return ( +
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + expect(isMutatingArray[0]).toEqual(0) + await vi.advanceTimersByTimeAsync(0) + expect(isMutatingArray[1]).toEqual(1) + await vi.advanceTimersByTimeAsync(501) + expect(isMutatingArray[2]).toEqual(0) + expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0) + }) + + it('should return the number of fetching mutations when used with queryClient.isMutating', async () => { + const isMutatingArray: Array = [] + const queryClient = new QueryClient() + const mutationOpts1 = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(500).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(500).then(() => 'data2'), + }) + + function Mutation() { + const isMutating = queryClient.isMutating() + const { mutate: mutate1 } = useMutation(mutationOpts1) + const { mutate: mutate2 } = useMutation(mutationOpts2) + + isMutatingArray.push(isMutating) + + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) + fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + expect(isMutatingArray[0]).toEqual(0) + await vi.advanceTimersByTimeAsync(0) + expect(isMutatingArray[1]).toEqual(2) + await vi.advanceTimersByTimeAsync(501) + expect(isMutatingArray[2]).toEqual(0) + expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0) + }) + + it('should return the number of fetching mutations when used with queryClient.isMutating (filter mutationOpt1.mutationKey)', async () => { + const isMutatingArray: Array = [] + const queryClient = new QueryClient() + const mutationOpts1 = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(500).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(500).then(() => 'data2'), + }) + + function Mutation() { + const isMutating = queryClient.isMutating({ + mutationKey: mutationOpts1.mutationKey, + }) + const { mutate: mutate1 } = useMutation(mutationOpts1) + const { mutate: mutate2 } = useMutation(mutationOpts2) + + isMutatingArray.push(isMutating) + + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) + fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + expect(isMutatingArray[0]).toEqual(0) + await vi.advanceTimersByTimeAsync(0) + expect(isMutatingArray[1]).toEqual(1) + await vi.advanceTimersByTimeAsync(501) + expect(isMutatingArray[2]).toEqual(0) + expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0) + }) + + it('should return the number of fetching mutations when used with useMutationState (with mutationKey in mutationOptions)', async () => { + const mutationStateArray: Array< + MutationState + > = [] + const queryClient = new QueryClient() + const mutationOpts = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(10).then(() => 'data'), + }) + + function Mutation() { + const { mutate } = useMutation(mutationOpts) + const data = useMutationState({ + filters: { mutationKey: mutationOpts.mutationKey, status: 'success' }, + }) + + mutationStateArray.push(...data) + + return ( +
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(mutationStateArray.length).toEqual(0) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(11) + expect(mutationStateArray.length).toEqual(1) + expect(mutationStateArray[0]?.data).toEqual('data') + }) + + it('should return the number of fetching mutations when used with useMutationState (without mutationKey in mutationOptions)', async () => { + const mutationStateArray: Array< + MutationState + > = [] + const queryClient = new QueryClient() + const mutationOpts = mutationOptions({ + mutationFn: () => sleep(10).then(() => 'data'), + }) + + function Mutation() { + const { mutate } = useMutation(mutationOpts) + const data = useMutationState({ + filters: { status: 'success' }, + }) + + mutationStateArray.push(...data) + + return ( +
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(mutationStateArray.length).toEqual(0) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(11) + expect(mutationStateArray.length).toEqual(1) + expect(mutationStateArray[0]?.data).toEqual('data') + }) + + it('should return the number of fetching mutations when used with useMutationState', async () => { + const mutationStateArray: Array< + MutationState + > = [] + const queryClient = new QueryClient() + const mutationOpts1 = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(10).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(10).then(() => 'data2'), + }) + + function Mutation() { + const { mutate: mutate1 } = useMutation(mutationOpts1) + const { mutate: mutate2 } = useMutation(mutationOpts2) + const data = useMutationState({ + filters: { status: 'success' }, + }) + + mutationStateArray.push(...data) + + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(mutationStateArray.length).toEqual(0) + + fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) + fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + await vi.advanceTimersByTimeAsync(11) + expect(mutationStateArray.length).toEqual(2) + expect(mutationStateArray[0]?.data).toEqual('data1') + expect(mutationStateArray[1]?.data).toEqual('data2') + }) + + it('should return the number of fetching mutations when used with useMutationState (filter mutationOpt1.mutationKey)', async () => { + const mutationStateArray: Array< + MutationState + > = [] + const queryClient = new QueryClient() + const mutationOpts1 = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(10).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(10).then(() => 'data2'), + }) + + function Mutation() { + const { mutate: mutate1 } = useMutation(mutationOpts1) + const { mutate: mutate2 } = useMutation(mutationOpts2) + const data = useMutationState({ + filters: { mutationKey: mutationOpts1.mutationKey, status: 'success' }, + }) + + mutationStateArray.push(...data) + + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(mutationStateArray.length).toEqual(0) + + fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) + fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + await vi.advanceTimersByTimeAsync(11) + expect(mutationStateArray.length).toEqual(1) + expect(mutationStateArray[0]?.data).toEqual('data1') + expect(mutationStateArray[1]).toBeFalsy() + }) +}) diff --git a/packages/preact-query/src/__tests__/queryOptions.test-d.tsx b/packages/preact-query/src/__tests__/queryOptions.test-d.tsx new file mode 100644 index 0000000000..aac63737eb --- /dev/null +++ b/packages/preact-query/src/__tests__/queryOptions.test-d.tsx @@ -0,0 +1,286 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest' +import { + QueriesObserver, + QueryClient, + dataTagSymbol, + skipToken, +} from '@tanstack/query-core' +import { queryOptions } from '../queryOptions' +import { useQuery } from '../useQuery' +import { useQueries } from '../useQueries' +import { useSuspenseQuery } from '../useSuspenseQuery' +import type { AnyUseQueryOptions } from '../types' +import type { + DataTag, + InitialDataFunction, + QueryObserverResult, +} from '@tanstack/query-core' + +describe('queryOptions', () => { + it('should not allow excess properties', () => { + assertType( + queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + // @ts-expect-error this is a good error, because stallTime does not exist! + stallTime: 1000, + }), + ) + }) + it('should infer types for callbacks', () => { + queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + staleTime: 1000, + select: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }) + }) + it('should work when passed to useQuery', () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }) + + const { data } = useQuery(options) + expectTypeOf(data).toEqualTypeOf() + }) + it('should work when passed to useSuspenseQuery', () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }) + + const { data } = useSuspenseQuery(options) + expectTypeOf(data).toEqualTypeOf() + }) + + it('should work when passed to fetchQuery', async () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }) + + const data = await new QueryClient().fetchQuery(options) + expectTypeOf(data).toEqualTypeOf() + }) + it('should work when passed to useQueries', () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }) + + const [{ data }] = useQueries({ + queries: [options], + }) + + expectTypeOf(data).toEqualTypeOf() + }) + it('should tag the queryKey with the result type of the QueryFn', () => { + const { queryKey } = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }) + + expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf() + }) + it('should tag the queryKey even if no promise is returned', () => { + const { queryKey } = queryOptions({ + queryKey: ['key'], + queryFn: () => 5, + }) + + expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf() + }) + it('should tag the queryKey with unknown if there is no queryFn', () => { + const { queryKey } = queryOptions({ + queryKey: ['key'], + }) + + expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf() + }) + it('should tag the queryKey with the result type of the QueryFn if select is used', () => { + const { queryKey } = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + select: (data) => data.toString(), + }) + + expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf() + }) + it('should return the proper type when passed to getQueryData', () => { + const { queryKey } = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }) + + const queryClient = new QueryClient() + const data = queryClient.getQueryData(queryKey) + expectTypeOf(data).toEqualTypeOf() + }) + it('should return the proper type when passed to getQueryState', () => { + const { queryKey } = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }) + + const queryClient = new QueryClient() + const state = queryClient.getQueryState(queryKey) + expectTypeOf(state?.data).toEqualTypeOf() + }) + it('should properly type updaterFn when passed to setQueryData', () => { + const { queryKey } = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }) + + const queryClient = new QueryClient() + const data = queryClient.setQueryData(queryKey, (prev) => { + expectTypeOf(prev).toEqualTypeOf() + return prev + }) + expectTypeOf(data).toEqualTypeOf() + }) + it('should properly type value when passed to setQueryData', () => { + const { queryKey } = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }) + + const queryClient = new QueryClient() + + // @ts-expect-error value should be a number + queryClient.setQueryData(queryKey, '5') + // @ts-expect-error value should be a number + queryClient.setQueryData(queryKey, () => '5') + + const data = queryClient.setQueryData(queryKey, 5) + expectTypeOf(data).toEqualTypeOf() + }) + + it('should infer even if there is a conditional skipToken', () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), + }) + + const queryClient = new QueryClient() + const data = queryClient.getQueryData(options.queryKey) + expectTypeOf(data).toEqualTypeOf() + }) + + it('should infer to unknown if we disable a query with just a skipToken', () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: skipToken, + }) + + const queryClient = new QueryClient() + const data = queryClient.getQueryData(options.queryKey) + expectTypeOf(data).toEqualTypeOf() + }) + + it('should throw a type error when using queryFn with skipToken in a suspense query', () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), + }) + // @ts-expect-error TS2345 + const { data } = useSuspenseQuery(options) + expectTypeOf(data).toEqualTypeOf() + }) + + it('should return the proper type when passed to QueriesObserver', () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }) + + const queryClient = new QueryClient() + const queriesObserver = new QueriesObserver(queryClient, [options]) + expectTypeOf(queriesObserver).toEqualTypeOf< + QueriesObserver> + >() + }) + + it('should allow undefined response in initialData', () => { + assertType((id: string | null) => + queryOptions({ + queryKey: ['todo', id], + queryFn: () => + Promise.resolve({ + id: '1', + title: 'Do Laundry', + }), + initialData: () => + !id + ? undefined + : { + id, + title: 'Initial Data', + }, + }), + ) + }) + + it('should allow optional initialData object', () => { + const testFn = (id?: string) => { + const options = queryOptions({ + queryKey: ['test'], + queryFn: () => Promise.resolve('something string'), + initialData: id ? 'initial string' : undefined, + }) + expectTypeOf(options.initialData).toMatchTypeOf< + InitialDataFunction | string | undefined + >() + } + testFn('id') + testFn() + }) + + it('should be passable to UseQueryOptions', () => { + function somethingWithQueryOptions( + options: TQueryOpts, + ) { + return options.queryKey + } + + const options = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + }) + + assertType(somethingWithQueryOptions(options)) + }) + + it('should return a custom query key type', () => { + type MyQueryKey = [Array, { type: 'foo' }] + + const options = queryOptions({ + queryKey: [['key'], { type: 'foo' }] as MyQueryKey, + queryFn: () => Promise.resolve(1), + }) + + expectTypeOf(options.queryKey).toEqualTypeOf< + DataTag + >() + }) + + it('should return a custom query key type with datatag', () => { + type MyQueryKey = DataTag< + [Array, { type: 'foo' }], + number, + Error & { myMessage: string } + > + + const options = queryOptions({ + queryKey: [['key'], { type: 'foo' }] as MyQueryKey, + queryFn: () => Promise.resolve(1), + }) + + expectTypeOf(options.queryKey).toEqualTypeOf< + DataTag + >() + }) +}) diff --git a/packages/preact-query/src/__tests__/queryOptions.test.tsx b/packages/preact-query/src/__tests__/queryOptions.test.tsx new file mode 100644 index 0000000000..28e539690b --- /dev/null +++ b/packages/preact-query/src/__tests__/queryOptions.test.tsx @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { queryOptions } from '../queryOptions' +import type { UseQueryOptions } from '../types' + +describe('queryOptions', () => { + it('should return the object received as a parameter without any modification.', () => { + const object: UseQueryOptions = { + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + } as const + + expect(queryOptions(object)).toStrictEqual(object) + }) +}) diff --git a/packages/preact-query/src/__tests__/ssr-hydration.test.tsx b/packages/preact-query/src/__tests__/ssr-hydration.test.tsx new file mode 100644 index 0000000000..07f469b568 --- /dev/null +++ b/packages/preact-query/src/__tests__/ssr-hydration.test.tsx @@ -0,0 +1,269 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { hydrateRoot } from 'react-dom/client' +import { act } from 'react' +import * as ReactDOMServer from 'react-dom/server' +import { + QueryCache, + QueryClient, + QueryClientProvider, + dehydrate, + hydrate, + useQuery, +} from '..' +import { setIsServer } from './utils' + +const ReactHydrate = (element: React.ReactElement, container: Element) => { + let root: any + act(() => { + root = hydrateRoot(container, element) + }) + return () => { + root.unmount() + } +} + +async function fetchData(value: TData, ms?: number): Promise { + await vi.advanceTimersByTimeAsync(ms || 1) + return value +} + +function PrintStateComponent({ componentName, result }: any): any { + return `${componentName} - status:${result.status} fetching:${result.isFetching} data:${result.data}` +} + +describe('Server side rendering with de/rehydration', () => { + let previousIsReactActEnvironment: unknown + beforeAll(() => { + // @ts-expect-error we expect IS_REACT_ACT_ENVIRONMENT to exist + previousIsReactActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers() + }) + + afterAll(() => { + // @ts-expect-error we expect IS_REACT_ACT_ENVIRONMENT to exist + globalThis.IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment + vi.useRealTimers() + }) + + it('should not mismatch on success', async () => { + const consoleMock = vi.spyOn(console, 'error') + consoleMock.mockImplementation(() => undefined) + + const fetchDataSuccess = vi.fn(fetchData) + + // -- Shared part -- + function SuccessComponent() { + const result = useQuery({ + queryKey: ['success'], + queryFn: () => fetchDataSuccess('success!'), + }) + return ( + + ) + } + + // -- Server part -- + setIsServer(true) + + const prefetchCache = new QueryCache() + const prefetchClient = new QueryClient({ + queryCache: prefetchCache, + }) + await prefetchClient.prefetchQuery({ + queryKey: ['success'], + queryFn: () => fetchDataSuccess('success'), + }) + const dehydratedStateServer = dehydrate(prefetchClient) + const renderCache = new QueryCache() + const renderClient = new QueryClient({ + queryCache: renderCache, + }) + hydrate(renderClient, dehydratedStateServer) + const markup = ReactDOMServer.renderToString( + + + , + ) + const stringifiedState = JSON.stringify(dehydratedStateServer) + renderClient.clear() + setIsServer(false) + + const expectedMarkup = + 'SuccessComponent - status:success fetching:true data:success' + + expect(markup).toBe(expectedMarkup) + expect(fetchDataSuccess).toHaveBeenCalledTimes(1) + + // -- Client part -- + const el = document.createElement('div') + el.innerHTML = markup + + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + hydrate(queryClient, JSON.parse(stringifiedState)) + + const unmount = ReactHydrate( + + + , + el, + ) + + // Check that we have no React hydration mismatches + expect(consoleMock).toHaveBeenCalledTimes(0) + + expect(fetchDataSuccess).toHaveBeenCalledTimes(2) + expect(el.innerHTML).toBe(expectedMarkup) + + unmount() + queryClient.clear() + consoleMock.mockRestore() + }) + + it('should not mismatch on error', async () => { + const consoleMock = vi.spyOn(console, 'error') + consoleMock.mockImplementation(() => undefined) + + const fetchDataError = vi.fn(() => { + throw new Error('fetchDataError') + }) + + // -- Shared part -- + function ErrorComponent() { + const result = useQuery({ + queryKey: ['error'], + queryFn: () => fetchDataError(), + retry: false, + }) + return ( + + ) + } + + // -- Server part -- + setIsServer(true) + const prefetchCache = new QueryCache() + const prefetchClient = new QueryClient({ + queryCache: prefetchCache, + }) + await prefetchClient.prefetchQuery({ + queryKey: ['error'], + queryFn: () => fetchDataError(), + }) + const dehydratedStateServer = dehydrate(prefetchClient) + const renderCache = new QueryCache() + const renderClient = new QueryClient({ + queryCache: renderCache, + }) + hydrate(renderClient, dehydratedStateServer) + const markup = ReactDOMServer.renderToString( + + + , + ) + const stringifiedState = JSON.stringify(dehydratedStateServer) + renderClient.clear() + setIsServer(false) + + const expectedMarkup = + 'ErrorComponent - status:pending fetching:true data:undefined' + + expect(markup).toBe(expectedMarkup) + + // -- Client part -- + const el = document.createElement('div') + el.innerHTML = markup + + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + hydrate(queryClient, JSON.parse(stringifiedState)) + + const unmount = ReactHydrate( + + + , + el, + ) + + expect(consoleMock).toHaveBeenCalledTimes(0) + expect(fetchDataError).toHaveBeenCalledTimes(2) + expect(el.innerHTML).toBe(expectedMarkup) + await vi.advanceTimersByTimeAsync(50) + expect(fetchDataError).toHaveBeenCalledTimes(2) + expect(el.innerHTML).toBe( + 'ErrorComponent - status:error fetching:false data:undefined', + ) + + unmount() + queryClient.clear() + consoleMock.mockRestore() + }) + + it('should not mismatch on queries that were not prefetched', async () => { + const consoleMock = vi.spyOn(console, 'error') + consoleMock.mockImplementation(() => undefined) + + const fetchDataSuccess = vi.fn(fetchData) + + // -- Shared part -- + function SuccessComponent() { + const result = useQuery({ + queryKey: ['success'], + queryFn: () => fetchDataSuccess('success!'), + }) + return ( + + ) + } + + // -- Server part -- + setIsServer(true) + + const prefetchClient = new QueryClient() + const dehydratedStateServer = dehydrate(prefetchClient) + const renderClient = new QueryClient() + hydrate(renderClient, dehydratedStateServer) + const markup = ReactDOMServer.renderToString( + + + , + ) + const stringifiedState = JSON.stringify(dehydratedStateServer) + renderClient.clear() + setIsServer(false) + + const expectedMarkup = + 'SuccessComponent - status:pending fetching:true data:undefined' + + expect(markup).toBe(expectedMarkup) + + // -- Client part -- + const el = document.createElement('div') + el.innerHTML = markup + + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + hydrate(queryClient, JSON.parse(stringifiedState)) + + const unmount = ReactHydrate( + + + , + el, + ) + + // Check that we have no React hydration mismatches + expect(consoleMock).toHaveBeenCalledTimes(0) + expect(fetchDataSuccess).toHaveBeenCalledTimes(1) + expect(el.innerHTML).toBe(expectedMarkup) + await vi.advanceTimersByTimeAsync(50) + expect(fetchDataSuccess).toHaveBeenCalledTimes(1) + expect(el.innerHTML).toBe( + 'SuccessComponent - status:success fetching:false data:success!', + ) + + unmount() + queryClient.clear() + consoleMock.mockRestore() + }) +}) diff --git a/packages/preact-query/src/__tests__/ssr.test.tsx b/packages/preact-query/src/__tests__/ssr.test.tsx new file mode 100644 index 0000000000..0738b8d290 --- /dev/null +++ b/packages/preact-query/src/__tests__/ssr.test.tsx @@ -0,0 +1,176 @@ +import * as React from 'react' +import { renderToString } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + QueryClientProvider, + useInfiniteQuery, + useQuery, +} from '..' +import { setIsServer } from './utils' + +describe('Server Side Rendering', () => { + setIsServer(true) + + let queryCache: QueryCache + let queryClient: QueryClient + + beforeEach(() => { + vi.useFakeTimers() + queryCache = new QueryCache() + queryClient = new QueryClient({ queryCache }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should not trigger fetch', () => { + const key = queryKey() + const queryFn = vi.fn(() => sleep(10).then(() => 'data')) + + function Page() { + const query = useQuery({ queryKey: key, queryFn }) + + const content = `status ${query.status}` + + return ( +
+
{content}
+
+ ) + } + + const markup = renderToString( + + + , + ) + + expect(markup).toContain('status pending') + expect(queryFn).toHaveBeenCalledTimes(0) + + queryCache.clear() + }) + + it('should add prefetched data to cache', async () => { + const key = queryKey() + + const promise = queryClient.fetchQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + await vi.advanceTimersByTimeAsync(10) + + const data = await promise + + expect(data).toBe('data') + expect(queryCache.find({ queryKey: key })?.state.data).toBe('data') + + queryCache.clear() + }) + + it('should return existing data from the cache', async () => { + const key = queryKey() + const queryFn = vi.fn(() => sleep(10).then(() => 'data')) + + function Page() { + const query = useQuery({ queryKey: key, queryFn }) + + const content = `status ${query.status}` + + return ( +
+
{content}
+
+ ) + } + + queryClient.prefetchQuery({ queryKey: key, queryFn }) + await vi.advanceTimersByTimeAsync(10) + + const markup = renderToString( + + + , + ) + + expect(markup).toContain('status success') + expect(queryFn).toHaveBeenCalledTimes(1) + + queryCache.clear() + }) + + it('should add initialData to the cache', () => { + const key = queryKey() + + function Page() { + const [page, setPage] = React.useState(1) + const { data } = useQuery({ + queryKey: [key, page], + queryFn: () => sleep(10).then(() => page), + initialData: 1, + }) + + return ( +
+

{data}

+ +
+ ) + } + + renderToString( + + + , + ) + + const keys = queryCache.getAll().map((query) => query.queryKey) + + expect(keys).toEqual([[key, 1]]) + + queryCache.clear() + }) + + it('useInfiniteQuery should return the correct state', async () => { + const key = queryKey() + const queryFn = vi.fn(() => sleep(10).then(() => 'page 1')) + + function Page() { + const query = useInfiniteQuery({ + queryKey: key, + queryFn, + getNextPageParam: () => undefined, + initialPageParam: 0, + }) + return ( +
    + {query.data?.pages.map((page) => ( +
  • {page}
  • + ))} +
+ ) + } + + queryClient.prefetchInfiniteQuery({ + queryKey: key, + queryFn, + initialPageParam: 0, + }) + await vi.advanceTimersByTimeAsync(10) + + const markup = renderToString( + + + , + ) + + expect(markup).toContain('page 1') + expect(queryFn).toHaveBeenCalledTimes(1) + + queryCache.clear() + }) +}) diff --git a/packages/preact-query/src/__tests__/suspense.test.tsx b/packages/preact-query/src/__tests__/suspense.test.tsx new file mode 100644 index 0000000000..409cdfcbce --- /dev/null +++ b/packages/preact-query/src/__tests__/suspense.test.tsx @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, render } from '@testing-library/react' +import { Suspense } from 'react' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { QueryClient, QueryClientProvider, useSuspenseQuery } from '..' +import type { QueryKey } from '..' + +function renderWithSuspense(client: QueryClient, ui: React.ReactNode) { + return render( + + {ui} + , + ) +} + +function createTestQuery(options: { + fetchCount: { count: number } + queryKey: QueryKey + staleTime?: number | (() => number) +}) { + return function TestComponent() { + const { data } = useSuspenseQuery({ + queryKey: options.queryKey, + queryFn: () => + sleep(10).then(() => { + options.fetchCount.count++ + return 'data' + }), + staleTime: options.staleTime, + }) + return
data: {data}
+ } +} + +describe('Suspense Timer Tests', () => { + let queryClient: QueryClient + let fetchCount: { count: number } + + beforeEach(() => { + vi.useFakeTimers() + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + fetchCount = { count: 0 } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should enforce minimum staleTime of 1000ms when using suspense with number', async () => { + const TestComponent = createTestQuery({ + fetchCount, + queryKey: ['test'], + staleTime: 10, + }) + + const rendered = renderWithSuspense(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: data')).toBeInTheDocument() + + rendered.rerender( + + + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(100)) + + expect(fetchCount.count).toBe(1) + }) + + it('should enforce minimum staleTime of 1000ms when using suspense with function', async () => { + const TestComponent = createTestQuery({ + fetchCount, + queryKey: ['test-func'], + staleTime: () => 10, + }) + + const rendered = renderWithSuspense(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: data')).toBeInTheDocument() + + rendered.rerender( + + + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(100)) + + expect(fetchCount.count).toBe(1) + }) + + it('should respect staleTime when value is greater than 1000ms', async () => { + const TestComponent = createTestQuery({ + fetchCount, + queryKey: queryKey(), + staleTime: 2000, + }) + + const rendered = renderWithSuspense(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: data')).toBeInTheDocument() + + rendered.rerender( + + + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(1500)) + + expect(fetchCount.count).toBe(1) + }) + + it('should enforce minimum staleTime when undefined is provided', async () => { + const TestComponent = createTestQuery({ + fetchCount, + queryKey: queryKey(), + staleTime: undefined, + }) + + const rendered = renderWithSuspense(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: data')).toBeInTheDocument() + + rendered.rerender( + + + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(500)) + + expect(fetchCount.count).toBe(1) + }) + + it('should respect staleTime when function returns value greater than 1000ms', async () => { + const TestComponent = createTestQuery({ + fetchCount, + queryKey: queryKey(), + staleTime: () => 3000, + }) + + const rendered = renderWithSuspense(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: data')).toBeInTheDocument() + + rendered.rerender( + + + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(2000)) + + expect(fetchCount.count).toBe(1) + }) +}) diff --git a/packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx b/packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx new file mode 100644 index 0000000000..a231d20600 --- /dev/null +++ b/packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx @@ -0,0 +1,142 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { QueryClient } from '@tanstack/query-core' +import { useInfiniteQuery } from '../useInfiniteQuery' +import type { InfiniteData } from '@tanstack/query-core' + +describe('pageParam', () => { + it('initialPageParam should define type of param passed to queryFunctionContext', () => { + useInfiniteQuery({ + queryKey: ['key'], + queryFn: ({ pageParam }) => { + expectTypeOf(pageParam).toEqualTypeOf() + }, + initialPageParam: 1, + getNextPageParam: () => undefined, + }) + }) + + it('direction should be passed to queryFn of useInfiniteQuery', () => { + useInfiniteQuery({ + queryKey: ['key'], + queryFn: ({ direction }) => { + expectTypeOf(direction).toEqualTypeOf<'forward' | 'backward'>() + }, + initialPageParam: 1, + getNextPageParam: () => undefined, + }) + }) + + it('initialPageParam should define type of param passed to queryFunctionContext for fetchInfiniteQuery', () => { + const queryClient = new QueryClient() + queryClient.fetchInfiniteQuery({ + queryKey: ['key'], + queryFn: ({ pageParam }) => { + expectTypeOf(pageParam).toEqualTypeOf() + }, + initialPageParam: 1, + }) + }) + + it('initialPageParam should define type of param passed to queryFunctionContext for prefetchInfiniteQuery', () => { + const queryClient = new QueryClient() + queryClient.prefetchInfiniteQuery({ + queryKey: ['key'], + queryFn: ({ pageParam }) => { + expectTypeOf(pageParam).toEqualTypeOf() + }, + initialPageParam: 1, + }) + }) +}) +describe('select', () => { + it('should still return paginated data if no select result', () => { + const infiniteQuery = useInfiniteQuery({ + queryKey: ['key'], + queryFn: ({ pageParam }) => { + return pageParam * 5 + }, + initialPageParam: 1, + getNextPageParam: () => undefined, + }) + + // TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now + expectTypeOf(infiniteQuery.data).toEqualTypeOf< + InfiniteData | undefined + >() + }) + + it('should be able to transform data to arbitrary result', () => { + const infiniteQuery = useInfiniteQuery({ + queryKey: ['key'], + queryFn: ({ pageParam }) => { + return pageParam * 5 + }, + initialPageParam: 1, + getNextPageParam: () => undefined, + select: (data) => { + expectTypeOf(data).toEqualTypeOf>() + return 'selected' as const + }, + }) + + expectTypeOf(infiniteQuery.data).toEqualTypeOf<'selected' | undefined>() + }) +}) +describe('getNextPageParam / getPreviousPageParam', () => { + it('should get typed params', () => { + const infiniteQuery = useInfiniteQuery({ + queryKey: ['key'], + queryFn: ({ pageParam }) => { + return String(pageParam) + }, + initialPageParam: 1, + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { + expectTypeOf(lastPage).toEqualTypeOf() + expectTypeOf(allPages).toEqualTypeOf>() + expectTypeOf(lastPageParam).toEqualTypeOf() + expectTypeOf(allPageParams).toEqualTypeOf>() + return undefined + }, + getPreviousPageParam: ( + firstPage, + allPages, + firstPageParam, + allPageParams, + ) => { + expectTypeOf(firstPage).toEqualTypeOf() + expectTypeOf(allPages).toEqualTypeOf>() + expectTypeOf(firstPageParam).toEqualTypeOf() + expectTypeOf(allPageParams).toEqualTypeOf>() + return undefined + }, + }) + + // TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now + expectTypeOf(infiniteQuery.data).toEqualTypeOf< + InfiniteData | undefined + >() + }) +}) + +describe('error booleans', () => { + it('should not be permanently `false`', () => { + const { + isFetchNextPageError, + isFetchPreviousPageError, + isLoadingError, + isRefetchError, + } = useInfiniteQuery({ + queryKey: ['key'], + queryFn: ({ pageParam }) => { + return pageParam * 5 + }, + initialPageParam: 1, + getNextPageParam: () => undefined, + }) + + expectTypeOf(isFetchNextPageError).toEqualTypeOf() + expectTypeOf(isFetchPreviousPageError).toEqualTypeOf() + expectTypeOf(isLoadingError).toEqualTypeOf() + expectTypeOf(isRefetchError).toEqualTypeOf() + }) +}) diff --git a/packages/preact-query/src/__tests__/useInfiniteQuery.test.tsx b/packages/preact-query/src/__tests__/useInfiniteQuery.test.tsx new file mode 100644 index 0000000000..d663eb3177 --- /dev/null +++ b/packages/preact-query/src/__tests__/useInfiniteQuery.test.tsx @@ -0,0 +1,1864 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render } from '@testing-library/react' +import * as React from 'react' +import { + createRenderStream, + useTrackRenders, +} from '@testing-library/react-render-stream' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + QueryClientProvider, + keepPreviousData, + useInfiniteQuery, +} from '..' +import { renderWithClient, setActTimeout } from './utils' +import type { + InfiniteData, + QueryFunctionContext, + UseInfiniteQueryResult, +} from '..' +import type { Mock } from 'vitest' + +interface Result { + items: Array + nextId?: number + prevId?: number + ts: number +} + +const pageSize = 10 + +const fetchItems = async ( + page: number, + ts: number, + noNext?: boolean, + noPrev?: boolean, +): Promise => { + await sleep(10) + return { + items: [...new Array(10)].fill(null).map((_, d) => page * pageSize + d), + nextId: noNext ? undefined : page + 1, + prevId: noPrev ? undefined : page - 1, + ts, + } +} + +describe('useInfiniteQuery', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const queryCache = new QueryCache() + const queryClient = new QueryClient({ + queryCache, + defaultOptions: { + queries: { + experimental_prefetchInRender: true, + }, + }, + }) + + it('should return the correct states for a successful query', async () => { + const key = queryKey() + const states: Array>> = [] + + function Page() { + const state = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => sleep(10).then(() => pageParam), + getNextPageParam: (lastPage) => lastPage + 1, + initialPageParam: 0, + }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + + expect(states.length).toBe(2) + expect(states[0]).toEqual({ + data: undefined, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + fetchNextPage: expect.any(Function), + fetchPreviousPage: expect.any(Function), + hasNextPage: false, + hasPreviousPage: false, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isFetching: true, + isPaused: false, + isFetchNextPageError: false, + isFetchingNextPage: false, + isFetchPreviousPageError: false, + isFetchingPreviousPage: false, + isLoading: true, + isPending: true, + isInitialLoading: true, + isLoadingError: false, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: true, + isSuccess: false, + isEnabled: true, + refetch: expect.any(Function), + status: 'pending', + fetchStatus: 'fetching', + promise: expect.any(Promise), + }) + expect(states[1]).toEqual({ + data: { pages: [0], pageParams: [0] }, + dataUpdatedAt: expect.any(Number), + error: null, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + fetchNextPage: expect.any(Function), + fetchPreviousPage: expect.any(Function), + hasNextPage: true, + hasPreviousPage: false, + isError: false, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isPaused: false, + isFetchNextPageError: false, + isFetchingNextPage: false, + isFetchPreviousPageError: false, + isFetchingPreviousPage: false, + isLoading: false, + isPending: false, + isInitialLoading: false, + isLoadingError: false, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: true, + isSuccess: true, + isEnabled: true, + refetch: expect.any(Function), + status: 'success', + fetchStatus: 'idle', + promise: expect.any(Promise), + }) + }) + + it('should not throw when fetchNextPage returns an error', async () => { + const key = queryKey() + let noThrow = false + + function Page() { + const start = 1 + const state = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => + sleep(10).then(() => { + if (pageParam === 2) throw new Error('error') + return pageParam + }), + retry: 1, + retryDelay: 10, + getNextPageParam: (lastPage) => lastPage + 1, + initialPageParam: start, + }) + + const { fetchNextPage } = state + + React.useEffect(() => { + setActTimeout(() => { + fetchNextPage() + .then(() => { + noThrow = true + }) + .catch(() => undefined) + }, 20) + }, [fetchNextPage]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(50) + expect(noThrow).toBe(true) + }) + + it('should keep the previous data when placeholderData is set', async () => { + const key = queryKey() + const states: Array>> = [] + + function Page() { + const [order, setOrder] = React.useState('desc') + + const state = useInfiniteQuery({ + queryKey: [key, order], + queryFn: ({ pageParam }) => + sleep(10).then(() => `${pageParam}-${order}`), + getNextPageParam: () => 1, + initialPageParam: 0, + placeholderData: keepPreviousData, + notifyOnChangeProps: 'all', + }) + + states.push(state) + + return ( +
+ + +
data: {state.data?.pages.join(',') ?? 'null'}
+
isFetching: {String(state.isFetching)}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 0-desc')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /fetchNextPage/i })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 0-desc,1-desc')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /order/i })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 0-asc')).toBeInTheDocument() + expect(rendered.getByText('isFetching: false')).toBeInTheDocument() + + expect(states.length).toBe(6) + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + isFetchingNextPage: false, + isSuccess: false, + isPlaceholderData: false, + }) + expect(states[1]).toMatchObject({ + data: { pages: ['0-desc'] }, + isFetching: false, + isFetchingNextPage: false, + isSuccess: true, + isPlaceholderData: false, + }) + expect(states[2]).toMatchObject({ + data: { pages: ['0-desc'] }, + isFetching: true, + isFetchingNextPage: true, + isSuccess: true, + isPlaceholderData: false, + }) + expect(states[3]).toMatchObject({ + data: { pages: ['0-desc', '1-desc'] }, + isFetching: false, + isFetchingNextPage: false, + isSuccess: true, + isPlaceholderData: false, + }) + // Set state + expect(states[4]).toMatchObject({ + data: { pages: ['0-desc', '1-desc'] }, + isFetching: true, + isFetchingNextPage: false, + isSuccess: true, + isPlaceholderData: true, + }) + expect(states[5]).toMatchObject({ + data: { pages: ['0-asc'] }, + isFetching: false, + isFetchingNextPage: false, + isSuccess: true, + isPlaceholderData: false, + }) + }) + + it('should be able to select a part of the data', async () => { + const key = queryKey() + const states: Array>> = [] + + function Page() { + const state = useInfiniteQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => ({ count: 1 })), + select: (data) => ({ + pages: data.pages.map((x) => `count: ${x.count}`), + pageParams: data.pageParams, + }), + getNextPageParam: () => undefined, + initialPageParam: 0, + }) + states.push(state) + + return
{state.data?.pages.join(',')}
+ } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('count: 1')).toBeInTheDocument() + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: undefined, + isSuccess: false, + }) + expect(states[1]).toMatchObject({ + data: { pages: ['count: 1'] }, + isSuccess: true, + }) + }) + + it('should be able to select a new result and not cause infinite renders', async () => { + const key = queryKey() + const states: Array< + UseInfiniteQueryResult> + > = [] + let selectCalled = 0 + + function Page() { + const state = useInfiniteQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => ({ count: 1 })), + select: React.useCallback((data: InfiniteData<{ count: number }>) => { + selectCalled++ + return { + pages: data.pages.map((x) => ({ ...x, id: Math.random() })), + pageParams: data.pageParams, + } + }, []), + getNextPageParam: () => undefined, + initialPageParam: 0, + }) + states.push(state) + + return ( +
+ {state.data?.pages.map((page) => ( +
count: {page.count}
+ ))} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('count: 1')).toBeInTheDocument() + + expect(states.length).toBe(2) + expect(selectCalled).toBe(1) + expect(states[0]).toMatchObject({ + data: undefined, + isSuccess: false, + }) + expect(states[1]).toMatchObject({ + data: { pages: [{ count: 1 }] }, + isSuccess: true, + }) + }) + + it('should be able to reverse the data', async () => { + const key = queryKey() + const states: Array>> = [] + + function Page() { + const state = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => sleep(10).then(() => pageParam), + select: (data) => ({ + pages: [...data.pages].reverse(), + pageParams: [...data.pageParams].reverse(), + }), + notifyOnChangeProps: 'all', + getNextPageParam: () => 1, + initialPageParam: 0, + }) + + states.push(state) + + return ( +
+ +
data: {state.data?.pages.join(',') ?? 'null'}
+
isFetching: {state.isFetching}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 0')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /fetchNextPage/i })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 1,0')).toBeInTheDocument() + + expect(states.length).toBe(4) + expect(states[0]).toMatchObject({ + data: undefined, + isSuccess: false, + }) + expect(states[1]).toMatchObject({ + data: { pages: [0] }, + isSuccess: true, + }) + expect(states[2]).toMatchObject({ + data: { pages: [0] }, + isSuccess: true, + }) + expect(states[3]).toMatchObject({ + data: { pages: [1, 0] }, + isSuccess: true, + }) + }) + + it('should be able to fetch a previous page', async () => { + const key = queryKey() + const states: Array>> = [] + + function Page() { + const start = 10 + const state = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => sleep(10).then(() => pageParam), + initialPageParam: start, + getNextPageParam: (lastPage) => lastPage + 1, + getPreviousPageParam: (firstPage) => firstPage - 1, + notifyOnChangeProps: 'all', + }) + + states.push(state) + + return ( +
+
data: {state.data?.pages.join(',') ?? null}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 10')).toBeInTheDocument() + + fireEvent.click( + rendered.getByRole('button', { name: /fetch previous page/i }), + ) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 9,10')).toBeInTheDocument() + + expect(states.length).toBe(4) + expect(states[0]).toMatchObject({ + data: undefined, + hasNextPage: false, + hasPreviousPage: false, + isFetching: true, + isFetchingNextPage: false, + isFetchingPreviousPage: false, + isSuccess: false, + }) + expect(states[1]).toMatchObject({ + data: { pages: [10] }, + hasNextPage: true, + hasPreviousPage: true, + isFetching: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, + isSuccess: true, + }) + expect(states[2]).toMatchObject({ + data: { pages: [10] }, + hasNextPage: true, + hasPreviousPage: true, + isFetching: true, + isFetchingNextPage: false, + isFetchingPreviousPage: true, + isSuccess: true, + }) + expect(states[3]).toMatchObject({ + data: { pages: [9, 10] }, + hasNextPage: true, + hasPreviousPage: true, + isFetching: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, + isSuccess: true, + }) + }) + + it('should be able to refetch when providing page params automatically', async () => { + const key = queryKey() + const states: Array>> = [] + + function Page() { + const state = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => sleep(10).then(() => pageParam), + initialPageParam: 10, + getPreviousPageParam: (firstPage) => firstPage - 1, + getNextPageParam: (lastPage) => lastPage + 1, + notifyOnChangeProps: 'all', + }) + + states.push(state) + + return ( +
+ + + +
data: {state.data?.pages.join(',') ?? 'null'}
+
isFetching: {String(state.isFetching)}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 10')).toBeInTheDocument() + fireEvent.click(rendered.getByRole('button', { name: /fetchNextPage/i })) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 10,11')).toBeInTheDocument() + fireEvent.click( + rendered.getByRole('button', { name: /fetchPreviousPage/i }), + ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 9,10,11')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + expect(rendered.getByText('isFetching: false')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(31) + expect(states.length).toBe(8) + // Initial fetch + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + isFetchingNextPage: false, + isRefetching: false, + }) + // Initial fetch done + expect(states[1]).toMatchObject({ + data: { pages: [10] }, + isFetching: false, + isFetchingNextPage: false, + isRefetching: false, + }) + // Fetch next page + expect(states[2]).toMatchObject({ + data: { pages: [10] }, + isFetching: true, + isFetchingNextPage: true, + isRefetching: false, + }) + // Fetch next page done + expect(states[3]).toMatchObject({ + data: { pages: [10, 11] }, + isFetching: false, + isFetchingNextPage: false, + isRefetching: false, + }) + // Fetch previous page + expect(states[4]).toMatchObject({ + data: { pages: [10, 11] }, + isFetching: true, + isFetchingNextPage: false, + isFetchingPreviousPage: true, + isRefetching: false, + }) + // Fetch previous page done + expect(states[5]).toMatchObject({ + data: { pages: [9, 10, 11] }, + isFetching: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, + isRefetching: false, + }) + // Refetch + expect(states[6]).toMatchObject({ + data: { pages: [9, 10, 11] }, + isFetching: true, + isFetchingNextPage: false, + isFetchingPreviousPage: false, + isRefetching: true, + }) + // Refetch done + expect(states[7]).toMatchObject({ + data: { pages: [9, 10, 11] }, + isFetching: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, + isRefetching: false, + }) + }) + + it('should return the correct states when refetch fails', async () => { + const key = queryKey() + const states: Array>> = [] + let isRefetch = false + + function Page() { + const state = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => + sleep(10).then(() => { + if (isRefetch) throw new Error() + return pageParam + }), + initialPageParam: 10, + getPreviousPageParam: (firstPage) => firstPage - 1, + getNextPageParam: (lastPage) => lastPage + 1, + notifyOnChangeProps: 'all', + retry: false, + }) + + states.push(state) + + return ( +
+ +
data: {state.data?.pages.join(',') ?? 'null'}
+
isFetching: {String(state.isFetching)}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 10')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + expect(rendered.getByText('isFetching: false')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + expect(states.length).toBe(4) + // Initial fetch + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + isFetchNextPageError: false, + isFetchingNextPage: false, + isFetchPreviousPageError: false, + isFetchingPreviousPage: false, + isRefetchError: false, + isRefetching: false, + }) + // Initial fetch done + expect(states[1]).toMatchObject({ + data: { pages: [10] }, + isFetching: false, + isFetchNextPageError: false, + isFetchingNextPage: false, + isFetchPreviousPageError: false, + isFetchingPreviousPage: false, + isRefetchError: false, + isRefetching: false, + }) + // Refetch + expect(states[2]).toMatchObject({ + data: { pages: [10] }, + isFetching: true, + isFetchNextPageError: false, + isFetchingNextPage: false, + isFetchPreviousPageError: false, + isFetchingPreviousPage: false, + isRefetchError: false, + isRefetching: true, + }) + // Refetch failed + expect(states[3]).toMatchObject({ + data: { pages: [10] }, + isFetching: false, + isFetchNextPageError: false, + isFetchingNextPage: false, + isFetchPreviousPageError: false, + isFetchingPreviousPage: false, + isRefetchError: true, + isRefetching: false, + }) + }) + + it('should return the correct states when fetchNextPage fails', async () => { + const key = queryKey() + const states: Array>> = [] + + function Page() { + const state = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => + sleep(10).then(() => { + if (pageParam !== 10) throw new Error() + return pageParam + }), + initialPageParam: 10, + getPreviousPageParam: (firstPage) => firstPage - 1, + getNextPageParam: (lastPage) => lastPage + 1, + notifyOnChangeProps: 'all', + retry: false, + }) + + states.push(state) + + return ( +
+ +
data: {state.data?.pages.join(',') ?? 'null'}
+
isFetching: {String(state.isFetching)}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 10')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /fetchNextPage/i })) + expect(rendered.getByText('isFetching: false')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + expect(states.length).toBe(4) + // Initial fetch + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + isFetchNextPageError: false, + isFetchingNextPage: false, + isFetchPreviousPageError: false, + isFetchingPreviousPage: false, + isRefetchError: false, + isRefetching: false, + }) + // Initial fetch done + expect(states[1]).toMatchObject({ + data: { pages: [10] }, + isFetching: false, + isFetchNextPageError: false, + isFetchingNextPage: false, + isFetchPreviousPageError: false, + isFetchingPreviousPage: false, + isRefetchError: false, + isRefetching: false, + }) + // Fetch next page + expect(states[2]).toMatchObject({ + data: { pages: [10] }, + isFetching: true, + isFetchNextPageError: false, + isFetchingNextPage: true, + isFetchPreviousPageError: false, + isFetchingPreviousPage: false, + isRefetchError: false, + isRefetching: false, + }) + // Fetch next page failed + expect(states[3]).toMatchObject({ + data: { pages: [10] }, + isFetching: false, + isFetchNextPageError: true, + isFetchingNextPage: false, + isFetchPreviousPageError: false, + isFetchingPreviousPage: false, + isRefetchError: false, + isRefetching: false, + }) + }) + + it('should return the correct states when fetchPreviousPage fails', async () => { + const key = queryKey() + const states: Array>> = [] + + function Page() { + const state = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => + sleep(10).then(() => { + if (pageParam !== 10) throw new Error() + return pageParam + }), + initialPageParam: 10, + getPreviousPageParam: (firstPage) => firstPage - 1, + getNextPageParam: (lastPage) => lastPage + 1, + notifyOnChangeProps: 'all', + retry: false, + }) + + states.push(state) + + return ( +
+ +
data: {state.data?.pages.join(',') ?? 'null'}
+
isFetching: {String(state.isFetching)}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 10')).toBeInTheDocument() + + fireEvent.click( + rendered.getByRole('button', { name: /fetchPreviousPage/i }), + ) + expect(rendered.getByText('isFetching: false')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + expect(states.length).toBe(4) + // Initial fetch + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + isFetchNextPageError: false, + isFetchingNextPage: false, + isFetchPreviousPageError: false, + isFetchingPreviousPage: false, + isRefetchError: false, + isRefetching: false, + }) + // Initial fetch done + expect(states[1]).toMatchObject({ + data: { pages: [10] }, + isFetching: false, + isFetchNextPageError: false, + isFetchingNextPage: false, + isFetchPreviousPageError: false, + isFetchingPreviousPage: false, + isRefetchError: false, + isRefetching: false, + }) + // Fetch previous page + expect(states[2]).toMatchObject({ + data: { pages: [10] }, + isFetching: true, + isFetchNextPageError: false, + isFetchingNextPage: false, + isFetchPreviousPageError: false, + isFetchingPreviousPage: true, + isRefetchError: false, + isRefetching: false, + }) + // Fetch previous page failed + expect(states[3]).toMatchObject({ + data: { pages: [10] }, + isFetching: false, + isFetchNextPageError: false, + isFetchingNextPage: false, + isFetchPreviousPageError: true, + isFetchingPreviousPage: false, + isRefetchError: false, + isRefetching: false, + }) + }) + + it('should silently cancel any ongoing fetch when fetching more', async () => { + const key = queryKey() + + function Page() { + const start = 10 + const { data, fetchNextPage, refetch, status, fetchStatus } = + useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => sleep(50).then(() => pageParam), + initialPageParam: start, + getNextPageParam: (lastPage) => lastPage + 1, + }) + + return ( +
+ + +
data: {JSON.stringify(data)}
+
+ status: {status}, {fetchStatus} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(51) + expect(rendered.getByText('status: success, idle')).toBeInTheDocument() + expect( + rendered.getByText('data: {"pages":[10],"pageParams":[10]}'), + ).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('status: success, fetching')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /fetchNextPage/i })) + await vi.advanceTimersByTimeAsync(51) + expect(rendered.getByText('status: success, idle')).toBeInTheDocument() + expect( + rendered.getByText('data: {"pages":[10,11],"pageParams":[10,11]}'), + ).toBeInTheDocument() + }) + + it('should silently cancel an ongoing fetchNextPage request when another fetchNextPage is invoked', async () => { + const key = queryKey() + const start = 10 + const onAborts: Array) => any>> = [] + const abortListeners: Array) => any>> = [] + const fetchPage = vi.fn< + (context: QueryFunctionContext) => Promise + >(async ({ pageParam, signal }) => { + const onAbort = vi.fn() + const abortListener = vi.fn() + onAborts.push(onAbort) + abortListeners.push(abortListener) + signal.onabort = onAbort + signal.addEventListener('abort', abortListener) + await sleep(50) + return pageParam + }) + + function Page() { + const { fetchNextPage } = useInfiniteQuery({ + queryKey: key, + queryFn: fetchPage, + initialPageParam: start, + getNextPageParam: (lastPage) => lastPage + 1, + }) + + React.useEffect(() => { + setActTimeout(() => { + fetchNextPage() + }, 100) + setActTimeout(() => { + fetchNextPage() + }, 110) + }, [fetchNextPage]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(160) + + const expectedCallCount = 3 + expect(fetchPage).toBeCalledTimes(expectedCallCount) + expect(onAborts).toHaveLength(expectedCallCount) + expect(abortListeners).toHaveLength(expectedCallCount) + + let callIndex = 0 + const firstCtx = fetchPage.mock.calls[callIndex]![0] + expect(firstCtx.pageParam).toEqual(start) + expect(firstCtx.queryKey).toEqual(key) + expect(firstCtx.signal).toBeInstanceOf(AbortSignal) + expect(firstCtx.signal.aborted).toBe(false) + expect(onAborts[callIndex]).not.toHaveBeenCalled() + expect(abortListeners[callIndex]).not.toHaveBeenCalled() + + callIndex = 1 + const secondCtx = fetchPage.mock.calls[callIndex]![0] + expect(secondCtx.pageParam).toBe(11) + expect(secondCtx.queryKey).toEqual(key) + expect(secondCtx.signal).toBeInstanceOf(AbortSignal) + expect(secondCtx.signal.aborted).toBe(true) + expect(onAborts[callIndex]).toHaveBeenCalledTimes(1) + expect(abortListeners[callIndex]).toHaveBeenCalledTimes(1) + + callIndex = 2 + const thirdCtx = fetchPage.mock.calls[callIndex]![0] + expect(thirdCtx.pageParam).toBe(11) + expect(thirdCtx.queryKey).toEqual(key) + expect(thirdCtx.signal).toBeInstanceOf(AbortSignal) + expect(thirdCtx.signal.aborted).toBe(false) + expect(onAborts[callIndex]).not.toHaveBeenCalled() + expect(abortListeners[callIndex]).not.toHaveBeenCalled() + }) + + it('should not cancel an ongoing fetchNextPage request when another fetchNextPage is invoked if `cancelRefetch: false` is used', async () => { + const key = queryKey() + const start = 10 + const onAborts: Array) => any>> = [] + const abortListeners: Array) => any>> = [] + const fetchPage = vi.fn< + (context: QueryFunctionContext) => Promise + >(async ({ pageParam, signal }) => { + const onAbort = vi.fn() + const abortListener = vi.fn() + onAborts.push(onAbort) + abortListeners.push(abortListener) + signal.onabort = onAbort + signal.addEventListener('abort', abortListener) + await sleep(50) + return pageParam + }) + + function Page() { + const { fetchNextPage } = useInfiniteQuery({ + queryKey: key, + queryFn: fetchPage, + initialPageParam: start, + getNextPageParam: (lastPage) => lastPage + 1, + }) + + React.useEffect(() => { + setActTimeout(() => { + fetchNextPage() + }, 100) + setActTimeout(() => { + fetchNextPage({ cancelRefetch: false }) + }, 110) + }, [fetchNextPage]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(160) + + const expectedCallCount = 2 + expect(fetchPage).toBeCalledTimes(expectedCallCount) + expect(onAborts).toHaveLength(expectedCallCount) + expect(abortListeners).toHaveLength(expectedCallCount) + + let callIndex = 0 + const firstCtx = fetchPage.mock.calls[callIndex]![0] + expect(firstCtx.pageParam).toEqual(start) + expect(firstCtx.queryKey).toEqual(key) + expect(firstCtx.signal).toBeInstanceOf(AbortSignal) + expect(firstCtx.signal.aborted).toBe(false) + expect(onAborts[callIndex]).not.toHaveBeenCalled() + expect(abortListeners[callIndex]).not.toHaveBeenCalled() + + callIndex = 1 + const secondCtx = fetchPage.mock.calls[callIndex]![0] + expect(secondCtx.pageParam).toBe(11) + expect(secondCtx.queryKey).toEqual(key) + expect(secondCtx.signal).toBeInstanceOf(AbortSignal) + expect(secondCtx.signal.aborted).toBe(false) + expect(onAborts[callIndex]).not.toHaveBeenCalled() + expect(abortListeners[callIndex]).not.toHaveBeenCalled() + }) + + it('should keep fetching first page when not loaded yet and triggering fetch more', async () => { + const key = queryKey() + const states: Array>> = [] + + function Page() { + const start = 10 + const state = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => sleep(50).then(() => pageParam), + initialPageParam: start, + getNextPageParam: (lastPage) => lastPage + 1, + notifyOnChangeProps: 'all', + }) + + states.push(state) + + const { fetchNextPage } = state + + React.useEffect(() => { + setActTimeout(() => { + fetchNextPage() + }, 10) + }, [fetchNextPage]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(60) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + hasNextPage: false, + data: undefined, + isFetching: true, + isFetchingNextPage: false, + isSuccess: false, + }) + expect(states[1]).toMatchObject({ + hasNextPage: true, + data: { pages: [10] }, + isFetching: false, + isFetchingNextPage: false, + isSuccess: true, + }) + }) + + it('should stop fetching additional pages when the component is unmounted and AbortSignal is consumed', async () => { + const key = queryKey() + let fetches = 0 + + const initialData = { pages: [1, 2, 3, 4], pageParams: [0, 1, 2, 3] } + + function List() { + useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => + sleep(50).then(() => { + fetches++ + return pageParam * 10 + }), + initialData, + initialPageParam: 0, + getNextPageParam: (_, allPages) => { + return allPages.length === 4 ? undefined : allPages.length + }, + }) + + return null + } + + function Page() { + const [show, setShow] = React.useState(true) + + React.useEffect(() => { + setActTimeout(() => { + setShow(false) + }, 75) + }, []) + + return show ? : null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(125) + + expect(fetches).toBe(2) + expect(queryClient.getQueryState(key)).toMatchObject({ + data: initialData, + status: 'success', + error: null, + }) + }) + + it('should be able to set new pages with the query client', async () => { + const key = queryKey() + + let multiplier = 1 + + function Page() { + const [firstPage, setFirstPage] = React.useState(0) + + const state = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => + sleep(10).then(() => multiplier * pageParam), + getNextPageParam: (lastPage) => lastPage + 1, + initialPageParam: firstPage, + }) + + return ( +
+ + +
data: {JSON.stringify(state.data)}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect( + rendered.getByText('data: {"pages":[0],"pageParams":[0]}'), + ).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /setPages/i })) + await vi.advanceTimersByTimeAsync(11) + expect( + rendered.getByText('data: {"pages":[7,8],"pageParams":[7,8]}'), + ).toBeInTheDocument() + + multiplier = 2 + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + await vi.advanceTimersByTimeAsync(21) + expect( + rendered.getByText('data: {"pages":[14,30],"pageParams":[7,15]}'), + ).toBeInTheDocument() + }) + + it('should only refetch the first page when initialData is provided', async () => { + vi.useRealTimers() + + const key = queryKey() + + const renderStream = + createRenderStream>>() + + function Page() { + const state = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => sleep(10).then(() => pageParam), + initialData: { pages: [1], pageParams: [1] }, + getNextPageParam: (lastPage) => lastPage + 1, + initialPageParam: 0, + notifyOnChangeProps: 'all', + }) + + renderStream.replaceSnapshot(state) + + return ( + + ) + } + + const rendered = await renderStream.render( + + + , + ) + + { + const { snapshot } = await renderStream.takeRender() + expect(snapshot).toMatchObject({ + data: { pages: [1] }, + hasNextPage: true, + isFetching: true, + isFetchingNextPage: false, + isSuccess: true, + }) + } + + { + const { snapshot } = await renderStream.takeRender() + expect(snapshot).toMatchObject({ + data: { pages: [1] }, + hasNextPage: true, + isFetching: false, + isFetchingNextPage: false, + isSuccess: true, + }) + } + + fireEvent.click(rendered.getByText('fetchNextPage')) + + { + const { snapshot } = await renderStream.takeRender() + expect(snapshot).toMatchObject({ + data: { pages: [1] }, + hasNextPage: true, + isFetching: true, + isFetchingNextPage: true, + isSuccess: true, + }) + } + { + const { snapshot } = await renderStream.takeRender() + expect(snapshot).toMatchObject({ + data: { pages: [1, 2] }, + hasNextPage: true, + isFetching: false, + isFetchingNextPage: false, + isSuccess: true, + }) + } + }) + + it('should set hasNextPage to false if getNextPageParam returns undefined', async () => { + const key = queryKey() + const states: Array>> = [] + + function Page() { + const state = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => sleep(10).then(() => pageParam), + getNextPageParam: () => undefined, + initialPageParam: 1, + }) + + states.push(state) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: undefined, + hasNextPage: false, + isFetching: true, + isFetchingNextPage: false, + isSuccess: false, + }) + expect(states[1]).toMatchObject({ + data: { pages: [1] }, + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isSuccess: true, + }) + }) + + it('should compute hasNextPage correctly using initialData', async () => { + const key = queryKey() + const states: Array>> = [] + + function Page() { + const state = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => sleep(10).then(() => pageParam), + initialData: { pages: [10], pageParams: [10] }, + getNextPageParam: (lastPage) => (lastPage === 10 ? 11 : undefined), + initialPageParam: 10, + }) + + states.push(state) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: { pages: [10] }, + hasNextPage: true, + isFetching: true, + isFetchingNextPage: false, + isSuccess: true, + }) + expect(states[1]).toMatchObject({ + data: { pages: [10] }, + hasNextPage: true, + isFetching: false, + isFetchingNextPage: false, + isSuccess: true, + }) + }) + + it('should compute hasNextPage correctly for falsy getFetchMore return value using initialData', async () => { + const key = queryKey() + const states: Array>> = [] + + function Page() { + const state = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => sleep(10).then(() => pageParam), + initialPageParam: 10, + initialData: { pages: [10], pageParams: [10] }, + getNextPageParam: () => undefined, + }) + + states.push(state) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: { pages: [10] }, + hasNextPage: false, + isFetching: true, + isFetchingNextPage: false, + isSuccess: true, + }) + expect(states[1]).toMatchObject({ + data: { pages: [10] }, + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isSuccess: true, + }) + }) + + it('should not use selected data when computing hasNextPage', async () => { + const key = queryKey() + const states: Array>> = [] + + function Page() { + const state = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => sleep(10).then(() => pageParam), + getNextPageParam: (lastPage) => (lastPage === 1 ? 2 : undefined), + select: (data) => ({ + pages: data.pages.map((x) => x.toString()), + pageParams: data.pageParams, + }), + initialPageParam: 1, + }) + + states.push(state) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: undefined, + hasNextPage: false, + isFetching: true, + isFetchingNextPage: false, + isSuccess: false, + }) + expect(states[1]).toMatchObject({ + data: { pages: ['1'] }, + hasNextPage: true, + isFetching: false, + isFetchingNextPage: false, + isSuccess: true, + }) + }) + + it('should build fresh cursors on refetch', async () => { + const key = queryKey() + + const genItems = (size: number) => + [...new Array(size)].fill(null).map((_, d) => d) + const items = genItems(15) + const limit = 3 + + const fetchItemsWithLimit = (cursor = 0, ts: number) => + sleep(10).then(() => ({ + nextId: cursor + limit, + items: items.slice(cursor, cursor + limit), + ts, + })) + + function Page() { + const fetchCountRef = React.useRef(0) + const { + status, + data, + error, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + refetch, + } = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => + fetchItemsWithLimit(pageParam, fetchCountRef.current++), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextId, + }) + + return ( +
+

Pagination

+ {status === 'pending' ? ( + 'Loading...' + ) : status === 'error' ? ( + Error: {error.message} + ) : ( + <> +
Data:
+ {data.pages.map((page, i) => ( +
+
+ Page {i}: {page.ts} +
+
+ {page.items.map((item) => ( +

Item: {item}

+ ))} +
+
+ ))} +
+ + + +
+
{!isFetchingNextPage ? 'Background Updating...' : null}
+ + )} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('Loading...')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('Item: 2')).toBeInTheDocument() + expect(rendered.getByText('Page 0: 0')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('Load More')) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('Loading more...')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('Item: 5')).toBeInTheDocument() + expect(rendered.getByText('Page 0: 0')).toBeInTheDocument() + expect(rendered.getByText('Page 1: 1')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('Load More')) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('Loading more...')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('Item: 8')).toBeInTheDocument() + expect(rendered.getByText('Page 0: 0')).toBeInTheDocument() + expect(rendered.getByText('Page 1: 1')).toBeInTheDocument() + expect(rendered.getByText('Page 2: 2')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('Refetch')) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('Background Updating...')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(31) + expect(rendered.getByText('Item: 8')).toBeInTheDocument() + expect(rendered.getByText('Page 0: 3')).toBeInTheDocument() + expect(rendered.getByText('Page 1: 4')).toBeInTheDocument() + expect(rendered.getByText('Page 2: 5')).toBeInTheDocument() + // ensure that Item: 4 is rendered before removing it + expect(rendered.queryAllByText('Item: 4')).toHaveLength(1) + + // remove Item: 4 + fireEvent.click(rendered.getByText('Remove item')) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('Background Updating...')).toBeInTheDocument() + // ensure that an additional item is rendered (it means that cursors were properly rebuilt) + await vi.advanceTimersByTimeAsync(31) + expect(rendered.getByText('Item: 9')).toBeInTheDocument() + expect(rendered.getByText('Page 0: 6')).toBeInTheDocument() + expect(rendered.getByText('Page 1: 7')).toBeInTheDocument() + expect(rendered.getByText('Page 2: 8')).toBeInTheDocument() + // ensure that Item: 4 is no longer rendered + expect(rendered.queryAllByText('Item: 4')).toHaveLength(0) + }) + + it('should compute hasNextPage correctly for falsy getFetchMore return value on refetching', async () => { + const key = queryKey() + const MAX = 2 + + function Page() { + const fetchCountRef = React.useRef(0) + const [isRemovedLastPage, setIsRemovedLastPage] = + React.useState(false) + const { + status, + data, + error, + isFetching, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + refetch, + } = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => + fetchItems( + pageParam, + fetchCountRef.current++, + pageParam === MAX || (pageParam === MAX - 1 && isRemovedLastPage), + ), + getNextPageParam: (lastPage) => lastPage.nextId, + initialPageParam: 0, + }) + + return ( +
+

Pagination

+ {status === 'pending' ? ( + 'Loading...' + ) : status === 'error' ? ( + Error: {error.message} + ) : ( + <> +
Data:
+ {data.pages.map((page, i) => ( +
+
+ Page {i}: {page.ts} +
+
+ {page.items.map((item) => ( +

Item: {item}

+ ))} +
+
+ ))} +
+ + + +
+
+ {isFetching && !isFetchingNextPage + ? 'Background Updating...' + : null} +
+ + )} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('Loading...')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('Item: 9')).toBeInTheDocument() + expect(rendered.getByText('Page 0: 0')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('Load More')) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('Loading more...')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('Item: 19')).toBeInTheDocument() + expect(rendered.getByText('Page 0: 0')).toBeInTheDocument() + expect(rendered.getByText('Page 1: 1')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('Load More')) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('Loading more...')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('Item: 29')).toBeInTheDocument() + expect(rendered.getByText('Page 0: 0')).toBeInTheDocument() + expect(rendered.getByText('Page 1: 1')).toBeInTheDocument() + expect(rendered.getByText('Page 2: 2')).toBeInTheDocument() + expect(rendered.getByText('Nothing more to load')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('Remove Last Page')) + fireEvent.click(rendered.getByText('Refetch')) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('Background Updating...')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(21) + expect(rendered.queryByText('Item: 29')).not.toBeInTheDocument() + expect(rendered.getByText('Page 0: 3')).toBeInTheDocument() + expect(rendered.getByText('Page 1: 4')).toBeInTheDocument() + expect(rendered.queryByText('Page 2: 5')).not.toBeInTheDocument() + expect(rendered.getByText('Nothing more to load')).toBeInTheDocument() + }) + + it('should cancel the query function when there are no more subscriptions', () => { + const key = queryKey() + let cancelFn: Mock = vi.fn() + + const queryFn = ({ signal }: { signal?: AbortSignal }) => { + const promise = new Promise((resolve, reject) => { + cancelFn = vi.fn(() => reject('Cancelled')) + signal?.addEventListener('abort', cancelFn) + sleep(1000).then(() => resolve('OK')) + }) + + return promise + } + + function Inner() { + const state = useInfiniteQuery({ + queryKey: key, + queryFn, + getNextPageParam: () => undefined, + initialPageParam: 0, + }) + return ( +
+

Status: {state.status}

+
+ ) + } + + function Page() { + const [isVisible, setIsVisible] = React.useState(true) + + return ( + <> + + {isVisible && } +
{isVisible ? 'visible' : 'hidden'}
+ + ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('visible')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: 'hide' })) + expect(rendered.getByText('hidden')).toBeInTheDocument() + + expect(cancelFn).toHaveBeenCalled() + }) + + it('should use provided custom queryClient', async () => { + const key = queryKey() + const queryFn = () => sleep(10).then(() => 'custom client') + + function Page() { + const { data } = useInfiniteQuery( + { + queryKey: key, + queryFn, + getNextPageParam: () => undefined, + initialPageParam: 0, + }, + queryClient, + ) + + return
data: {data?.pages[0]}
+ } + + const rendered = render() + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: custom client')).toBeInTheDocument() + }) + + it('should work with React.use()', async () => { + vi.useRealTimers() + + const key = queryKey() + + const renderStream = createRenderStream({ snapshotDOM: true }) + + function Loading() { + useTrackRenders() + return <>loading... + } + + function MyComponent() { + useTrackRenders() + const fetchCountRef = React.useRef(0) + const query = useInfiniteQuery({ + queryFn: ({ pageParam }) => + fetchItems(pageParam, fetchCountRef.current++), + getNextPageParam: (lastPage) => lastPage.nextId, + initialPageParam: 0, + queryKey: key, + }) + const data = React.use(query.promise) + return ( + <> + {data.pages.map((page, index) => ( + +
+
Page: {index + 1}
+
+ {page.items.map((item) => ( +

Item: {item}

+ ))} +
+ ))} + + + ) + } + + function Page() { + useTrackRenders() + return ( + }> + + + ) + } + + const rendered = await renderStream.render( + + + , + ) + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading...') + expect(renderedComponents).toEqual([Page, Loading]) + } + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('Page: 1') + withinDOM().getByText('Item: 1') + expect(renderedComponents).toEqual([MyComponent]) + } + + // click button + rendered.getByRole('button', { name: 'fetchNextPage' }).click() + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('Page: 1') + expect(renderedComponents).toEqual([MyComponent]) + } + }) +}) diff --git a/packages/preact-query/src/__tests__/useIsFetching.test.tsx b/packages/preact-query/src/__tests__/useIsFetching.test.tsx new file mode 100644 index 0000000000..99793d4596 --- /dev/null +++ b/packages/preact-query/src/__tests__/useIsFetching.test.tsx @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render } from '@testing-library/react' +import * as React from 'react' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { QueryCache, QueryClient, useIsFetching, useQuery } from '..' +import { renderWithClient, setActTimeout } from './utils' + +describe('useIsFetching', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + // See https://github.com/tannerlinsley/react-query/issues/105 + it('should update as queries start and stop fetching', async () => { + const queryClient = new QueryClient() + const key = queryKey() + + function IsFetching() { + const isFetching = useIsFetching() + + return
isFetching: {isFetching}
+ } + + function Query() { + const [ready, setReady] = React.useState(false) + + useQuery({ + queryKey: key, + queryFn: () => sleep(50).then(() => 'test'), + enabled: ready, + }) + + return + } + + function Page() { + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('isFetching: 0')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /setReady/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('isFetching: 1')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(51) + expect(rendered.getByText('isFetching: 0')).toBeInTheDocument() + }) + + it('should not update state while rendering', async () => { + const queryClient = new QueryClient() + + const key1 = queryKey() + const key2 = queryKey() + + const isFetchingArray: Array = [] + + function IsFetching() { + const isFetching = useIsFetching() + + isFetchingArray.push(isFetching) + + return null + } + + function FirstQuery() { + useQuery({ + queryKey: key1, + queryFn: () => sleep(100).then(() => 'data1'), + }) + + return null + } + + function SecondQuery() { + useQuery({ + queryKey: key2, + queryFn: () => sleep(100).then(() => 'data2'), + }) + + return null + } + + function Page() { + const [renderSecond, setRenderSecond] = React.useState(false) + + React.useEffect(() => { + setActTimeout(() => { + setRenderSecond(true) + }, 50) + }, []) + + return ( + <> + + + {renderSecond && } + + ) + } + + renderWithClient(queryClient, ) + + expect(isFetchingArray[0]).toEqual(0) + await vi.advanceTimersByTimeAsync(0) + expect(isFetchingArray[1]).toEqual(1) + await vi.advanceTimersByTimeAsync(50) + expect(isFetchingArray[2]).toEqual(1) + await vi.advanceTimersByTimeAsync(1) + expect(isFetchingArray[3]).toEqual(2) + await vi.advanceTimersByTimeAsync(50) + expect(isFetchingArray[4]).toEqual(1) + await vi.advanceTimersByTimeAsync(50) + expect(isFetchingArray[5]).toEqual(0) + + expect(isFetchingArray).toEqual([0, 1, 1, 2, 1, 0]) + }) + + it('should be able to filter', async () => { + const queryClient = new QueryClient() + const key1 = queryKey() + const key2 = queryKey() + + const isFetchingArray: Array = [] + + function One() { + useQuery({ + queryKey: key1, + queryFn: () => sleep(10).then(() => 'test1'), + }) + + return null + } + + function Two() { + useQuery({ + queryKey: key2, + queryFn: () => sleep(20).then(() => 'test2'), + }) + + return null + } + + function Page() { + const [started, setStarted] = React.useState(false) + const isFetching = useIsFetching({ queryKey: key1 }) + + isFetchingArray.push(isFetching) + + return ( +
+ +
isFetching: {isFetching}
+ {started ? ( + <> + + + + ) : null} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('isFetching: 0')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /setStarted/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('isFetching: 1')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('isFetching: 0')).toBeInTheDocument() + + // at no point should we have isFetching: 2 + expect(isFetchingArray).toEqual(expect.not.arrayContaining([2])) + }) + + it('should show the correct fetching state when mounted after a query', async () => { + const queryClient = new QueryClient() + const key = queryKey() + + function Page() { + useQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'test'), + }) + + const isFetching = useIsFetching() + + return ( +
+
isFetching: {isFetching}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('isFetching: 1')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('isFetching: 0')).toBeInTheDocument() + }) + + it('should use provided custom queryClient', async () => { + const onSuccess = vi.fn() + + const queryCache = new QueryCache({ onSuccess }) + const queryClient = new QueryClient({ queryCache }) + const key = queryKey() + + function Page() { + useQuery( + { + queryKey: key, + queryFn: () => sleep(10).then(() => 'test'), + }, + queryClient, + ) + + const isFetching = useIsFetching({}, queryClient) + + return ( +
+
isFetching: {isFetching}
+
+ ) + } + + const rendered = render() + + expect(rendered.getByText('isFetching: 1')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('isFetching: 0')).toBeInTheDocument() + expect(onSuccess).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/preact-query/src/__tests__/useMutation.test.tsx b/packages/preact-query/src/__tests__/useMutation.test.tsx new file mode 100644 index 0000000000..30800c9b08 --- /dev/null +++ b/packages/preact-query/src/__tests__/useMutation.test.tsx @@ -0,0 +1,1182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render } from '@testing-library/react' +import * as React from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { MutationCache, QueryCache, QueryClient, useMutation } from '..' +import { + mockOnlineManagerIsOnline, + renderWithClient, + setActTimeout, +} from './utils' +import type { UseMutationResult } from '../types' + +describe('useMutation', () => { + let queryCache: QueryCache + let mutationCache: MutationCache + let queryClient: QueryClient + + beforeEach(() => { + queryCache = new QueryCache() + mutationCache = new MutationCache() + queryClient = new QueryClient({ + queryCache, + mutationCache, + }) + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should be able to reset `data`', async () => { + function Page() { + const { + mutate, + data = 'empty', + reset, + } = useMutation({ mutationFn: () => Promise.resolve('mutation') }) + + return ( +
+

{data}

+ + +
+ ) + } + + const { getByRole } = renderWithClient(queryClient, ) + + expect(getByRole('heading').textContent).toBe('empty') + + fireEvent.click(getByRole('button', { name: /mutate/i })) + + await vi.advanceTimersByTimeAsync(0) + expect(getByRole('heading').textContent).toBe('mutation') + + fireEvent.click(getByRole('button', { name: /reset/i })) + + await vi.advanceTimersByTimeAsync(0) + expect(getByRole('heading').textContent).toBe('empty') + }) + + it('should be able to reset `error`', async () => { + function Page() { + const { mutate, error, reset } = useMutation({ + mutationFn: () => { + const err = new Error('Expected mock error. All is well!') + err.stack = '' + return Promise.reject(err) + }, + }) + + return ( +
+ {error &&

{error.message}

} + + +
+ ) + } + + const { getByRole, queryByRole } = renderWithClient(queryClient, ) + + expect(queryByRole('heading')).toBeNull() + + fireEvent.click(getByRole('button', { name: /mutate/i })) + + await vi.advanceTimersByTimeAsync(0) + expect(getByRole('heading').textContent).toBe( + 'Expected mock error. All is well!', + ) + + fireEvent.click(getByRole('button', { name: /reset/i })) + + await vi.advanceTimersByTimeAsync(0) + expect(queryByRole('heading')).toBeNull() + }) + + it('should be able to call `onSuccess` and `onSettled` after each successful mutate', async () => { + let count = 0 + const onSuccessMock = vi.fn() + const onSettledMock = vi.fn() + + function Page() { + const { mutate } = useMutation({ + mutationFn: (vars: { count: number }) => Promise.resolve(vars.count), + + onSuccess: (data) => { + onSuccessMock(data) + }, + onSettled: (data) => { + onSettledMock(data) + }, + }) + + return ( +
+

{count}

+ +
+ ) + } + + const { getByRole } = renderWithClient(queryClient, ) + + expect(getByRole('heading').textContent).toBe('0') + + fireEvent.click(getByRole('button', { name: /mutate/i })) + fireEvent.click(getByRole('button', { name: /mutate/i })) + fireEvent.click(getByRole('button', { name: /mutate/i })) + + await vi.advanceTimersByTimeAsync(0) + expect(getByRole('heading').textContent).toBe('3') + expect(onSuccessMock).toHaveBeenCalledTimes(3) + + expect(onSuccessMock).toHaveBeenCalledWith(1) + expect(onSuccessMock).toHaveBeenCalledWith(2) + expect(onSuccessMock).toHaveBeenCalledWith(3) + + expect(onSettledMock).toHaveBeenCalledTimes(3) + + expect(onSettledMock).toHaveBeenCalledWith(1) + expect(onSettledMock).toHaveBeenCalledWith(2) + expect(onSettledMock).toHaveBeenCalledWith(3) + }) + + it('should set correct values for `failureReason` and `failureCount` on multiple mutate calls', async () => { + let count = 0 + type Value = { count: number } + + const mutateFn = vi.fn<(value: Value) => Promise>() + + mutateFn.mockImplementationOnce(() => { + return Promise.reject(new Error('Error test Jonas')) + }) + + mutateFn.mockImplementation(async (value) => { + await sleep(10) + return Promise.resolve(value) + }) + + function Page() { + const { mutate, failureCount, failureReason, data, status } = useMutation( + { mutationFn: mutateFn }, + ) + + return ( +
+

Data {data?.count}

+

Status {status}

+

Failed {failureCount} times

+

Failed because {failureReason?.message ?? 'null'}

+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('Data')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('Status error')).toBeInTheDocument() + expect(rendered.getByText('Failed 1 times')).toBeInTheDocument() + expect( + rendered.getByText('Failed because Error test Jonas'), + ).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('Status pending')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('Status success')).toBeInTheDocument() + expect(rendered.getByText('Data 2')).toBeInTheDocument() + expect(rendered.getByText('Failed 0 times')).toBeInTheDocument() + expect(rendered.getByText('Failed because null')).toBeInTheDocument() + }) + + it('should be able to call `onError` and `onSettled` after each failed mutate', async () => { + const onErrorMock = vi.fn() + const onSettledMock = vi.fn() + let count = 0 + + function Page() { + const { mutate } = useMutation({ + mutationFn: (vars: { count: number }) => { + const error = new Error( + `Expected mock error. All is well! ${vars.count}`, + ) + error.stack = '' + return Promise.reject(error) + }, + onError: (error: Error) => { + onErrorMock(error.message) + }, + onSettled: (_data, error) => { + onSettledMock(error?.message) + }, + }) + + return ( +
+

{count}

+ +
+ ) + } + + const { getByRole } = renderWithClient(queryClient, ) + + expect(getByRole('heading').textContent).toBe('0') + + fireEvent.click(getByRole('button', { name: /mutate/i })) + fireEvent.click(getByRole('button', { name: /mutate/i })) + fireEvent.click(getByRole('button', { name: /mutate/i })) + + await vi.advanceTimersByTimeAsync(0) + expect(getByRole('heading').textContent).toBe('3') + expect(onErrorMock).toHaveBeenCalledTimes(3) + expect(onErrorMock).toHaveBeenCalledWith( + 'Expected mock error. All is well! 1', + ) + expect(onErrorMock).toHaveBeenCalledWith( + 'Expected mock error. All is well! 2', + ) + expect(onErrorMock).toHaveBeenCalledWith( + 'Expected mock error. All is well! 3', + ) + + expect(onSettledMock).toHaveBeenCalledTimes(3) + expect(onSettledMock).toHaveBeenCalledWith( + 'Expected mock error. All is well! 1', + ) + expect(onSettledMock).toHaveBeenCalledWith( + 'Expected mock error. All is well! 2', + ) + expect(onSettledMock).toHaveBeenCalledWith( + 'Expected mock error. All is well! 3', + ) + }) + + it('should be able to override the useMutation success callbacks', async () => { + const callbacks: Array = [] + + function Page() { + const { mutateAsync } = useMutation({ + mutationFn: (text: string) => Promise.resolve(text), + onSuccess: () => { + callbacks.push('useMutation.onSuccess') + return Promise.resolve() + }, + onSettled: () => { + callbacks.push('useMutation.onSettled') + return Promise.resolve() + }, + }) + + React.useEffect(() => { + setActTimeout(async () => { + try { + const result = await mutateAsync('todo', { + onSuccess: () => { + callbacks.push('mutateAsync.onSuccess') + return Promise.resolve() + }, + onSettled: () => { + callbacks.push('mutateAsync.onSettled') + return Promise.resolve() + }, + }) + callbacks.push(`mutateAsync.result:${result}`) + } catch {} + }, 10) + }, [mutateAsync]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual([ + 'useMutation.onSuccess', + 'useMutation.onSettled', + 'mutateAsync.onSuccess', + 'mutateAsync.onSettled', + 'mutateAsync.result:todo', + ]) + }) + + it('should be able to override the error callbacks when using mutateAsync', async () => { + const callbacks: Array = [] + + function Page() { + const { mutateAsync } = useMutation({ + mutationFn: async (_text: string) => Promise.reject(new Error('oops')), + onError: () => { + callbacks.push('useMutation.onError') + return Promise.resolve() + }, + onSettled: () => { + callbacks.push('useMutation.onSettled') + return Promise.resolve() + }, + }) + + React.useEffect(() => { + setActTimeout(async () => { + try { + await mutateAsync('todo', { + onError: () => { + callbacks.push('mutateAsync.onError') + return Promise.resolve() + }, + onSettled: () => { + callbacks.push('mutateAsync.onSettled') + return Promise.resolve() + }, + }) + } catch (error) { + callbacks.push(`mutateAsync.error:${(error as Error).message}`) + } + }, 10) + }, [mutateAsync]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(10) + + expect(callbacks).toEqual([ + 'useMutation.onError', + 'useMutation.onSettled', + 'mutateAsync.onError', + 'mutateAsync.onSettled', + 'mutateAsync.error:oops', + ]) + }) + + it('should be able to use mutation defaults', async () => { + const key = queryKey() + + queryClient.setMutationDefaults(key, { + mutationFn: async (text: string) => { + await sleep(10) + return text + }, + }) + + const states: Array> = [] + + function Page() { + const state = useMutation({ mutationKey: key }) + + states.push(state) + + const { mutate } = state + + React.useEffect(() => { + setActTimeout(() => { + mutate('todo') + }, 10) + }, [mutate]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(21) + + expect(states.length).toBe(3) + expect(states[0]).toMatchObject({ data: undefined, isPending: false }) + expect(states[1]).toMatchObject({ data: undefined, isPending: true }) + expect(states[2]).toMatchObject({ data: 'todo', isPending: false }) + }) + + it('should be able to retry a failed mutation', async () => { + let count = 0 + + function Page() { + const { mutate } = useMutation({ + mutationFn: (_text: string) => { + count++ + return Promise.reject(new Error('oops')) + }, + retry: 1, + retryDelay: 5, + }) + + React.useEffect(() => { + setActTimeout(() => { + mutate('todo') + }, 10) + }, [mutate]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(15) + + expect(count).toBe(2) + }) + + it('should not retry mutations while offline', async () => { + const onlineMock = mockOnlineManagerIsOnline(false) + + let count = 0 + + function Page() { + const mutation = useMutation({ + mutationFn: (_text: string) => { + count++ + return Promise.reject(new Error('oops')) + }, + retry: 1, + retryDelay: 5, + }) + + return ( +
+ +
+ error:{' '} + {mutation.error instanceof Error ? mutation.error.message : 'null'}, + status: {mutation.status}, isPaused: {String(mutation.isPaused)} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect( + rendered.getByText('error: null, status: idle, isPaused: false'), + ).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + + await vi.advanceTimersByTimeAsync(0) + expect( + rendered.getByText('error: null, status: pending, isPaused: true'), + ).toBeInTheDocument() + + expect(count).toBe(0) + + onlineMock.mockReturnValue(true) + queryClient.getMutationCache().resumePausedMutations() + + await vi.advanceTimersByTimeAsync(6) + expect( + rendered.getByText('error: oops, status: error, isPaused: false'), + ).toBeInTheDocument() + + expect(count).toBe(2) + onlineMock.mockRestore() + }) + + it('should call onMutate even if paused', async () => { + const onlineMock = mockOnlineManagerIsOnline(false) + const onMutate = vi.fn() + let count = 0 + + function Page() { + const mutation = useMutation({ + mutationFn: async (_text: string) => { + count++ + await sleep(10) + return count + }, + onMutate, + }) + + return ( +
+ +
+ data: {mutation.data ?? 'null'}, status: {mutation.status}, + isPaused: {String(mutation.isPaused)} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect( + rendered.getByText('data: null, status: idle, isPaused: false'), + ).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + + await vi.advanceTimersByTimeAsync(0) + expect( + rendered.getByText('data: null, status: pending, isPaused: true'), + ).toBeInTheDocument() + + expect(onMutate).toHaveBeenCalledTimes(1) + expect(onMutate).toHaveBeenCalledWith('todo', { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }) + + onlineMock.mockReturnValue(true) + queryClient.getMutationCache().resumePausedMutations() + await vi.advanceTimersByTimeAsync(11) + expect( + rendered.getByText('data: 1, status: success, isPaused: false'), + ).toBeInTheDocument() + + expect(onMutate).toHaveBeenCalledTimes(1) + expect(count).toBe(1) + + onlineMock.mockRestore() + }) + + it('should optimistically go to paused state if offline', async () => { + const onlineMock = mockOnlineManagerIsOnline(false) + let count = 0 + const states: Array = [] + + function Page() { + const mutation = useMutation({ + mutationFn: async (_text: string) => { + count++ + await sleep(10) + return count + }, + }) + + states.push(`${mutation.status}, ${mutation.isPaused}`) + + return ( +
+ +
+ data: {mutation.data ?? 'null'}, status: {mutation.status}, + isPaused: {String(mutation.isPaused)} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect( + rendered.getByText('data: null, status: idle, isPaused: false'), + ).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + + await vi.advanceTimersByTimeAsync(0) + expect( + rendered.getByText('data: null, status: pending, isPaused: true'), + ).toBeInTheDocument() + + // no intermediate 'pending, false' state is expected because we don't start mutating! + expect(states[0]).toBe('idle, false') + expect(states[1]).toBe('pending, true') + + onlineMock.mockReturnValue(true) + queryClient.getMutationCache().resumePausedMutations() + + await vi.advanceTimersByTimeAsync(11) + expect( + rendered.getByText('data: 1, status: success, isPaused: false'), + ).toBeInTheDocument() + + onlineMock.mockRestore() + }) + + it('should be able to retry a mutation when online', async () => { + const onlineMock = mockOnlineManagerIsOnline(false) + const key = queryKey() + + let count = 0 + + function Page() { + const state = useMutation({ + mutationKey: key, + mutationFn: async (_text: string) => { + await sleep(10) + count++ + return count > 1 + ? Promise.resolve(`data${count}`) + : Promise.reject(new Error('oops')) + }, + retry: 1, + retryDelay: 5, + networkMode: 'offlineFirst', + }) + + return ( +
+ +
status: {state.status}
+
isPaused: {String(state.isPaused)}
+
data: {state.data ?? 'null'}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('status: idle')).toBeInTheDocument() + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(16) + expect(rendered.getByText('isPaused: true')).toBeInTheDocument() + + expect( + queryClient.getMutationCache().findAll({ mutationKey: key }).length, + ).toBe(1) + expect( + queryClient.getMutationCache().findAll({ mutationKey: key })[0]?.state, + ).toMatchObject({ + status: 'pending', + isPaused: true, + failureCount: 1, + failureReason: new Error('oops'), + }) + + onlineMock.mockReturnValue(true) + queryClient.getMutationCache().resumePausedMutations() + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: data2')).toBeInTheDocument() + + expect( + queryClient.getMutationCache().findAll({ mutationKey: key })[0]?.state, + ).toMatchObject({ + status: 'success', + isPaused: false, + failureCount: 0, + failureReason: null, + data: 'data2', + }) + + onlineMock.mockRestore() + }) + + // eslint-disable-next-line vitest/expect-expect + it('should not change state if unmounted', () => { + function Mutates() { + const { mutate } = useMutation({ mutationFn: () => sleep(10) }) + return + } + function Page() { + const [mounted, setMounted] = React.useState(true) + return ( +
+ + {mounted && } +
+ ) + } + + const { getByText } = renderWithClient(queryClient, ) + fireEvent.click(getByText('mutate')) + fireEvent.click(getByText('unmount')) + }) + + it('should be able to throw an error when throwOnError is set to true', async () => { + const err = new Error('Expected mock error. All is well!') + err.stack = '' + + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + function Page() { + const { mutate } = useMutation({ + mutationFn: () => { + return Promise.reject(err) + }, + throwOnError: true, + }) + + return ( +
+ +
+ ) + } + + const { getByText, queryByText } = renderWithClient( + queryClient, + ( +
+ error +
+ )} + > + +
, + ) + + fireEvent.click(getByText('mutate')) + + await vi.advanceTimersByTimeAsync(0) + expect(queryByText('error')).not.toBeNull() + + expect(consoleMock.mock.calls[0]?.[1]).toBe(err) + + consoleMock.mockRestore() + }) + + it('should be able to throw an error when throwOnError is a function that returns true', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + let boundary = false + function Page() { + const { mutate, error } = useMutation({ + mutationFn: () => { + const err = new Error('mock error') + err.stack = '' + return Promise.reject(err) + }, + throwOnError: () => { + return boundary + }, + }) + + return ( +
+ + {error && error.message} +
+ ) + } + + const { getByText, queryByText } = renderWithClient( + queryClient, + ( +
+ error boundary +
+ )} + > + +
, + ) + + // first error goes to component + fireEvent.click(getByText('mutate')) + await vi.advanceTimersByTimeAsync(0) + expect(queryByText('mock error')).not.toBeNull() + + // second error goes to boundary + boundary = true + fireEvent.click(getByText('mutate')) + await vi.advanceTimersByTimeAsync(0) + expect(queryByText('error boundary')).not.toBeNull() + consoleMock.mockRestore() + }) + + it('should pass meta to mutation', async () => { + const errorMock = vi.fn() + const successMock = vi.fn() + + const queryClientMutationMeta = new QueryClient({ + mutationCache: new MutationCache({ + onSuccess: (_, __, ___, mutation) => { + successMock(mutation.meta?.metaSuccessMessage) + }, + onError: (_, __, ___, mutation) => { + errorMock(mutation.meta?.metaErrorMessage) + }, + }), + }) + + const metaSuccessMessage = 'mutation succeeded' + const metaErrorMessage = 'mutation failed' + + function Page() { + const { mutate: succeed, isSuccess } = useMutation({ + mutationFn: () => Promise.resolve(''), + meta: { metaSuccessMessage }, + }) + const { mutate: error, isError } = useMutation({ + mutationFn: () => { + return Promise.reject(new Error('')) + }, + meta: { metaErrorMessage }, + }) + + return ( +
+ + + {isSuccess &&
successTest
} + {isError &&
errorTest
} +
+ ) + } + + const { getByText, queryByText } = renderWithClient( + queryClientMutationMeta, + , + ) + + fireEvent.click(getByText('succeed')) + fireEvent.click(getByText('error')) + + await vi.advanceTimersByTimeAsync(0) + expect(queryByText('successTest')).not.toBeNull() + expect(queryByText('errorTest')).not.toBeNull() + + expect(successMock).toHaveBeenCalledTimes(1) + expect(successMock).toHaveBeenCalledWith(metaSuccessMessage) + expect(errorMock).toHaveBeenCalledTimes(1) + expect(errorMock).toHaveBeenCalledWith(metaErrorMessage) + }) + + it('should call cache callbacks when unmounted', async () => { + const onSuccess = vi.fn() + const onSuccessMutate = vi.fn() + const onSettled = vi.fn() + const onSettledMutate = vi.fn() + const mutationKey = queryKey() + let count = 0 + + function Page() { + const [show, setShow] = React.useState(true) + return ( +
+ + {show && } +
+ ) + } + + function Component() { + const mutation = useMutation({ + mutationFn: async (_text: string) => { + count++ + await sleep(10) + return count + }, + mutationKey, + gcTime: 0, + onSuccess, + onSettled, + }) + + return ( +
+ +
+ data: {mutation.data ?? 'null'}, status: {mutation.status}, + isPaused: {String(mutation.isPaused)} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect( + rendered.getByText('data: null, status: idle, isPaused: false'), + ).toBeInTheDocument() + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + fireEvent.click(rendered.getByRole('button', { name: /hide/i })) + + await vi.advanceTimersByTimeAsync(10) + expect( + queryClient.getMutationCache().findAll({ mutationKey }), + ).toHaveLength(0) + + expect(count).toBe(1) + + expect(onSuccess).toHaveBeenCalledTimes(1) + expect(onSettled).toHaveBeenCalledTimes(1) + expect(onSuccessMutate).toHaveBeenCalledTimes(0) + expect(onSettledMutate).toHaveBeenCalledTimes(0) + }) + + it('should call mutate callbacks only for the last observer', async () => { + const onSuccess = vi.fn() + const onSuccessMutate = vi.fn() + const onSettled = vi.fn() + const onSettledMutate = vi.fn() + let count = 0 + + function Page() { + const mutation = useMutation({ + mutationFn: async (text: string) => { + count++ + const result = `result-${text}` + await sleep(10) + return result + }, + onSuccess, + onSettled, + }) + + return ( +
+ + +
+ data: {mutation.data ?? 'null'}, status: {mutation.status} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('data: null, status: idle')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) + fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + + await vi.advanceTimersByTimeAsync(11) + expect( + rendered.getByText('data: result-todo2, status: success'), + ).toBeInTheDocument() + + expect(count).toBe(2) + + expect(onSuccess).toHaveBeenCalledTimes(2) + expect(onSuccess).toHaveBeenNthCalledWith( + 1, + 'result-todo1', + 'todo1', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + expect(onSuccess).toHaveBeenNthCalledWith( + 2, + 'result-todo2', + 'todo2', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + expect(onSettled).toHaveBeenCalledTimes(2) + expect(onSuccessMutate).toHaveBeenCalledTimes(1) + expect(onSuccessMutate).toHaveBeenCalledWith( + 'result-todo2', + 'todo2', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + expect(onSettledMutate).toHaveBeenCalledTimes(1) + expect(onSettledMutate).toHaveBeenCalledWith( + 'result-todo2', + null, + 'todo2', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + }) + + it('should go to error state if onSuccess callback errors', async () => { + const error = new Error('error from onSuccess') + const onError = vi.fn() + + function Page() { + const mutation = useMutation({ + mutationFn: async (_text: string) => { + await sleep(10) + return 'result' + }, + onSuccess: () => Promise.reject(error), + onError, + }) + + return ( +
+ +
status: {mutation.status}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('status: idle')).toBeInTheDocument() + + rendered.getByRole('button', { name: /mutate/i }).click() + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('status: error')).toBeInTheDocument() + + expect(onError).toHaveBeenCalledWith(error, 'todo', undefined, { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }) + }) + + it('should go to error state if onError callback errors', async () => { + const error = new Error('error from onError') + const mutateFnError = new Error('mutateFnError') + + function Page() { + const mutation = useMutation({ + mutationFn: async (_text: string) => { + await sleep(10) + throw mutateFnError + }, + onError: () => Promise.reject(error), + }) + + return ( +
+ +
+ error:{' '} + {mutation.error instanceof Error ? mutation.error.message : 'null'}, + status: {mutation.status} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('error: null, status: idle')).toBeInTheDocument() + + rendered.getByRole('button', { name: /mutate/i }).click() + + await vi.advanceTimersByTimeAsync(11) + expect( + rendered.getByText('error: mutateFnError, status: error'), + ).toBeInTheDocument() + }) + + it('should go to error state if onSettled callback errors', async () => { + const error = new Error('error from onSettled') + const mutateFnError = new Error('mutateFnError') + const onError = vi.fn() + + function Page() { + const mutation = useMutation({ + mutationFn: async (_text: string) => { + await sleep(10) + throw mutateFnError + }, + onSettled: () => Promise.reject(error), + onError, + }) + + return ( +
+ +
+ error:{' '} + {mutation.error instanceof Error ? mutation.error.message : 'null'}, + status: {mutation.status} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('error: null, status: idle')).toBeInTheDocument() + + rendered.getByRole('button', { name: /mutate/i }).click() + + await vi.advanceTimersByTimeAsync(11) + expect( + rendered.getByText('error: mutateFnError, status: error'), + ).toBeInTheDocument() + expect(onError).toHaveBeenCalledWith(mutateFnError, 'todo', undefined, { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }) + }) + + it('should use provided custom queryClient', async () => { + function Page() { + const mutation = useMutation( + { + mutationFn: async (text: string) => { + return Promise.resolve(text) + }, + }, + queryClient, + ) + + return ( +
+ +
+ data: {mutation.data ?? 'null'}, status: {mutation.status} +
+
+ ) + } + + const rendered = render() + + expect(rendered.getByText('data: null, status: idle')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + + await vi.advanceTimersByTimeAsync(0) + expect( + rendered.getByText('data: custom client, status: success'), + ).toBeInTheDocument() + }) +}) diff --git a/packages/preact-query/src/__tests__/useMutationState.test-d.tsx b/packages/preact-query/src/__tests__/useMutationState.test-d.tsx new file mode 100644 index 0000000000..795995aa44 --- /dev/null +++ b/packages/preact-query/src/__tests__/useMutationState.test-d.tsx @@ -0,0 +1,23 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { useMutationState } from '../useMutationState' +import type { MutationState, MutationStatus } from '@tanstack/query-core' + +describe('useMutationState', () => { + it('should default to QueryState', () => { + const result = useMutationState({ + filters: { status: 'pending' }, + }) + + expectTypeOf(result).toEqualTypeOf< + Array> + >() + }) + it('should infer with select', () => { + const result = useMutationState({ + filters: { status: 'pending' }, + select: (mutation) => mutation.state.status, + }) + + expectTypeOf(result).toEqualTypeOf>() + }) +}) diff --git a/packages/preact-query/src/__tests__/useMutationState.test.tsx b/packages/preact-query/src/__tests__/useMutationState.test.tsx new file mode 100644 index 0000000000..e80b9c10f9 --- /dev/null +++ b/packages/preact-query/src/__tests__/useMutationState.test.tsx @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render } from '@testing-library/react' +import * as React from 'react' +import { sleep } from '@tanstack/query-test-utils' +import { QueryClient, useIsMutating, useMutation, useMutationState } from '..' +import { renderWithClient } from './utils' + +describe('useIsMutating', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should return the number of fetching mutations', async () => { + const isMutatingArray: Array = [] + const queryClient = new QueryClient() + + function IsMutating() { + const isMutating = useIsMutating() + + isMutatingArray.push(isMutating) + + return null + } + + function Mutations() { + const { mutate: mutate1 } = useMutation({ + mutationKey: ['mutation1'], + mutationFn: () => sleep(50).then(() => 'data'), + }) + const { mutate: mutate2 } = useMutation({ + mutationKey: ['mutation2'], + mutationFn: () => sleep(10).then(() => 'data'), + }) + + return ( +
+ + +
+ ) + } + + function Page() { + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) + await vi.advanceTimersByTimeAsync(10) + fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + + // we don't really care if this yields + // [ +0, 1, 2, +0 ] + // or + // [ +0, 1, 2, 1, +0 ] + // our batching strategy might yield different results + + await vi.advanceTimersByTimeAsync(41) + expect(isMutatingArray[0]).toEqual(0) + expect(isMutatingArray[1]).toEqual(1) + expect(isMutatingArray[2]).toEqual(2) + expect(isMutatingArray[3]).toEqual(1) + expect(isMutatingArray[4]).toEqual(0) + + expect(isMutatingArray).toEqual([0, 1, 2, 1, 0]) + }) + + it('should filter correctly by mutationKey', async () => { + const isMutatingArray: Array = [] + const queryClient = new QueryClient() + + function IsMutating() { + const isMutating = useIsMutating({ mutationKey: ['mutation1'] }) + isMutatingArray.push(isMutating) + return null + } + + function Page() { + const { mutate: mutate1 } = useMutation({ + mutationKey: ['mutation1'], + mutationFn: () => sleep(100).then(() => 'data'), + }) + const { mutate: mutate2 } = useMutation({ + mutationKey: ['mutation2'], + mutationFn: () => sleep(100).then(() => 'data'), + }) + + React.useEffect(() => { + mutate1() + mutate2() + }, [mutate1, mutate2]) + + return + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(101) + expect(isMutatingArray).toEqual([0, 1, 0]) + }) + + it('should filter correctly by predicate', async () => { + const isMutatingArray: Array = [] + const queryClient = new QueryClient() + + function IsMutating() { + const isMutating = useIsMutating({ + predicate: (mutation) => + mutation.options.mutationKey?.[0] === 'mutation1', + }) + isMutatingArray.push(isMutating) + return null + } + + function Page() { + const { mutate: mutate1 } = useMutation({ + mutationKey: ['mutation1'], + mutationFn: () => sleep(100).then(() => 'data'), + }) + const { mutate: mutate2 } = useMutation({ + mutationKey: ['mutation2'], + mutationFn: () => sleep(100).then(() => 'data'), + }) + + React.useEffect(() => { + mutate1() + mutate2() + }, [mutate1, mutate2]) + + return + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(101) + expect(isMutatingArray).toEqual([0, 1, 0]) + }) + + it('should use provided custom queryClient', async () => { + const queryClient = new QueryClient() + + function Page() { + const isMutating = useIsMutating({}, queryClient) + const { mutate } = useMutation( + { + mutationKey: ['mutation1'], + mutationFn: () => sleep(10).then(() => 'data'), + }, + queryClient, + ) + + React.useEffect(() => { + mutate() + }, [mutate]) + + return ( +
+
mutating: {isMutating}
+
+ ) + } + + const rendered = render() + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('mutating: 1')).toBeInTheDocument() + }) +}) + +describe('useMutationState', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should return variables after calling mutate', async () => { + const queryClient = new QueryClient() + const variables: Array> = [] + const mutationKey = ['mutation'] + + function Variables() { + variables.push( + useMutationState({ + filters: { mutationKey, status: 'pending' }, + select: (mutation) => mutation.state.variables, + }), + ) + + return null + } + + function Mutate() { + const { mutate, data } = useMutation({ + mutationKey, + mutationFn: (input: number) => sleep(150).then(() => 'data' + input), + }) + + return ( +
+ data: {data ?? 'null'} + +
+ ) + } + + function Page() { + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('data: null')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(151) + expect(rendered.getByText('data: data1')).toBeInTheDocument() + + expect(variables).toEqual([[], [1], []]) + }) +}) diff --git a/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test-d.tsx b/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test-d.tsx new file mode 100644 index 0000000000..03af450c93 --- /dev/null +++ b/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test-d.tsx @@ -0,0 +1,60 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest' +import { usePrefetchInfiniteQuery } from '..' + +describe('usePrefetchInfiniteQuery', () => { + it('should return nothing', () => { + const result = usePrefetchInfiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + initialPageParam: 1, + getNextPageParam: () => 1, + }) + + expectTypeOf(result).toEqualTypeOf() + }) + + it('should require initialPageParam and getNextPageParam', () => { + assertType( + // @ts-expect-error TS2345 + usePrefetchInfiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }), + ) + }) + + it('should not allow refetchInterval, enabled or throwOnError options', () => { + assertType( + usePrefetchInfiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + initialPageParam: 1, + getNextPageParam: () => 1, + // @ts-expect-error TS2353 + refetchInterval: 1000, + }), + ) + + assertType( + usePrefetchInfiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + initialPageParam: 1, + getNextPageParam: () => 1, + // @ts-expect-error TS2353 + enabled: true, + }), + ) + + assertType( + usePrefetchInfiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + initialPageParam: 1, + getNextPageParam: () => 1, + // @ts-expect-error TS2353 + throwOnError: true, + }), + ) + }) +}) diff --git a/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx b/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx new file mode 100644 index 0000000000..7d323ec7ea --- /dev/null +++ b/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import React from 'react' +import { act, fireEvent } from '@testing-library/react' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + usePrefetchInfiniteQuery, + useSuspenseInfiniteQuery, +} from '..' +import { renderWithClient } from './utils' +import type { InfiniteData, UseSuspenseInfiniteQueryOptions } from '..' +import type { Mock } from 'vitest' + +const generateInfiniteQueryOptions = ( + data: Array<{ data: string; currentPage: number; totalPages: number }>, +) => { + let currentPage = 0 + + return { + queryFn: vi + .fn<(...args: Array) => Promise<(typeof data)[number]>>() + .mockImplementation(async () => { + const currentPageData = data[currentPage] + if (!currentPageData) { + throw new Error('No data defined for page ' + currentPage) + } + + await sleep(10) + currentPage++ + + return currentPageData + }), + initialPageParam: 1, + getNextPageParam: (lastPage: (typeof data)[number]) => + lastPage.currentPage === lastPage.totalPages + ? undefined + : lastPage.currentPage + 1, + } +} + +describe('usePrefetchInfiniteQuery', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + + const Fallback = vi.fn().mockImplementation(() =>
Loading...
) + + function Suspended(props: { + queryOpts: UseSuspenseInfiniteQueryOptions< + T, + Error, + InfiniteData, + Array, + any + > + renderPage: (page: T) => React.JSX.Element + }) { + const state = useSuspenseInfiniteQuery(props.queryOpts) + + return ( +
+ {state.data.pages.map((page, index) => ( +
{props.renderPage(page)}
+ ))} + +
+ ) + } + + it('should prefetch an infinite query if query state does not exist', async () => { + const data = [ + { data: 'Do you fetch on render?', currentPage: 1, totalPages: 3 }, + { data: 'Or do you render as you fetch?', currentPage: 2, totalPages: 3 }, + { + data: 'Either way, Tanstack Query helps you!', + currentPage: 3, + totalPages: 3, + }, + ] + + const queryOpts = { + queryKey: queryKey(), + ...generateInfiniteQueryOptions(data), + } + + function App() { + usePrefetchInfiniteQuery({ ...queryOpts, pages: data.length }) + + return ( + }> +
data: {page.data}
} + /> +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await act(() => vi.advanceTimersByTimeAsync(30)) + rendered.getByText('data: Do you fetch on render?') + fireEvent.click(rendered.getByText('Next Page')) + expect( + rendered.getByText('data: Or do you render as you fetch?'), + ).toBeInTheDocument() + fireEvent.click(rendered.getByText('Next Page')) + expect( + rendered.getByText('data: Either way, Tanstack Query helps you!'), + ).toBeInTheDocument() + expect(Fallback).toHaveBeenCalledTimes(1) + expect(queryOpts.queryFn).toHaveBeenCalledTimes(3) + }) + + it('should not display fallback if the query cache is already populated', async () => { + const queryOpts = { + queryKey: queryKey(), + ...generateInfiniteQueryOptions([ + { data: 'Prefetch rocks!', currentPage: 1, totalPages: 3 }, + { data: 'No waterfalls, boy!', currentPage: 2, totalPages: 3 }, + { data: 'Tanstack Query #ftw', currentPage: 3, totalPages: 3 }, + ]), + } + + queryClient.prefetchInfiniteQuery({ ...queryOpts, pages: 3 }) + await vi.advanceTimersByTimeAsync(30) + ;(queryOpts.queryFn as Mock).mockClear() + + function App() { + usePrefetchInfiniteQuery(queryOpts) + + return ( + }> +
data: {page.data}
} + /> +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('data: Prefetch rocks!')).toBeInTheDocument() + fireEvent.click(rendered.getByText('Next Page')) + expect(rendered.getByText('data: No waterfalls, boy!')).toBeInTheDocument() + fireEvent.click(rendered.getByText('Next Page')) + expect(rendered.getByText('data: Tanstack Query #ftw')).toBeInTheDocument() + expect(queryOpts.queryFn).not.toHaveBeenCalled() + expect(Fallback).not.toHaveBeenCalled() + }) + + it('should not create an endless loop when using inside a suspense boundary', async () => { + const queryOpts = { + queryKey: queryKey(), + ...generateInfiniteQueryOptions([ + { data: 'Infinite Page 1', currentPage: 1, totalPages: 3 }, + { data: 'Infinite Page 2', currentPage: 1, totalPages: 3 }, + { data: 'Infinite Page 3', currentPage: 1, totalPages: 3 }, + ]), + } + + function Prefetch({ children }: { children: React.ReactNode }) { + usePrefetchInfiniteQuery(queryOpts) + return <>{children} + } + + function App() { + return ( + + +
data: {page.data}
} + /> +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await act(() => vi.advanceTimersByTimeAsync(10)) + rendered.getByText('data: Infinite Page 1') + fireEvent.click(rendered.getByText('Next Page')) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: Infinite Page 2')).toBeInTheDocument() + fireEvent.click(rendered.getByText('Next Page')) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: Infinite Page 3')).toBeInTheDocument() + expect(queryOpts.queryFn).toHaveBeenCalledTimes(3) + }) +}) diff --git a/packages/preact-query/src/__tests__/usePrefetchQuery.test-d.tsx b/packages/preact-query/src/__tests__/usePrefetchQuery.test-d.tsx new file mode 100644 index 0000000000..09dbaf18c1 --- /dev/null +++ b/packages/preact-query/src/__tests__/usePrefetchQuery.test-d.tsx @@ -0,0 +1,59 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest' +import { skipToken, usePrefetchQuery } from '..' + +describe('usePrefetchQuery', () => { + it('should return nothing', () => { + const result = usePrefetchQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }) + + expectTypeOf(result).toEqualTypeOf() + }) + + it('should not allow refetchInterval, enabled or throwOnError options', () => { + assertType( + usePrefetchQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + // @ts-expect-error TS2345 + refetchInterval: 1000, + }), + ) + + assertType( + usePrefetchQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + // @ts-expect-error TS2345 + enabled: true, + }), + ) + + assertType( + usePrefetchQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + // @ts-expect-error TS2345 + throwOnError: true, + }), + ) + }) + + it('should not allow skipToken in queryFn', () => { + assertType( + usePrefetchQuery({ + queryKey: ['key'], + // @ts-expect-error + queryFn: skipToken, + }), + ) + assertType( + usePrefetchQuery({ + queryKey: ['key'], + // @ts-expect-error + queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), + }), + ) + }) +}) diff --git a/packages/preact-query/src/__tests__/usePrefetchQuery.test.tsx b/packages/preact-query/src/__tests__/usePrefetchQuery.test.tsx new file mode 100644 index 0000000000..e196a2f620 --- /dev/null +++ b/packages/preact-query/src/__tests__/usePrefetchQuery.test.tsx @@ -0,0 +1,288 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import React from 'react' +import { act, fireEvent } from '@testing-library/react' +import { ErrorBoundary } from 'react-error-boundary' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + usePrefetchQuery, + useQueryErrorResetBoundary, + useSuspenseQuery, +} from '..' +import { renderWithClient } from './utils' + +import type { UseSuspenseQueryOptions } from '..' + +const generateQueryFn = (data: string) => + vi + .fn<(...args: Array) => Promise>() + .mockImplementation(async () => { + await sleep(10) + + return data + }) + +describe('usePrefetchQuery', () => { + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + function Suspended(props: { + queryOpts: UseSuspenseQueryOptions> + children?: React.ReactNode + }) { + const state = useSuspenseQuery(props.queryOpts) + + return ( +
+
data: {String(state.data)}
+ {props.children} +
+ ) + } + + it('should prefetch query if query state does not exist', async () => { + const queryOpts = { + queryKey: queryKey(), + queryFn: generateQueryFn('prefetchQuery'), + } + + const componentQueryOpts = { + ...queryOpts, + queryFn: generateQueryFn('useSuspenseQuery'), + } + + function App() { + usePrefetchQuery(queryOpts) + + return ( + + + + ) + } + + const rendered = renderWithClient(queryClient, ) + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: prefetchQuery')).toBeInTheDocument() + expect(queryOpts.queryFn).toHaveBeenCalledTimes(1) + }) + + it('should not prefetch query if query state exists', async () => { + const queryOpts = { + queryKey: queryKey(), + queryFn: generateQueryFn('The usePrefetchQuery hook is smart!'), + } + + function App() { + usePrefetchQuery(queryOpts) + + return ( + + + + ) + } + + queryClient.fetchQuery(queryOpts) + await vi.advanceTimersByTimeAsync(10) + queryOpts.queryFn.mockClear() + const rendered = renderWithClient(queryClient, ) + + expect(rendered.queryByText('fetching: true')).not.toBeInTheDocument() + expect( + rendered.getByText('data: The usePrefetchQuery hook is smart!'), + ).toBeInTheDocument() + expect(queryOpts.queryFn).not.toHaveBeenCalled() + }) + + it('should let errors fall through and not refetch failed queries', async () => { + const consoleMock = vi.spyOn(console, 'error') + consoleMock.mockImplementation(() => undefined) + const queryFn = generateQueryFn('Not an error') + + const queryOpts = { + queryKey: queryKey(), + queryFn, + } + + queryFn.mockImplementationOnce(async () => { + await sleep(10) + + throw new Error('Oops! Server error!') + }) + + function App() { + usePrefetchQuery(queryOpts) + + return ( +
Oops!
}> + + + +
+ ) + } + + queryClient.prefetchQuery(queryOpts) + await vi.advanceTimersByTimeAsync(10) + queryFn.mockClear() + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('Oops!')).toBeInTheDocument() + expect(rendered.queryByText('data: Not an error')).not.toBeInTheDocument() + expect(queryOpts.queryFn).not.toHaveBeenCalled() + + consoleMock.mockRestore() + }) + + it('should not create an endless loop when using inside a suspense boundary', async () => { + const queryFn = generateQueryFn('prefetchedQuery') + + const queryOpts = { + queryKey: queryKey(), + queryFn, + } + + function Prefetch({ children }: { children: React.ReactNode }) { + usePrefetchQuery(queryOpts) + return <>{children} + } + + function App() { + return ( + + + + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: prefetchedQuery')).toBeInTheDocument() + expect(queryOpts.queryFn).toHaveBeenCalledTimes(1) + }) + + it('should be able to recover from errors and try fetching again', async () => { + const consoleMock = vi.spyOn(console, 'error') + consoleMock.mockImplementation(() => undefined) + const queryFn = generateQueryFn('This is fine :dog: :fire:') + + const queryOpts = { + queryKey: queryKey(), + queryFn, + } + + queryFn.mockImplementationOnce(async () => { + await sleep(10) + + throw new Error('Oops! Server error!') + }) + + function App() { + const { reset } = useQueryErrorResetBoundary() + usePrefetchQuery(queryOpts) + + return ( + ( +
+
Oops!
+ +
+ )} + > + + + +
+ ) + } + + queryClient.prefetchQuery(queryOpts) + await vi.advanceTimersByTimeAsync(10) + queryFn.mockClear() + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('Oops!')).toBeInTheDocument() + fireEvent.click(rendered.getByText('Try again')) + await act(() => vi.advanceTimersByTimeAsync(10)) + expect( + rendered.getByText('data: This is fine :dog: :fire:'), + ).toBeInTheDocument() + expect(queryOpts.queryFn).toHaveBeenCalledTimes(1) + consoleMock.mockRestore() + }) + + it('should not create a suspense waterfall if prefetch is fired', async () => { + const firstQueryOpts = { + queryKey: queryKey(), + queryFn: generateQueryFn('Prefetch is nice!'), + } + + const secondQueryOpts = { + queryKey: queryKey(), + queryFn: generateQueryFn('Prefetch is really nice!!'), + } + + const thirdQueryOpts = { + queryKey: queryKey(), + queryFn: generateQueryFn('Prefetch does not create waterfalls!!'), + } + + const Fallback = vi.fn().mockImplementation(() =>
Loading...
) + + function App() { + usePrefetchQuery(firstQueryOpts) + usePrefetchQuery(secondQueryOpts) + usePrefetchQuery(thirdQueryOpts) + + return ( + }> + + + + + + + ) + } + + const rendered = renderWithClient(queryClient, ) + expect( + queryClient.getQueryState(firstQueryOpts.queryKey)?.fetchStatus, + ).toBe('fetching') + expect( + queryClient.getQueryState(secondQueryOpts.queryKey)?.fetchStatus, + ).toBe('fetching') + expect( + queryClient.getQueryState(thirdQueryOpts.queryKey)?.fetchStatus, + ).toBe('fetching') + expect(rendered.getByText('Loading...')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: Prefetch is nice!')).toBeInTheDocument() + expect( + rendered.getByText('data: Prefetch is really nice!!'), + ).toBeInTheDocument() + expect( + rendered.getByText('data: Prefetch does not create waterfalls!!'), + ).toBeInTheDocument() + expect(Fallback).toHaveBeenCalledTimes(1) + expect(firstQueryOpts.queryFn).toHaveBeenCalledTimes(1) + expect(secondQueryOpts.queryFn).toHaveBeenCalledTimes(1) + expect(thirdQueryOpts.queryFn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/preact-query/src/__tests__/useQueries.test-d.tsx b/packages/preact-query/src/__tests__/useQueries.test-d.tsx new file mode 100644 index 0000000000..9aaeb45dc2 --- /dev/null +++ b/packages/preact-query/src/__tests__/useQueries.test-d.tsx @@ -0,0 +1,170 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { skipToken } from '..' +import { useQueries } from '../useQueries' +import { queryOptions } from '../queryOptions' +import type { OmitKeyof } from '..' +import type { UseQueryOptions, UseQueryResult } from '../types' + +describe('UseQueries config object overload', () => { + it('TData should always be defined when initialData is provided as an object', () => { + const query1 = { + queryKey: ['key1'], + queryFn: () => { + return { + wow: true, + } + }, + initialData: { + wow: false, + }, + } + + const query2 = { + queryKey: ['key2'], + queryFn: () => 'Query Data', + initialData: 'initial data', + } + + const query3 = { + queryKey: ['key2'], + queryFn: () => 'Query Data', + } + + const queryResults = useQueries({ queries: [query1, query2, query3] }) + + const query1Data = queryResults[0].data + const query2Data = queryResults[1].data + const query3Data = queryResults[2].data + + expectTypeOf(query1Data).toEqualTypeOf<{ wow: boolean }>() + expectTypeOf(query2Data).toEqualTypeOf() + expectTypeOf(query3Data).toEqualTypeOf() + }) + + it('TData should be defined when passed through queryOptions', () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: () => { + return { + wow: true, + } + }, + initialData: { + wow: true, + }, + }) + const queryResults = useQueries({ queries: [options] }) + + const data = queryResults[0].data + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() + }) + + it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQuery', () => { + const query1 = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + select: (data) => data > 1, + }) + + const query2 = { + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + select: (data: number) => data > 1, + } + + const queryResults = useQueries({ queries: [query1, query2] }) + const query1Data = queryResults[0].data + const query2Data = queryResults[1].data + + expectTypeOf(query1Data).toEqualTypeOf() + expectTypeOf(query2Data).toEqualTypeOf() + }) + + it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => { + const queryResults = useQueries({ + queries: [ + { + queryKey: ['key'], + queryFn: () => { + return { + wow: true, + } + }, + initialData: () => undefined as { wow: boolean } | undefined, + }, + ], + }) + + const data = queryResults[0].data + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() + }) + + describe('custom hook', () => { + it('should allow custom hooks using UseQueryOptions', () => { + type Data = string + + const useCustomQueries = ( + options?: OmitKeyof, 'queryKey' | 'queryFn'>, + ) => { + return useQueries({ + queries: [ + { + ...options, + queryKey: ['todos-key'], + queryFn: () => Promise.resolve('data'), + }, + ], + }) + } + + const queryResults = useCustomQueries() + const data = queryResults[0].data + + expectTypeOf(data).toEqualTypeOf() + }) + }) + + it('TData should have correct type when conditional skipToken is passed', () => { + const queryResults = useQueries({ + queries: [ + { + queryKey: ['withSkipToken'], + queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), + }, + ], + }) + + const firstResult = queryResults[0] + + expectTypeOf(firstResult).toEqualTypeOf>() + expectTypeOf(firstResult.data).toEqualTypeOf() + }) + + it('should return correct data for dynamic queries with mixed result types', () => { + const Queries1 = { + get: () => + queryOptions({ + queryKey: ['key1'], + queryFn: () => Promise.resolve(1), + }), + } + const Queries2 = { + get: () => + queryOptions({ + queryKey: ['key2'], + queryFn: () => Promise.resolve(true), + }), + } + + const queries1List = [1, 2, 3].map(() => ({ ...Queries1.get() })) + const result = useQueries({ + queries: [...queries1List, { ...Queries2.get() }], + }) + + expectTypeOf(result).toEqualTypeOf< + [...Array>, UseQueryResult] + >() + }) +}) diff --git a/packages/preact-query/src/__tests__/useQueries.test.tsx b/packages/preact-query/src/__tests__/useQueries.test.tsx new file mode 100644 index 0000000000..19fbe1f7c3 --- /dev/null +++ b/packages/preact-query/src/__tests__/useQueries.test.tsx @@ -0,0 +1,1814 @@ +import { + afterEach, + beforeEach, + describe, + expect, + expectTypeOf, + it, + vi, +} from 'vitest' +import { fireEvent, render } from '@testing-library/react' +import * as React from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + queryOptions, + skipToken, + useQueries, +} from '..' +import { renderWithClient } from './utils' +import type { + QueryFunction, + QueryKey, + QueryObserverResult, + UseQueryOptions, + UseQueryResult, +} from '..' +import type { QueryFunctionContext } from '@tanstack/query-core' + +describe('useQueries', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + + it('should return the correct states', async () => { + const key1 = queryKey() + const key2 = queryKey() + const results: Array> = [] + + function Page() { + const result = useQueries({ + queries: [ + { + queryKey: key1, + queryFn: async () => { + await sleep(10) + return 1 + }, + }, + { + queryKey: key2, + queryFn: async () => { + await sleep(200) + return 2 + }, + }, + ], + }) + results.push(result) + + return ( +
+
+ data1: {String(result[0].data ?? 'null')}, data2:{' '} + {String(result[1].data ?? 'null')} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(201) + expect(rendered.getByText('data1: 1, data2: 2')).toBeInTheDocument() + + expect(results.length).toBe(3) + expect(results[0]).toMatchObject([{ data: undefined }, { data: undefined }]) + expect(results[1]).toMatchObject([{ data: 1 }, { data: undefined }]) + expect(results[2]).toMatchObject([{ data: 1 }, { data: 2 }]) + }) + + it('should track results', async () => { + const key1 = queryKey() + const results: Array> = [] + let count = 0 + + function Page() { + const result = useQueries({ + queries: [ + { + queryKey: key1, + queryFn: async () => { + await sleep(10) + count++ + return count + }, + }, + ], + }) + results.push(result) + + return ( +
+
data: {String(result[0].data ?? 'null')}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 1')).toBeInTheDocument() + + expect(results.length).toBe(2) + expect(results[0]).toMatchObject([{ data: undefined }]) + expect(results[1]).toMatchObject([{ data: 1 }]) + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 2')).toBeInTheDocument() + + // only one render for data update, no render for isFetching transition + expect(results.length).toBe(3) + + expect(results[2]).toMatchObject([{ data: 2 }]) + }) + + it('handles type parameter - tuple of tuples', () => { + const key1 = queryKey() + const key2 = queryKey() + const key3 = queryKey() + + // @ts-expect-error (Page component is not rendered) + function Page() { + const result1 = useQueries< + [[number], [string], [Array, boolean]] + >({ + queries: [ + { + queryKey: key1, + queryFn: () => 1, + }, + { + queryKey: key2, + queryFn: () => 'string', + }, + { + queryKey: key3, + queryFn: () => ['string[]'], + }, + ], + }) + expectTypeOf(result1[0]).toEqualTypeOf>() + expectTypeOf(result1[1]).toEqualTypeOf>() + expectTypeOf(result1[2]).toEqualTypeOf< + UseQueryResult, boolean> + >() + expectTypeOf(result1[0].data).toEqualTypeOf() + expectTypeOf(result1[1].data).toEqualTypeOf() + expectTypeOf(result1[2].data).toEqualTypeOf | undefined>() + expectTypeOf(result1[2].error).toEqualTypeOf() + + // TData (3rd element) takes precedence over TQueryFnData (1st element) + const result2 = useQueries< + [[string, unknown, string], [string, unknown, number]] + >({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return a.toLowerCase() + }, + }, + { + queryKey: key2, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return parseInt(a) + }, + }, + ], + }) + expectTypeOf(result2[0]).toEqualTypeOf>() + expectTypeOf(result2[1]).toEqualTypeOf>() + expectTypeOf(result2[0].data).toEqualTypeOf() + expectTypeOf(result2[1].data).toEqualTypeOf() + + // types should be enforced + useQueries<[[string, unknown, string], [string, boolean, number]]>({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return a.toLowerCase() + }, + placeholderData: 'string', + // @ts-expect-error (initialData: string) + initialData: 123, + }, + { + queryKey: key2, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return parseInt(a) + }, + placeholderData: 'string', + // @ts-expect-error (initialData: string) + initialData: 123, + }, + ], + }) + + // field names should be enforced + useQueries<[[string]]>({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + // @ts-expect-error (invalidField) + someInvalidField: [], + }, + ], + }) + } + }) + + it('handles type parameter - tuple of objects', () => { + const key1 = queryKey() + const key2 = queryKey() + const key3 = queryKey() + + // @ts-expect-error (Page component is not rendered) + function Page() { + const result1 = useQueries< + [ + { queryFnData: number }, + { queryFnData: string }, + { queryFnData: Array; error: boolean }, + ] + >({ + queries: [ + { + queryKey: key1, + queryFn: () => 1, + }, + { + queryKey: key2, + queryFn: () => 'string', + }, + { + queryKey: key3, + queryFn: () => ['string[]'], + }, + ], + }) + expectTypeOf(result1[0]).toEqualTypeOf>() + expectTypeOf(result1[1]).toEqualTypeOf>() + expectTypeOf(result1[2]).toEqualTypeOf< + UseQueryResult, boolean> + >() + expectTypeOf(result1[0].data).toEqualTypeOf() + expectTypeOf(result1[1].data).toEqualTypeOf() + expectTypeOf(result1[2].data).toEqualTypeOf | undefined>() + expectTypeOf(result1[2].error).toEqualTypeOf() + + // TData (data prop) takes precedence over TQueryFnData (queryFnData prop) + const result2 = useQueries< + [ + { queryFnData: string; data: string }, + { queryFnData: string; data: number }, + ] + >({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return a.toLowerCase() + }, + }, + { + queryKey: key2, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return parseInt(a) + }, + }, + ], + }) + expectTypeOf(result2[0]).toEqualTypeOf>() + expectTypeOf(result2[1]).toEqualTypeOf>() + expectTypeOf(result2[0].data).toEqualTypeOf() + expectTypeOf(result2[1].data).toEqualTypeOf() + + // can pass only TData (data prop) although TQueryFnData will be left unknown + const result3 = useQueries<[{ data: string }, { data: number }]>({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return a as string + }, + }, + { + queryKey: key2, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return a as number + }, + }, + ], + }) + expectTypeOf(result3[0]).toEqualTypeOf>() + expectTypeOf(result3[1]).toEqualTypeOf>() + expectTypeOf(result3[0].data).toEqualTypeOf() + expectTypeOf(result3[1].data).toEqualTypeOf() + + // types should be enforced + useQueries< + [ + { queryFnData: string; data: string }, + { queryFnData: string; data: number; error: boolean }, + ] + >({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return a.toLowerCase() + }, + placeholderData: 'string', + // @ts-expect-error (initialData: string) + initialData: 123, + }, + { + queryKey: key2, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return parseInt(a) + }, + placeholderData: 'string', + // @ts-expect-error (initialData: string) + initialData: 123, + }, + ], + }) + + // field names should be enforced + useQueries<[{ queryFnData: string }]>({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + // @ts-expect-error (invalidField) + someInvalidField: [], + }, + ], + }) + } + }) + + it('correctly returns types when passing through queryOptions', () => { + // @ts-expect-error (Page component is not rendered) + function Page() { + // data and results types are correct when using queryOptions + const result4 = useQueries({ + queries: [ + queryOptions({ + queryKey: ['key1'], + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return a.toLowerCase() + }, + }), + queryOptions({ + queryKey: ['key2'], + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return parseInt(a) + }, + }), + ], + }) + expectTypeOf(result4[0]).toEqualTypeOf>() + expectTypeOf(result4[1]).toEqualTypeOf>() + expectTypeOf(result4[0].data).toEqualTypeOf() + expectTypeOf(result4[1].data).toEqualTypeOf() + } + }) + + it('handles array literal without type parameter to infer result type', () => { + const key1 = queryKey() + const key2 = queryKey() + const key3 = queryKey() + const key4 = queryKey() + const key5 = queryKey() + + type BizError = { code: number } + const throwOnError = (_error: BizError) => true + + // @ts-expect-error (Page component is not rendered) + function Page() { + // Array.map preserves TQueryFnData + const result1 = useQueries({ + queries: Array(50).map((_, i) => ({ + queryKey: ['key', i] as const, + queryFn: () => i + 10, + })), + }) + expectTypeOf(result1).toEqualTypeOf< + Array> + >() + if (result1[0]) { + expectTypeOf(result1[0].data).toEqualTypeOf() + } + + // Array.map preserves TError + const result1_err = useQueries({ + queries: Array(50).map((_, i) => ({ + queryKey: ['key', i] as const, + queryFn: () => i + 10, + throwOnError, + })), + }) + expectTypeOf(result1_err).toEqualTypeOf< + Array> + >() + if (result1_err[0]) { + expectTypeOf(result1_err[0].data).toEqualTypeOf() + expectTypeOf(result1_err[0].error).toEqualTypeOf() + } + + // Array.map preserves TData + const result2 = useQueries({ + queries: Array(50).map((_, i) => ({ + queryKey: ['key', i] as const, + queryFn: () => i + 10, + select: (data: number) => data.toString(), + })), + }) + expectTypeOf(result2).toEqualTypeOf< + Array> + >() + + const result2_err = useQueries({ + queries: Array(50).map((_, i) => ({ + queryKey: ['key', i] as const, + queryFn: () => i + 10, + select: (data: number) => data.toString(), + throwOnError, + })), + }) + expectTypeOf(result2_err).toEqualTypeOf< + Array> + >() + + const result3 = useQueries({ + queries: [ + { + queryKey: key1, + queryFn: () => 1, + }, + { + queryKey: key2, + queryFn: () => 'string', + }, + { + queryKey: key3, + queryFn: () => ['string[]'], + select: () => 123, + }, + { + queryKey: key5, + queryFn: () => 'string', + throwOnError, + }, + ], + }) + expectTypeOf(result3[0]).toEqualTypeOf>() + expectTypeOf(result3[1]).toEqualTypeOf>() + expectTypeOf(result3[2]).toEqualTypeOf>() + expectTypeOf(result3[0].data).toEqualTypeOf() + expectTypeOf(result3[1].data).toEqualTypeOf() + expectTypeOf(result3[3].data).toEqualTypeOf() + // select takes precedence over queryFn + expectTypeOf(result3[2].data).toEqualTypeOf() + // infer TError from throwOnError + expectTypeOf(result3[3].error).toEqualTypeOf() + + // initialData/placeholderData are enforced + useQueries({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + placeholderData: 'string', + // @ts-expect-error (initialData: string) + initialData: 123, + }, + { + queryKey: key2, + queryFn: () => 123, + // @ts-expect-error (placeholderData: number) + placeholderData: 'string', + initialData: 123, + }, + ], + }) + + // select and throwOnError params are "indirectly" enforced + useQueries({ + queries: [ + // unfortunately TS will not suggest the type for you + { + queryKey: key1, + queryFn: () => 'string', + }, + // however you can add a type to the callback + { + queryKey: key2, + queryFn: () => 'string', + }, + // the type you do pass is enforced + { + queryKey: key3, + queryFn: () => 'string', + }, + { + queryKey: key4, + queryFn: () => 'string', + select: (a: string) => parseInt(a), + }, + { + queryKey: key5, + queryFn: () => 'string', + throwOnError, + }, + ], + }) + + // callbacks are also indirectly enforced with Array.map + useQueries({ + queries: Array(50).map((_, i) => ({ + queryKey: ['key', i] as const, + queryFn: () => i + 10, + select: (data: number) => data.toString(), + })), + }) + useQueries({ + queries: Array(50).map((_, i) => ({ + queryKey: ['key', i] as const, + queryFn: () => i + 10, + select: (data: number) => data.toString(), + })), + }) + + // results inference works when all the handlers are defined + const result4 = useQueries({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + }, + { + queryKey: key2, + queryFn: () => 'string', + }, + { + queryKey: key4, + queryFn: () => 'string', + select: (a: string) => parseInt(a), + }, + { + queryKey: key5, + queryFn: () => 'string', + select: (a: string) => parseInt(a), + throwOnError, + }, + ], + }) + expectTypeOf(result4[0]).toEqualTypeOf>() + expectTypeOf(result4[1]).toEqualTypeOf>() + expectTypeOf(result4[2]).toEqualTypeOf>() + expectTypeOf(result4[3]).toEqualTypeOf>() + + // handles when queryFn returns a Promise + const result5 = useQueries({ + queries: [ + { + queryKey: key1, + queryFn: () => Promise.resolve('string'), + }, + ], + }) + expectTypeOf(result5[0]).toEqualTypeOf>() + + // Array as const does not throw error + const result6 = useQueries({ + queries: [ + { + queryKey: ['key1'], + queryFn: () => 'string', + }, + { + queryKey: ['key1'], + queryFn: () => 123, + }, + { + queryKey: key5, + queryFn: () => 'string', + throwOnError, + }, + ], + } as const) + expectTypeOf(result6[0]).toEqualTypeOf>() + expectTypeOf(result6[1]).toEqualTypeOf>() + expectTypeOf(result6[2]).toEqualTypeOf>() + + // field names should be enforced - array literal + useQueries({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + // @ts-expect-error (invalidField) + someInvalidField: [], + }, + ], + }) + + // field names should be enforced - Array.map() result + useQueries({ + // @ts-expect-error (invalidField) + queries: Array(10).map(() => ({ + someInvalidField: '', + })), + }) + + // field names should be enforced - array literal + useQueries({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + // @ts-expect-error (invalidField) + someInvalidField: [], + }, + ], + }) + + // supports queryFn using fetch() to return Promise - Array.map() result + useQueries({ + queries: Array(50).map((_, i) => ({ + queryKey: ['key', i] as const, + queryFn: () => + fetch('return Promise').then((resp) => resp.json()), + })), + }) + + // supports queryFn using fetch() to return Promise - array literal + useQueries({ + queries: [ + { + queryKey: key1, + queryFn: () => + fetch('return Promise').then((resp) => resp.json()), + }, + ], + }) + } + }) + + it('handles strongly typed queryFn factories and useQueries wrappers', () => { + // QueryKey + queryFn factory + type QueryKeyA = ['queryA'] + const getQueryKeyA = (): QueryKeyA => ['queryA'] + type GetQueryFunctionA = () => QueryFunction + const getQueryFunctionA: GetQueryFunctionA = () => () => { + return Promise.resolve(1) + } + type SelectorA = (data: number) => [number, string] + const getSelectorA = (): SelectorA => (data) => [data, data.toString()] + + type QueryKeyB = ['queryB', string] + const getQueryKeyB = (id: string): QueryKeyB => ['queryB', id] + type GetQueryFunctionB = () => QueryFunction + const getQueryFunctionB: GetQueryFunctionB = () => () => { + return Promise.resolve('1') + } + type SelectorB = (data: string) => [string, number] + const getSelectorB = (): SelectorB => (data) => [data, +data] + + // Wrapper with strongly typed array-parameter + function useWrappedQueries< + TQueryFnData, + TError, + TData, + TQueryKey extends QueryKey, + >(queries: Array>) { + return useQueries({ + queries: queries.map( + // no need to type the mapped query + (query) => { + const { queryFn: fn, queryKey: key } = query + expectTypeOf(fn).toEqualTypeOf< + | typeof skipToken + | QueryFunction + | undefined + >() + return { + queryKey: key, + queryFn: + fn && fn !== skipToken + ? (ctx: QueryFunctionContext) => { + // eslint-disable-next-line vitest/valid-expect + expectTypeOf(ctx.queryKey) + return fn.call({}, ctx) + } + : undefined, + } + }, + ), + }) + } + + // @ts-expect-error (Page component is not rendered) + function Page() { + const result = useQueries({ + queries: [ + { + queryKey: getQueryKeyA(), + queryFn: getQueryFunctionA(), + }, + { + queryKey: getQueryKeyB('id'), + queryFn: getQueryFunctionB(), + }, + ], + }) + expectTypeOf(result[0]).toEqualTypeOf>() + expectTypeOf(result[1]).toEqualTypeOf>() + + const withSelector = useQueries({ + queries: [ + { + queryKey: getQueryKeyA(), + queryFn: getQueryFunctionA(), + select: getSelectorA(), + }, + { + queryKey: getQueryKeyB('id'), + queryFn: getQueryFunctionB(), + select: getSelectorB(), + }, + ], + }) + expectTypeOf(withSelector[0]).toEqualTypeOf< + UseQueryResult<[number, string], Error> + >() + expectTypeOf(withSelector[1]).toEqualTypeOf< + UseQueryResult<[string, number], Error> + >() + + const withWrappedQueries = useWrappedQueries( + Array(10).map(() => ({ + queryKey: getQueryKeyA(), + queryFn: getQueryFunctionA(), + select: getSelectorA(), + })), + ) + + expectTypeOf(withWrappedQueries).toEqualTypeOf< + Array> + >() + } + }) + + it("should throw error if in one of queries' queryFn throws and throwOnError is in use", async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key1 = queryKey() + const key2 = queryKey() + const key3 = queryKey() + const key4 = queryKey() + + function Page() { + useQueries({ + queries: [ + { + queryKey: key1, + queryFn: () => + Promise.reject( + new Error( + 'this should not throw because throwOnError is not set', + ), + ), + }, + { + queryKey: key2, + queryFn: () => Promise.reject(new Error('single query error')), + throwOnError: true, + retry: false, + }, + { + queryKey: key3, + queryFn: () => Promise.resolve(2), + }, + { + queryKey: key4, + queryFn: async () => + Promise.reject( + new Error('this should not throw because query#2 already did'), + ), + throwOnError: true, + retry: false, + }, + ], + }) + + return null + } + + const rendered = renderWithClient( + queryClient, + ( +
+
error boundary
+
{error.message}
+
+ )} + > + +
, + ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('single query error')).toBeInTheDocument() + consoleMock.mockRestore() + }) + + it("should throw error if in one of queries' queryFn throws and throwOnError function resolves to true", async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key1 = queryKey() + const key2 = queryKey() + const key3 = queryKey() + const key4 = queryKey() + + function Page() { + useQueries({ + queries: [ + { + queryKey: key1, + queryFn: () => + Promise.reject( + new Error( + 'this should not throw because throwOnError function resolves to false', + ), + ), + throwOnError: () => false, + retry: false, + }, + { + queryKey: key2, + queryFn: () => Promise.resolve(2), + }, + { + queryKey: key3, + queryFn: () => Promise.reject(new Error('single query error')), + throwOnError: () => true, + retry: false, + }, + { + queryKey: key4, + queryFn: async () => + Promise.reject( + new Error('this should not throw because query#3 already did'), + ), + throwOnError: true, + retry: false, + }, + ], + }) + + return null + } + + const rendered = renderWithClient( + queryClient, + ( +
+
error boundary
+
{error.message}
+
+ )} + > + +
, + ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('single query error')).toBeInTheDocument() + consoleMock.mockRestore() + }) + + it('should use provided custom queryClient', async () => { + const key = queryKey() + const queryFn = async () => { + return Promise.resolve('custom client') + } + + function Page() { + const queries = useQueries( + { + queries: [ + { + queryKey: key, + queryFn, + }, + ], + }, + queryClient, + ) + + return
data: {queries[0].data}
+ } + + const rendered = render() + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('data: custom client')).toBeInTheDocument() + }) + + it('should combine queries', async () => { + const key1 = queryKey() + const key2 = queryKey() + + function Page() { + const queries = useQueries( + { + queries: [ + { + queryKey: key1, + queryFn: () => Promise.resolve('first result'), + }, + { + queryKey: key2, + queryFn: () => Promise.resolve('second result'), + }, + ], + combine: (results) => { + return { + combined: true, + res: results.map((res) => res.data).join(','), + } + }, + }, + queryClient, + ) + + return ( +
+
+ data: {String(queries.combined)} {queries.res} +
+
+ ) + } + + const rendered = render() + + await vi.advanceTimersByTimeAsync(0) + expect( + rendered.getByText('data: true first result,second result'), + ).toBeInTheDocument() + }) + + it('should not return new instances when called without queries', async () => { + const key = queryKey() + const ids: Array = [] + let resultChanged = 0 + + function Page() { + const [count, setCount] = React.useState(0) + const result = useQueries({ + queries: ids.map((id) => { + return { + queryKey: [key, id], + queryFn: () => { + return () => { + return Promise.resolve({ + id, + content: { value: Math.random() }, + }) + } + }, + } + }), + combine: () => ({ empty: 'object' }), + }) + + React.useEffect(() => { + resultChanged++ + }, [result]) + + return ( +
+
count: {count}
+
data: {JSON.stringify(result)}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('data: {"empty":"object"}')).toBeInTheDocument() + expect(rendered.getByText('count: 0')).toBeInTheDocument() + + expect(resultChanged).toBe(1) + + fireEvent.click(rendered.getByRole('button', { name: /inc/i })) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('count: 1')).toBeInTheDocument() + // there should be no further effect calls because the returned object is structurally shared + expect(resultChanged).toBe(1) + }) + + it('should not have infinite render loops with empty queries (#6645)', () => { + let renderCount = 0 + + function Page() { + const result = useQueries({ + queries: [], + }) + + React.useEffect(() => { + renderCount++ + }) + + return
data: {JSON.stringify(result)}
+ } + + renderWithClient(queryClient, ) + + expect(renderCount).toBe(1) + }) + + it('should only call combine with query results', async () => { + const key1 = queryKey() + const key2 = queryKey() + + function Page() { + const result = useQueries({ + queries: [ + { + queryKey: key1, + queryFn: async () => { + await sleep(5) + return Promise.resolve('query1') + }, + }, + { + queryKey: key2, + queryFn: async () => { + await sleep(20) + return Promise.resolve('query2') + }, + }, + ], + combine: ([query1, query2]) => { + return { + data: { query1: query1.data, query2: query2.data }, + } + }, + }) + + return
data: {JSON.stringify(result)}
+ } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(21) + expect( + rendered.getByText( + 'data: {"data":{"query1":"query1","query2":"query2"}}', + ), + ).toBeInTheDocument() + }) + + it('should track property access through combine function', async () => { + const key1 = queryKey() + const key2 = queryKey() + let count = 0 + const results: Array = [] + + function Page() { + const queries = useQueries( + { + queries: [ + { + queryKey: key1, + queryFn: async () => { + await sleep(5) + return Promise.resolve('first result ' + count) + }, + }, + { + queryKey: key2, + queryFn: async () => { + await sleep(50) + return Promise.resolve('second result ' + count) + }, + }, + ], + combine: (queryResults) => { + return { + combined: true, + refetch: () => queryResults.forEach((res) => res.refetch()), + res: queryResults + .flatMap((res) => (res.data ? [res.data] : [])) + .join(','), + } + }, + }, + queryClient, + ) + + results.push(queries) + + return ( +
+
+ data: {String(queries.combined)} {queries.res} +
+ +
+ ) + } + + const rendered = render() + + await vi.advanceTimersByTimeAsync(51) + expect( + rendered.getByText('data: true first result 0,second result 0'), + ).toBeInTheDocument() + + expect(results.length).toBe(3) + + expect(results[0]).toStrictEqual({ + combined: true, + refetch: expect.any(Function), + res: '', + }) + + expect(results[1]).toStrictEqual({ + combined: true, + refetch: expect.any(Function), + res: 'first result 0', + }) + + expect(results[2]).toStrictEqual({ + combined: true, + refetch: expect.any(Function), + res: 'first result 0,second result 0', + }) + + count++ + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + + await vi.advanceTimersByTimeAsync(51) + expect( + rendered.getByText('data: true first result 1,second result 1'), + ).toBeInTheDocument() + + const length = results.length + + expect([4, 5, 6]).toContain(results.length) + + expect(results[results.length - 1]).toStrictEqual({ + combined: true, + refetch: expect.any(Function), + res: 'first result 1,second result 1', + }) + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + + await vi.advanceTimersByTimeAsync(100) + // no further re-render because data didn't change + expect(results.length).toBe(length) + }) + + it('should synchronously track properties of all observer even if a property (isLoading) is only accessed on one observer (#7000)', async () => { + const key = queryKey() + const ids = [1, 2] + + function Page() { + const { isLoading } = useQueries({ + queries: ids.map((id) => ({ + queryKey: [key, id], + queryFn: () => + sleep(10).then(() => { + if (id === 2) throw new Error('FAILURE') + return { id, title: `Post ${id}` } + }), + retry: false, + })), + combine: (results) => { + // this tracks data on all observers + void results.forEach((result) => result.data) + return { + // .some aborts early, so `isLoading` might not be accessed (and thus tracked) on all observers + // leading to missing re-renders + isLoading: results.some((result) => result.isLoading), + } + }, + }) + + return ( +
+

Loading Status: {isLoading ? 'Loading...' : 'Loaded'}

+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('Loading Status: Loading...')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('Loading Status: Loaded')).toBeInTheDocument() + }) + + it('should not have stale closures with combine (#6648)', async () => { + const key = queryKey() + + function Page() { + const [count, setCount] = React.useState(0) + const queries = useQueries( + { + queries: [ + { + queryKey: key, + queryFn: () => Promise.resolve('result'), + }, + ], + combine: (results) => { + return { + count, + res: results.map((res) => res.data).join(','), + } + }, + }, + queryClient, + ) + + return ( +
+
+ data: {String(queries.count)} {queries.res} +
+ +
+ ) + } + + const rendered = render() + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('data: 0 result')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /inc/i })) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('data: 1 result')).toBeInTheDocument() + }) + + it('should optimize combine if it is a stable reference', async () => { + const key1 = queryKey() + const key2 = queryKey() + + const client = new QueryClient() + + const spy = vi.fn() + let value = 0 + + function Page() { + const [state, setState] = React.useState(0) + const queries = useQueries( + { + queries: [ + { + queryKey: key1, + queryFn: async () => { + await sleep(10) + return 'first result:' + value + }, + }, + { + queryKey: key2, + queryFn: async () => { + await sleep(20) + return 'second result:' + value + }, + }, + ], + combine: React.useCallback((results: Array) => { + const result = { + combined: true, + res: results.map((res) => res.data).join(','), + } + spy(result) + return result + }, []), + }, + client, + ) + + return ( +
+
+ data: {String(queries.combined)} {queries.res} +
+ +
+ ) + } + + const rendered = render() + + await vi.advanceTimersByTimeAsync(21) + expect( + rendered.getByText('data: true first result:0,second result:0'), + ).toBeInTheDocument() + + // both pending, one pending, both resolved + expect(spy).toHaveBeenCalledTimes(3) + + client.refetchQueries() + + await vi.advanceTimersByTimeAsync(21) + // no increase because result hasn't changed + expect(spy).toHaveBeenCalledTimes(3) + + fireEvent.click(rendered.getByRole('button', { name: /rerender/i })) + + // one extra call due to recomputing the combined result on rerender + expect(spy).toHaveBeenCalledTimes(4) + + value = 1 + + client.refetchQueries() + + await vi.advanceTimersByTimeAsync(21) + expect( + rendered.getByText('data: true first result:1,second result:1'), + ).toBeInTheDocument() + + // refetch with new values triggers: both pending -> one pending -> both resolved + expect(spy).toHaveBeenCalledTimes(7) + }) + + it('should re-run combine if the functional reference changes', async () => { + const key1 = queryKey() + const key2 = queryKey() + + const client = new QueryClient() + + const spy = vi.fn() + + function Page() { + const [state, setState] = React.useState(0) + const queries = useQueries( + { + queries: [ + { + queryKey: [key1], + queryFn: async () => { + await sleep(10) + return 'first result' + }, + }, + { + queryKey: [key2], + queryFn: async () => { + await sleep(20) + return 'second result' + }, + }, + ], + combine: React.useCallback( + (results: Array) => { + const result = { + combined: true, + state, + res: results.map((res) => res.data).join(','), + } + spy(result) + return result + }, + [state], + ), + }, + client, + ) + + return ( +
+
+ data: {String(queries.state)} {queries.res} +
+ +
+ ) + } + + const rendered = render() + + await vi.advanceTimersByTimeAsync(21) + expect( + rendered.getByText('data: 0 first result,second result'), + ).toBeInTheDocument() + + // both pending, one pending, both resolved + expect(spy).toHaveBeenCalledTimes(3) + + fireEvent.click(rendered.getByRole('button', { name: /rerender/i })) + + // state changed, re-run combine + expect(spy).toHaveBeenCalledTimes(4) + }) + + it('should not re-render if combine returns a stable reference', async () => { + const key1 = queryKey() + const key2 = queryKey() + + const client = new QueryClient() + + const queryFns: Array = [] + let renders = 0 + + function Page() { + const data = useQueries( + { + queries: [ + { + queryKey: [key1], + queryFn: async () => { + await sleep(10) + queryFns.push('first result') + return 'first result' + }, + }, + { + queryKey: [key2], + queryFn: async () => { + await sleep(20) + queryFns.push('second result') + return 'second result' + }, + }, + ], + combine: () => 'foo', + }, + client, + ) + + renders++ + + return ( +
+
data: {data}
+
+ ) + } + + const rendered = render() + + await vi.advanceTimersByTimeAsync(21) + expect(rendered.getByText('data: foo')).toBeInTheDocument() + + expect(queryFns).toEqual(['first result', 'second result']) + + expect(renders).toBe(1) + }) + + it('should re-render once combine returns a different reference', async () => { + const key1 = queryKey() + const key2 = queryKey() + const key3 = queryKey() + + const client = new QueryClient() + + let renders = 0 + + function Page() { + const data = useQueries( + { + queries: [ + { + queryKey: [key1], + queryFn: async () => { + await sleep(10) + return 'first result' + }, + }, + { + queryKey: [key2], + queryFn: async () => { + await sleep(15) + return 'second result' + }, + }, + { + queryKey: [key3], + queryFn: async () => { + await sleep(20) + return 'third result' + }, + }, + ], + combine: (results) => { + const isPending = results.some((res) => res.isPending) + + return isPending ? 'pending' : 'foo' + }, + }, + client, + ) + + renders++ + + return ( +
+
data: {data}
+
+ ) + } + + const rendered = render() + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('data: pending')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(21) + expect(rendered.getByText('data: foo')).toBeInTheDocument() + + // one with pending, one with foo + expect(renders).toBe(2) + }) + + it('should track properties correctly with combine', async () => { + const key1 = queryKey() + const key2 = queryKey() + const key3 = queryKey() + + const client = new QueryClient() + + function Page() { + const data = useQueries( + { + queries: [ + { + queryKey: [key1], + queryFn: async () => { + await sleep(10) + return 'first result' + }, + }, + { + queryKey: [key2], + queryFn: async () => { + await sleep(15) + return 'second result' + }, + }, + { + queryKey: [key3], + queryFn: async () => { + await sleep(20) + return 'third result' + }, + }, + ], + combine: (results) => { + if (results.find((r) => r.isPending)) { + return 'pending' + } + return results.map((r) => r.data).join(', ') + }, + }, + client, + ) + + return ( +
+
data: {data}
+ +
+ ) + } + + const rendered = render() + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('data: pending')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(21) + expect( + rendered.getByText('data: first result, second result, third result'), + ).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /update/i })) + + await vi.advanceTimersByTimeAsync(21) + expect( + rendered.getByText( + 'data: first result updated, second result, third result', + ), + ).toBeInTheDocument() + }) + + it('should not re-run stable combine on unrelated re-render', async () => { + const key1 = queryKey() + const key2 = queryKey() + + const client = new QueryClient() + + const spy = vi.fn() + + function Page() { + const [unrelatedState, setUnrelatedState] = React.useState(0) + + const queries = useQueries( + { + queries: [ + { + queryKey: key1, + queryFn: async () => { + await sleep(10) + return 'first result' + }, + }, + { + queryKey: key2, + queryFn: async () => { + await sleep(20) + return 'second result' + }, + }, + ], + combine: React.useCallback((results: Array) => { + const result = { + combined: true, + res: results.map((res) => res.data).join(','), + } + spy(result) + return result + }, []), + }, + client, + ) + + return ( +
+
+ data: {String(queries.combined)} {queries.res} +
+
unrelated: {unrelatedState}
+ +
+ ) + } + + const rendered = render() + + await vi.advanceTimersByTimeAsync(21) + expect( + rendered.getByText('data: true first result,second result'), + ).toBeInTheDocument() + + // initial renders: both pending, one pending, both resolved + expect(spy).toHaveBeenCalledTimes(3) + + fireEvent.click(rendered.getByRole('button', { name: /increment/i })) + + await vi.advanceTimersByTimeAsync(0) + + expect(rendered.getByText('unrelated: 1')).toBeInTheDocument() + + // combine should NOT re-run for unrelated re-render with stable reference + expect(spy).toHaveBeenCalledTimes(3) + + fireEvent.click(rendered.getByRole('button', { name: /increment/i })) + + await vi.advanceTimersByTimeAsync(0) + + expect(rendered.getByText('unrelated: 2')).toBeInTheDocument() + + // still no extra calls to combine + expect(spy).toHaveBeenCalledTimes(3) + }) + + it('should not cause infinite re-renders when removing last query', async () => { + let renderCount = 0 + + function Page() { + const [queries, setQueries] = React.useState([ + { + queryKey: ['query1'], + queryFn: () => 'data1', + }, + { + queryKey: ['query2'], + queryFn: () => 'data2', + }, + ]) + renderCount++ + + const result = useQueries({ queries }) + + return ( +
+
renders: {renderCount}
+
queries: {result.length}
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + renderCount = 0 + + fireEvent.click(rendered.getByRole('button', { name: /remove last/i })) + await vi.advanceTimersByTimeAsync(100) + + expect(renderCount).toBeLessThan(10) + expect(rendered.getByTestId('query-count').textContent).toBe('queries: 1') + + renderCount = 0 + + fireEvent.click(rendered.getByRole('button', { name: /remove first/i })) + await vi.advanceTimersByTimeAsync(100) + + expect(renderCount).toBeLessThan(10) + expect(rendered.getByTestId('query-count').textContent).toBe('queries: 1') + }) +}) diff --git a/packages/preact-query/src/__tests__/useQuery.promise.test.tsx b/packages/preact-query/src/__tests__/useQuery.promise.test.tsx new file mode 100644 index 0000000000..b6c4bba173 --- /dev/null +++ b/packages/preact-query/src/__tests__/useQuery.promise.test.tsx @@ -0,0 +1,1431 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import * as React from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { + createRenderStream, + useTrackRenders, +} from '@testing-library/react-render-stream' +import { queryKey } from '@tanstack/query-test-utils' +import { waitFor } from '@testing-library/react' +import { + QueryClient, + QueryClientProvider, + QueryErrorResetBoundary, + keepPreviousData, + useInfiniteQuery, + useQuery, +} from '..' +import { QueryCache } from '../index' + +describe('useQuery().promise', () => { + const queryCache = new QueryCache() + const queryClient = new QueryClient({ + queryCache, + }) + + beforeAll(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + queryClient.setDefaultOptions({ + queries: { experimental_prefetchInRender: true }, + }) + }) + afterAll(() => { + vi.useRealTimers() + queryClient.setDefaultOptions({ + queries: { experimental_prefetchInRender: false }, + }) + }) + + it('should work with a basic test', async () => { + const key = queryKey() + + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + useTrackRenders() + return <>{data} + } + + function Loading() { + useTrackRenders() + return <>loading.. + } + + function Page() { + useTrackRenders() + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await vi.advanceTimersByTimeAsync(1) + return 'test' + }, + }) + + return ( + }> +
+ +
+
status:{query.status}
+
+ ) + } + + await renderStream.render( + + + , + ) + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(renderedComponents).toEqual([Page, Loading]) + } + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + }) + + it('colocate suspense and promise', async () => { + const key = queryKey() + let callCount = 0 + + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent() { + useTrackRenders() + const query = useQuery({ + queryKey: key, + queryFn: async () => { + callCount++ + await vi.advanceTimersByTimeAsync(1) + return 'test' + }, + staleTime: 1000, + }) + const data = React.use(query.promise) + + return <>{data} + } + + function Loading() { + useTrackRenders() + return <>loading.. + } + function Page() { + useTrackRenders() + return ( + }> + + + ) + } + + await renderStream.render( + + + , + ) + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(renderedComponents).toEqual([Page, Loading]) + } + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test') + expect(renderedComponents).toEqual([MyComponent]) + } + + expect(callCount).toBe(1) + }) + + it('parallel queries', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + let callCount = 0 + + function MyComponent() { + useTrackRenders() + const query = useQuery({ + queryKey: key, + queryFn: async () => { + callCount++ + await vi.advanceTimersByTimeAsync(1) + return 'test' + }, + staleTime: 1000, + }) + const data = React.use(query.promise) + + return <>{data} + } + + function Loading() { + useTrackRenders() + return <>loading.. + } + function Page() { + useTrackRenders() + return ( + <> + }> + + + + + + + + + + ) + } + + await renderStream.render( + + + , + ) + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(renderedComponents).toEqual([Page, Loading]) + } + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('testtesttesttesttest') + expect(renderedComponents).toEqual([ + MyComponent, + MyComponent, + MyComponent, + MyComponent, + MyComponent, + ]) + } + + expect(callCount).toBe(1) + }) + + it('should work with initial data', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent(props: { promise: Promise }) { + useTrackRenders() + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + useTrackRenders() + + return <>loading.. + } + function Page() { + useTrackRenders() + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await vi.advanceTimersByTimeAsync(1) + return 'test' + }, + initialData: 'initial', + }) + + return ( + }> + + + ) + } + + await renderStream.render( + + + , + ) + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('initial') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + }) + + it('should not fetch with initial data and staleTime', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + const queryFn = vi.fn().mockImplementation(async () => { + await vi.advanceTimersByTimeAsync(1) + return 'test' + }) + + function MyComponent(props: { promise: Promise }) { + useTrackRenders() + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + useTrackRenders() + return <>loading.. + } + function Page() { + useTrackRenders() + const query = useQuery({ + queryKey: key, + queryFn, + initialData: 'initial', + staleTime: 1000, + }) + + return ( + }> + + + ) + } + + await renderStream.render( + + + , + ) + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('initial') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + + // should not call queryFn because of staleTime + initialData combo + expect(queryFn).toHaveBeenCalledTimes(0) + }) + + it('should work with static placeholderData', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent(props: { promise: Promise }) { + useTrackRenders() + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + useTrackRenders() + + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await vi.advanceTimersByTimeAsync(1) + return 'test' + }, + placeholderData: 'placeholder', + }) + useTrackRenders() + + return ( + }> + + + ) + } + + await renderStream.render( + + + , + ) + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('placeholder') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + }) + + it('should work with placeholderData: keepPreviousData', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent(props: { promise: Promise }) { + useTrackRenders() + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + useTrackRenders() + + return <>loading.. + } + function Page() { + useTrackRenders() + const [count, setCount] = React.useState(0) + const query = useQuery({ + queryKey: [...key, count], + queryFn: async () => { + await vi.advanceTimersByTimeAsync(1) + return 'test-' + count + }, + placeholderData: keepPreviousData, + }) + + return ( +
+ }> + + + +
+ ) + } + + const rendered = await renderStream.render( + + + , + ) + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(renderedComponents).toEqual([Page, Loading]) + } + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test-0') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + + rendered.getByRole('button', { name: 'increment' }).click() + + // re-render because of the increment + { + const { renderedComponents } = await renderStream.takeRender() + expect(renderedComponents).toEqual([Page, MyComponent]) + } + + // re-render with new data, no loading between + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test-1') + // no more suspense boundary rendering + expect(renderedComponents).toEqual([Page, MyComponent]) + } + }) + + it('should be possible to select a part of the data with select', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent(props: { promise: Promise }) { + useTrackRenders() + const data = React.use(props.promise) + return <>{data} + } + + function Loading() { + useTrackRenders() + return <>loading.. + } + + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await vi.advanceTimersByTimeAsync(1) + return { name: 'test' } + }, + select: (data) => data.name, + }) + + useTrackRenders() + return ( + }> + + + ) + } + + await renderStream.render( + + + , + ) + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(renderedComponents).toEqual([Page, Loading]) + } + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + }) + + it('should throw error if the promise fails', async () => { + const renderStream = createRenderStream({ snapshotDOM: true }) + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const key = queryKey() + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + + let queryCount = 0 + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await vi.advanceTimersByTimeAsync(1) + if (++queryCount > 1) { + // second time this query mounts, it should not throw + return 'data' + } + throw new Error('Error test') + }, + retry: false, + }) + + return ( + }> + + + ) + } + + const rendered = await renderStream.render( + + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + +
+ )} +
+
, + ) + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('error boundary') + } + + consoleMock.mockRestore() + + rendered.getByText('resetErrorBoundary').click() + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('data') + } + + expect(queryCount).toBe(2) + }) + + it('should throw error if the promise fails (colocate suspense and promise)', async () => { + const renderStream = createRenderStream({ snapshotDOM: true }) + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const key = queryKey() + + function MyComponent() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await vi.advanceTimersByTimeAsync(1) + throw new Error('Error test') + }, + retry: false, + }) + const data = React.use(query.promise) + + return <>{data} + } + + function Page() { + return ( + + + + ) + } + + await renderStream.render( + +
error boundary
}> + +
+
, + ) + + { + const { withinDOM } = await renderStream.takeRender() + expect(withinDOM().getByText('loading..')).toBeInTheDocument() + } + + { + const { withinDOM } = await renderStream.takeRender() + expect(withinDOM().getByText('error boundary')).toBeInTheDocument() + } + + consoleMock.mockRestore() + }) + + it('should recreate promise with data changes', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent(props: { promise: Promise }) { + useTrackRenders() + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + useTrackRenders() + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await vi.advanceTimersByTimeAsync(1) + return 'test1' + }, + }) + + useTrackRenders() + return ( + }> + + + ) + } + + await renderStream.render( + + + , + ) + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(renderedComponents).toEqual([Page, Loading]) + } + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test1') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + + queryClient.setQueryData(key, 'test2') + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test2') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + }) + + it('should dedupe when re-fetched with queryClient.fetchQuery while suspending', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + const queryFn = vi.fn().mockImplementation(async () => { + await vi.advanceTimersByTimeAsync(10) + return 'test' + }) + + const options = { + queryKey: key, + queryFn, + } + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const query = useQuery(options) + + return ( +
+ }> + + + +
+ ) + } + + const rendered = await renderStream.render( + + + , + ) + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } + + rendered.getByText('fetch').click() + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test') + } + + expect(queryFn).toHaveBeenCalledOnce() + }) + + it('should dedupe when re-fetched with refetchQueries while suspending', async () => { + const key = queryKey() + let count = 0 + const renderStream = createRenderStream({ snapshotDOM: true }) + const queryFn = vi.fn().mockImplementation(async () => { + await vi.advanceTimersByTimeAsync(10) + return 'test' + count++ + }) + + const options = { + queryKey: key, + queryFn, + } + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const query = useQuery(options) + + return ( +
+ }> + + + +
+ ) + } + + const rendered = await renderStream.render( + + + , + ) + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } + + rendered.getByText('refetch').click() + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test0') + } + + expect(queryFn).toHaveBeenCalledOnce() + }) + + it('should stay pending when canceled with cancelQueries while suspending until refetched', async () => { + const renderStream = createRenderStream({ snapshotDOM: true }) + const key = queryKey() + let count = 0 + const queryFn = vi.fn().mockImplementation(async () => { + await vi.advanceTimersByTimeAsync(10) + return 'test' + count++ + }) + + const options = { + queryKey: key, + queryFn, + } + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const query = useQuery(options) + + return ( +
+ }> + + + + +
+ ) + } + + const rendered = await renderStream.render( + + <>error boundary}> + + + , + ) + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } + + rendered.getByText('cancel').click() + + { + await renderStream.takeRender() + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'pending', + fetchStatus: 'idle', + }) + } + + expect(queryFn).toHaveBeenCalledOnce() + + rendered.getByText('fetch').click() + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('hello') + } + }) + + it('should resolve to previous data when canceled with cancelQueries while suspending', async () => { + const renderStream = createRenderStream({ snapshotDOM: true }) + const key = queryKey() + const queryFn = vi.fn().mockImplementation(async () => { + await vi.advanceTimersByTimeAsync(10) + return 'test' + }) + + const options = { + queryKey: key, + queryFn, + } + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const query = useQuery(options) + + return ( +
+ }> + + + +
+ ) + } + + queryClient.setQueryData(key, 'initial') + + const rendered = await renderStream.render( + + + , + ) + + rendered.getByText('cancel').click() + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('initial') + } + + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should suspend when not enabled', async () => { + const renderStream = createRenderStream({ snapshotDOM: true }) + const key = queryKey() + + const options = (count: number) => ({ + queryKey: [...key, count], + queryFn: async () => { + await vi.advanceTimersByTimeAsync(10) + return 'test' + count + }, + }) + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const [count, setCount] = React.useState(0) + const query = useQuery({ ...options(count), enabled: count > 0 }) + + return ( +
+ }> + + + +
+ ) + } + + const rendered = await renderStream.render( + + + , + ) + + { + const { withinDOM } = await renderStream.takeRender() + expect(withinDOM().getByText('loading..')).toBeInTheDocument() + } + + rendered.getByText('enable').click() + + // loading re-render with enabled + await renderStream.takeRender() + + { + const { withinDOM } = await renderStream.takeRender() + expect(withinDOM().getByText('test1')).toBeInTheDocument() + } + }) + + it('should show correct data when read from cache only (staleTime)', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + queryClient.setQueryData(key, 'initial') + + const queryFn = vi.fn().mockImplementation(async () => { + await vi.advanceTimersByTimeAsync(1) + return 'test' + }) + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn, + staleTime: Infinity, + }) + + return ( + }> + + + ) + } + + await renderStream.render( + + + , + ) + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('initial') + } + + expect(queryFn).toHaveBeenCalledTimes(0) + }) + + it('should show correct data when switching between cache entries without re-fetches', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent(props: { promise: Promise }) { + useTrackRenders() + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + useTrackRenders() + return <>loading.. + } + function Page() { + useTrackRenders() + const [count, setCount] = React.useState(0) + const query = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await vi.advanceTimersByTimeAsync(10) + return 'test' + count + }, + staleTime: Infinity, + }) + + return ( +
+ }> + + + + +
+ ) + } + + const rendered = await renderStream.render( + + + , + ) + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(renderedComponents).toEqual([Page, Loading]) + } + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test0') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + + rendered.getByText('inc').click() + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(renderedComponents).toEqual([Page, Loading]) + } + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test1') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + + rendered.getByText('dec').click() + + { + const { renderedComponents, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test0') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + }) + + it('should not resolve with intermediate data when keys are switched', async () => { + const key = queryKey() + const renderStream = createRenderStream<{ data: string }>({ + snapshotDOM: true, + }) + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + renderStream.replaceSnapshot({ data }) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const [count, setCount] = React.useState(0) + const query = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await vi.advanceTimersByTimeAsync(10) + return 'test' + count + }, + staleTime: Infinity, + }) + + return ( +
+ }> + + + +
+ ) + } + + const rendered = await renderStream.render( + + + , + ) + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } + + { + const { snapshot, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test0') + expect(snapshot).toMatchObject({ data: 'test0' }) + } + + rendered.getByText('inc').click() + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } + + rendered.getByText('inc').click() + await renderStream.takeRender() + + rendered.getByText('inc').click() + await renderStream.takeRender() + + { + const { snapshot, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test3') + expect(snapshot).toMatchObject({ data: 'test3' }) + } + }) + + it('should not resolve with intermediate data when keys are switched (with background updates)', async () => { + const key = queryKey() + const renderStream = createRenderStream<{ data: string }>({ + snapshotDOM: true, + }) + let modifier = '' + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + renderStream.replaceSnapshot({ data }) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const [count, setCount] = React.useState(0) + const query = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await vi.advanceTimersByTimeAsync(10) + return 'test' + count + modifier + }, + }) + + return ( +
+ }> + + + + +
+ ) + } + + const rendered = await renderStream.render( + + + , + ) + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } + + { + const { snapshot, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test0') + expect(snapshot).toMatchObject({ data: 'test0' }) + } + + rendered.getByText('inc').click() + { + const { snapshot } = await renderStream.takeRender() + expect(snapshot).toMatchObject({ data: 'test0' }) + } + + rendered.getByText('inc').click() + { + const { snapshot } = await renderStream.takeRender() + expect(snapshot).toMatchObject({ data: 'test0' }) + } + + rendered.getByText('inc').click() + + { + const { snapshot, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(snapshot).toMatchObject({ data: 'test0' }) + } + + { + const { snapshot, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test3') + expect(snapshot).toMatchObject({ data: 'test3' }) + } + + modifier = 'new' + + rendered.getByText('dec').click() + { + const { snapshot } = await renderStream.takeRender() + expect(snapshot).toMatchObject({ data: 'test3' }) + } + + rendered.getByText('dec').click() + { + const { snapshot } = await renderStream.takeRender() + expect(snapshot).toMatchObject({ data: 'test3' }) + } + + rendered.getByText('dec').click() + { + const { snapshot } = await renderStream.takeRender() + expect(snapshot).toMatchObject({ data: 'test0' }) + } + + await waitFor(() => rendered.getByText('test0new')) + }) + + it('should not suspend indefinitely with multiple, nested observers)', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent({ input }: { input: string }) { + const query = useTheQuery(input) + const data = React.use(query.promise) + + return <>{data} + } + + function useTheQuery(input: string) { + return useQuery({ + staleTime: Infinity, + queryKey: [key, input], + queryFn: async () => { + await vi.advanceTimersByTimeAsync(1) + return input + ' response' + }, + }) + } + + function Page() { + const [input, setInput] = React.useState('defaultInput') + useTheQuery(input) + + return ( +
+ + + + +
+ ) + } + + const rendered = await renderStream.render( + + + , + ) + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('defaultInput response') + } + + expect( + queryClient.getQueryCache().find({ queryKey: [key, 'defaultInput'] })! + .observers.length, + ).toBe(2) + + rendered.getByText('setInput').click() + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('someInput response') + } + + expect( + queryClient.getQueryCache().find({ queryKey: [key, 'defaultInput'] })! + .observers.length, + ).toBe(0) + + expect( + queryClient.getQueryCache().find({ queryKey: [key, 'someInput'] })! + .observers.length, + ).toBe(2) + }) + + it('should implicitly observe data when promise is used', async () => { + const key = queryKey() + + const renderStream = createRenderStream({ snapshotDOM: true }) + + function Page() { + useTrackRenders() + const query = useInfiniteQuery({ + queryKey: key, + queryFn: async () => { + await vi.advanceTimersByTimeAsync(1) + return { nextCursor: 1, data: 'test' } + }, + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + + React.use(query.promise) + + const hasNextPage = query.hasNextPage + + return ( +
+
hasNextPage: {String(hasNextPage)}
+
+ ) + } + + await renderStream.render( + + + + + , + ) + + { + const { withinDOM } = await renderStream.takeRender() + expect(withinDOM().getByText('loading..')).toBeInTheDocument() + } + + { + const { withinDOM } = await renderStream.takeRender() + expect(withinDOM().getByText('hasNextPage: true')).toBeInTheDocument() + } + }) +}) diff --git a/packages/preact-query/src/__tests__/useQuery.test-d.tsx b/packages/preact-query/src/__tests__/useQuery.test-d.tsx new file mode 100644 index 0000000000..7e99666beb --- /dev/null +++ b/packages/preact-query/src/__tests__/useQuery.test-d.tsx @@ -0,0 +1,341 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { queryKey } from '@tanstack/query-test-utils' +import { useQuery } from '../useQuery' +import { queryOptions } from '../queryOptions' +import type { OmitKeyof, QueryFunction, UseQueryOptions } from '..' + +describe('useQuery', () => { + const key = queryKey() + + // unspecified query function should default to unknown + const noQueryFn = useQuery({ queryKey: key }) + expectTypeOf(noQueryFn.data).toEqualTypeOf() + expectTypeOf(noQueryFn.error).toEqualTypeOf() + + // it should infer the result type from the query function + const fromQueryFn = useQuery({ queryKey: key, queryFn: () => 'test' }) + expectTypeOf(fromQueryFn.data).toEqualTypeOf() + expectTypeOf(fromQueryFn.error).toEqualTypeOf() + expectTypeOf(fromQueryFn.promise).toEqualTypeOf>() + + // it should be possible to specify the result type + const withResult = useQuery({ + queryKey: key, + queryFn: () => 'test', + }) + expectTypeOf(withResult.data).toEqualTypeOf() + expectTypeOf(withResult.error).toEqualTypeOf() + + // it should be possible to specify the error type + const withError = useQuery({ + queryKey: key, + queryFn: () => 'test', + }) + expectTypeOf(withError.data).toEqualTypeOf() + expectTypeOf(withError.error).toEqualTypeOf() + + // it should provide the result type in the configuration + useQuery({ + queryKey: [key], + queryFn: () => Promise.resolve(true), + }) + + // it should be possible to specify a union type as result type + const unionTypeSync = useQuery({ + queryKey: key, + queryFn: () => (Math.random() > 0.5 ? ('a' as const) : ('b' as const)), + }) + expectTypeOf(unionTypeSync.data).toEqualTypeOf<'a' | 'b' | undefined>() + const unionTypeAsync = useQuery<'a' | 'b'>({ + queryKey: key, + queryFn: () => Promise.resolve(Math.random() > 0.5 ? 'a' : 'b'), + }) + expectTypeOf(unionTypeAsync.data).toEqualTypeOf<'a' | 'b' | undefined>() + + // should error when the query function result does not match with the specified type + // @ts-expect-error + useQuery({ queryKey: key, queryFn: () => 'test' }) + + // it should infer the result type from a generic query function + function queryFn(): Promise { + return Promise.resolve({} as T) + } + + const fromGenericQueryFn = useQuery({ + queryKey: key, + queryFn: () => queryFn(), + }) + expectTypeOf(fromGenericQueryFn.data).toEqualTypeOf() + expectTypeOf(fromGenericQueryFn.error).toEqualTypeOf() + + const fromGenericOptionsQueryFn = useQuery({ + queryKey: key, + queryFn: () => queryFn(), + }) + expectTypeOf(fromGenericOptionsQueryFn.data).toEqualTypeOf< + string | undefined + >() + expectTypeOf(fromGenericOptionsQueryFn.error).toEqualTypeOf() + + type MyData = number + type MyQueryKey = readonly ['my-data', number] + + const getMyDataArrayKey: QueryFunction = ({ + queryKey: [, n], + }) => { + return Promise.resolve(n + 42) + } + + useQuery({ + queryKey: ['my-data', 100], + queryFn: getMyDataArrayKey, + }) + + const getMyDataStringKey: QueryFunction = (context) => { + expectTypeOf(context.queryKey).toEqualTypeOf<['1']>() + return Promise.resolve(Number(context.queryKey[0]) + 42) + } + + useQuery({ + queryKey: ['1'], + queryFn: getMyDataStringKey, + }) + + // it should handle query-functions that return Promise + useQuery({ + queryKey: key, + queryFn: () => fetch('return Promise').then((resp) => resp.json()), + }) + + // handles wrapped queries with custom fetcher passed as inline queryFn + const useWrappedQuery = < + TQueryKey extends [string, Record?], + TQueryFnData, + TError, + TData = TQueryFnData, + >( + qk: TQueryKey, + fetcher: ( + obj: TQueryKey[1], + token: string, + // return type must be wrapped with TQueryFnReturn + ) => Promise, + options?: OmitKeyof< + UseQueryOptions, + 'queryKey' | 'queryFn' | 'initialData' + >, + ) => + useQuery({ + queryKey: qk, + queryFn: () => fetcher(qk[1], 'token'), + ...options, + }) + const testQuery = useWrappedQuery([''], () => Promise.resolve('1')) + expectTypeOf(testQuery.data).toEqualTypeOf() + + // handles wrapped queries with custom fetcher passed directly to useQuery + const useWrappedFuncStyleQuery = < + TQueryKey extends [string, Record?], + TQueryFnData, + TError, + TData = TQueryFnData, + >( + qk: TQueryKey, + fetcher: () => Promise, + options?: OmitKeyof< + UseQueryOptions, + 'queryKey' | 'queryFn' | 'initialData' + >, + ) => useQuery({ queryKey: qk, queryFn: fetcher, ...options }) + const testFuncStyle = useWrappedFuncStyleQuery([''], () => + Promise.resolve(true), + ) + expectTypeOf(testFuncStyle.data).toEqualTypeOf() + + it('should return the correct states for a successful query', () => { + const state = useQuery({ + queryKey: key, + queryFn: () => Promise.resolve('test'), + }) + + if (state.isPending) { + expectTypeOf(state.data).toEqualTypeOf() + expectTypeOf(state.error).toEqualTypeOf() + return pending + } + + if (state.isLoadingError) { + expectTypeOf(state.data).toEqualTypeOf() + expectTypeOf(state.error).toEqualTypeOf() + return {state.error.message} + } + + expectTypeOf(state.data).toEqualTypeOf() + expectTypeOf(state.error).toEqualTypeOf() + return {state.data} + }) + + describe('initialData', () => { + describe('Config object overload', () => { + it('TData should always be defined when initialData is provided as an object', () => { + const { data } = useQuery({ + queryKey: ['key'], + queryFn: () => ({ wow: true }), + initialData: { wow: true }, + }) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() + }) + + it('TData should be defined when passed through queryOptions', () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: () => { + return { + wow: true, + } + }, + initialData: { + wow: true, + }, + }) + const { data } = useQuery(options) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() + }) + + it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQuery', () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + }) + + const query = useQuery({ + ...options, + select: (data) => data > 1, + }) + + expectTypeOf(query.data).toEqualTypeOf() + }) + + it('TData should always be defined when initialData is provided as a function which ALWAYS returns the data', () => { + const { data } = useQuery({ + queryKey: ['key'], + queryFn: () => { + return { + wow: true, + } + }, + initialData: () => ({ + wow: true, + }), + }) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() + }) + + it('TData should have undefined in the union when initialData is NOT provided', () => { + const { data } = useQuery({ + queryKey: ['key'], + queryFn: () => { + return { + wow: true, + } + }, + }) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() + }) + + it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => { + const { data } = useQuery({ + queryKey: ['key'], + queryFn: () => { + return { + wow: true, + } + }, + initialData: () => undefined as { wow: boolean } | undefined, + }) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() + }) + + it('TData should be narrowed after an isSuccess check when initialData is provided as a function which can return undefined', () => { + const { data, isSuccess } = useQuery({ + queryKey: ['key'], + queryFn: () => { + return { + wow: true, + } + }, + initialData: () => undefined as { wow: boolean } | undefined, + }) + + if (isSuccess) { + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() + } + }) + + // eslint-disable-next-line vitest/expect-expect + it('TData should depend from only arguments, not the result', () => { + // @ts-expect-error + const result: UseQueryResult<{ wow: string }> = useQuery({ + queryKey: ['key'], + queryFn: () => { + return { + wow: true, + } + }, + initialData: () => undefined as { wow: boolean } | undefined, + }) + + void result + }) + + it('data should not have undefined when initialData is provided', () => { + const { data } = useQuery({ + queryKey: ['query-key'], + initialData: 42, + }) + + expectTypeOf(data).toEqualTypeOf() + }) + }) + + describe('custom hook', () => { + it('should allow custom hooks using UseQueryOptions', () => { + type Data = string + + const useCustomQuery = ( + options?: OmitKeyof, 'queryKey' | 'queryFn'>, + ) => { + return useQuery({ + ...options, + queryKey: ['todos-key'], + queryFn: () => Promise.resolve('data'), + }) + } + + const { data } = useCustomQuery() + + expectTypeOf(data).toEqualTypeOf() + }) + }) + + describe('structuralSharing', () => { + it('should be able to use structuralSharing with unknown types', () => { + // https://github.com/TanStack/query/issues/6525#issuecomment-1938411343 + useQuery({ + queryKey: ['key'], + queryFn: () => 5, + structuralSharing: (oldData, newData) => { + expectTypeOf(oldData).toBeUnknown() + expectTypeOf(newData).toBeUnknown() + return newData + }, + }) + }) + }) + }) +}) diff --git a/packages/preact-query/src/__tests__/useQuery.test.tsx b/packages/preact-query/src/__tests__/useQuery.test.tsx new file mode 100644 index 0000000000..39393379c0 --- /dev/null +++ b/packages/preact-query/src/__tests__/useQuery.test.tsx @@ -0,0 +1,6778 @@ +import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest' +import { act, fireEvent, render } from '@testing-library/react' +import * as React from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { + mockVisibilityState, + queryKey, + sleep, +} from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + dehydrate, + hydrate, + keepPreviousData, + skipToken, + useQuery, +} from '..' +import { + Blink, + mockOnlineManagerIsOnline, + renderWithClient, + setActTimeout, +} from './utils' +import type { DefinedUseQueryResult, QueryFunction, UseQueryResult } from '..' +import type { Mock } from 'vitest' + +describe('useQuery', () => { + let queryCache: QueryCache + let queryClient: QueryClient + + beforeEach(() => { + queryCache = new QueryCache() + queryClient = new QueryClient({ + queryCache, + }) + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + // See https://github.com/tannerlinsley/react-query/issues/105 + it('should allow to set default data value', async () => { + const key = queryKey() + + function Page() { + const { data = 'default' } = useQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'test'), + }) + + return ( +
+

{data}

+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('default')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('test')).toBeInTheDocument() + }) + + it('should return the correct states for a successful query', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return 'test' + }, + }) + + states.push(state) + + if (state.isPending) { + return pending + } + + if (state.isLoadingError) { + return {state.error.message} + } + + return {state.data} + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('test') + + expect(states.length).toEqual(2) + + expect(states[0]).toEqual({ + data: undefined, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isFetching: true, + isPaused: false, + isPending: true, + isInitialLoading: true, + isLoading: true, + isLoadingError: false, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: true, + isSuccess: false, + isEnabled: true, + refetch: expect.any(Function), + status: 'pending', + fetchStatus: 'fetching', + promise: expect.any(Promise), + }) + + expect(states[1]).toEqual({ + data: 'test', + dataUpdatedAt: expect.any(Number), + error: null, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isError: false, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isPaused: false, + isPending: false, + isInitialLoading: false, + isLoading: false, + isLoadingError: false, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: true, + isSuccess: true, + isEnabled: true, + refetch: expect.any(Function), + status: 'success', + fetchStatus: 'idle', + promise: expect.any(Promise), + }) + + expect(states[0]!.promise).toEqual(states[1]!.promise) + }) + + it('should return the correct states for an unsuccessful query', async () => { + const key = queryKey() + + const states: Array = [] + let index = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => Promise.reject(new Error(`rejected #${++index}`)), + + retry: 1, + retryDelay: 1, + }) + + states.push(state) + + return ( +
+

Status: {state.status}

+
Failure Count: {state.failureCount}
+
Failure Reason: {state.failureReason?.message}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(2) + rendered.getByText('Status: error') + + expect(states[0]).toEqual({ + data: undefined, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isFetching: true, + isPaused: false, + isPending: true, + isInitialLoading: true, + isLoading: true, + isLoadingError: false, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: true, + isSuccess: false, + isEnabled: true, + refetch: expect.any(Function), + status: 'pending', + fetchStatus: 'fetching', + promise: expect.any(Promise), + }) + + expect(states[1]).toEqual({ + data: undefined, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 1, + failureReason: new Error('rejected #1'), + errorUpdateCount: 0, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isFetching: true, + isPaused: false, + isPending: true, + isInitialLoading: true, + isLoading: true, + isLoadingError: false, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: true, + isSuccess: false, + isEnabled: true, + refetch: expect.any(Function), + status: 'pending', + fetchStatus: 'fetching', + promise: expect.any(Promise), + }) + + expect(states[2]).toEqual({ + data: undefined, + dataUpdatedAt: 0, + error: new Error('rejected #2'), + errorUpdatedAt: expect.any(Number), + failureCount: 2, + failureReason: new Error('rejected #2'), + errorUpdateCount: 1, + isError: true, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isPaused: false, + isPending: false, + isInitialLoading: false, + isLoading: false, + isLoadingError: true, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: true, + isSuccess: false, + isEnabled: true, + refetch: expect.any(Function), + status: 'error', + fetchStatus: 'idle', + promise: expect.any(Promise), + }) + + expect(states[0]!.promise).toEqual(states[1]!.promise) + expect(states[1]!.promise).toEqual(states[2]!.promise) + }) + + it('should set isFetchedAfterMount to true after a query has been fetched', async () => { + const key = queryKey() + + await queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => 'prefetched', + }) + + function Page() { + const result = useQuery({ queryKey: key, queryFn: () => 'new data' }) + + return ( + <> +
data: {result.data}
+
isFetched: {result.isFetched ? 'true' : 'false'}
+
+ isFetchedAfterMount: {result.isFetchedAfterMount ? 'true' : 'false'} +
+ + ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('data: prefetched')).toBeInTheDocument() + expect(rendered.getByText('isFetched: true')).toBeInTheDocument() + expect(rendered.getByText('isFetchedAfterMount: false')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('data: new data')).toBeInTheDocument() + expect(rendered.getByText('isFetched: true')).toBeInTheDocument() + expect(rendered.getByText('isFetchedAfterMount: true')).toBeInTheDocument() + }) + + it('should not cancel an ongoing fetch when refetch is called with cancelRefetch=false if we have data already', async () => { + const key = queryKey() + let fetchCount = 0 + + function Page() { + const { refetch } = useQuery({ + queryKey: key, + queryFn: async () => { + fetchCount++ + await sleep(10) + return 'data' + }, + enabled: false, + initialData: 'initialData', + }) + + React.useEffect(() => { + setActTimeout(() => { + refetch() + }, 5) + setActTimeout(() => { + refetch({ cancelRefetch: false }) + }, 5) + }, [refetch]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(15) + // first refetch only, second refetch is ignored + expect(fetchCount).toBe(1) + }) + + it('should cancel an ongoing fetch when refetch is called (cancelRefetch=true) if we have data already', async () => { + const key = queryKey() + let fetchCount = 0 + + function Page() { + const { refetch } = useQuery({ + queryKey: key, + queryFn: async () => { + fetchCount++ + await sleep(10) + return 'data' + }, + enabled: false, + initialData: 'initialData', + }) + + React.useEffect(() => { + setActTimeout(() => { + refetch() + }, 5) + setActTimeout(() => { + refetch() + }, 5) + }, [refetch]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(15) + // first refetch (gets cancelled) and second refetch + expect(fetchCount).toBe(2) + }) + + it('should not cancel an ongoing fetch when refetch is called (cancelRefetch=true) if we do not have data yet', async () => { + const key = queryKey() + let fetchCount = 0 + + function Page() { + const { refetch } = useQuery({ + queryKey: key, + queryFn: async () => { + fetchCount++ + await sleep(10) + return 'data' + }, + enabled: false, + }) + + React.useEffect(() => { + setActTimeout(() => { + refetch() + }, 5) + setActTimeout(() => { + refetch() + }, 5) + }, [refetch]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(15) + // first refetch will not get cancelled, second one gets skipped + expect(fetchCount).toBe(1) + }) + + it('should be able to watch a query without providing a query function', async () => { + const key = queryKey() + const states: Array> = [] + + queryClient.setQueryDefaults(key, { queryFn: () => 'data' }) + + function Page() { + const state = useQuery({ queryKey: key }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'data' }) + }) + + it('should pick up a query when re-mounting with gcTime 0', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const [toggle, setToggle] = React.useState(false) + + return ( +
+ + {toggle ? ( + + ) : ( + + )} +
+ ) + } + + function Component({ value }: { value: string }) { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return 'data: ' + value + }, + + gcTime: 0, + notifyOnChangeProps: 'all', + }) + states.push(state) + return ( +
+
{state.data}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 1') + + fireEvent.click(rendered.getByRole('button', { name: /toggle/i })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 2') + + expect(states.length).toBe(4) + // First load + expect(states[0]).toMatchObject({ + isPending: true, + isSuccess: false, + isFetching: true, + }) + // First success + expect(states[1]).toMatchObject({ + isPending: false, + isSuccess: true, + isFetching: false, + }) + // Switch, goes to fetching + expect(states[2]).toMatchObject({ + isPending: false, + isSuccess: true, + isFetching: true, + }) + // Second success + expect(states[3]).toMatchObject({ + isPending: false, + isSuccess: true, + isFetching: false, + }) + }) + + it('should not get into an infinite loop when removing a query with gcTime 0 and rerendering', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const [, rerender] = React.useState({}) + + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(5) + return 'data' + }, + + gcTime: 0, + notifyOnChangeProps: ['isPending', 'isSuccess', 'data'], + }) + + states.push(state) + + return ( + <> +
{state.data}
+ + + + ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('data') + + fireEvent.click(rendered.getByRole('button', { name: 'remove' })) + + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('data') + + expect(states.length).toBe(4) + // First load + expect(states[0]).toMatchObject({ + isPending: true, + isSuccess: false, + data: undefined, + }) + // First success + expect(states[1]).toMatchObject({ + isPending: false, + isSuccess: true, + data: 'data', + }) + // Remove + expect(states[2]).toMatchObject({ + isPending: true, + isSuccess: false, + data: undefined, + }) + // Second success + expect(states[3]).toMatchObject({ + isPending: false, + isSuccess: true, + data: 'data', + }) + }) + + it('should fetch when refetchOnMount is false and nothing has been fetched yet', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => 'test', + refetchOnMount: false, + }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test' }) + }) + + it('should not fetch when refetchOnMount is false and data has been fetched already', () => { + const key = queryKey() + const states: Array> = [] + + queryClient.setQueryData(key, 'prefetched') + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => 'test', + refetchOnMount: false, + }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + expect(states.length).toBe(1) + expect(states[0]).toMatchObject({ data: 'prefetched' }) + }) + + it('should be able to select a part of the data with select', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => ({ name: 'test' }), + select: (data) => data.name, + }) + states.push(state) + + return
{state.data}
+ } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('test') + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test' }) + }) + + it('should be able to select a part of the data with select in object syntax', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => ({ name: 'test' }), + select: (data) => data.name, + }) + states.push(state) + + return
{state.data}
+ } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('test') + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test' }) + }) + + it('should throw an error when a selector throws', async () => { + const key = queryKey() + const states: Array> = [] + const error = new Error('Select Error') + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => ({ name: 'test' }), + select: () => { + throw error + }, + }) + states.push(state) + + return
{state.status}
+ } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('error') + + expect(states.length).toBe(2) + + expect(states[0]).toMatchObject({ status: 'pending', data: undefined }) + expect(states[1]).toMatchObject({ status: 'error', error }) + }) + + it('should not re-run a stable select when it re-renders if selector throws an error', async () => { + const key = queryKey() + const error = new Error('Select Error') + let runs = 0 + + function Page() { + const [, rerender] = React.useReducer(() => ({}), {}) + const state = useQuery({ + queryKey: key, + queryFn: () => (runs === 0 ? 'test' : 'test2'), + + select: React.useCallback(() => { + runs++ + throw error + }, []), + }) + return ( +
+
error: {state.error?.message}
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('error: Select Error') + expect(runs).toEqual(1) + fireEvent.click(rendered.getByRole('button', { name: 'rerender' })) + await vi.advanceTimersByTimeAsync(0) + expect(runs).toEqual(1) + fireEvent.click(rendered.getByRole('button', { name: 'refetch' })) + await vi.advanceTimersByTimeAsync(0) + expect(runs).toEqual(2) + }) + + it('should track properties and only re-render when a tracked property changes', async () => { + const key = queryKey() + const states: Array> = [] + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + count++ + return 'test' + count + }, + }) + + states.push(state) + + return ( +
+

{state.data ?? null}

+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('test1') + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('test2') + + expect(states.length).toBe(3) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test1' }) + expect(states[2]).toMatchObject({ data: 'test2' }) + }) + + it('should always re-render if we are tracking props but not using any', async () => { + const key = queryKey() + let renderCount = 0 + const states: Array> = [] + + function Page() { + const state = useQuery({ queryKey: key, queryFn: () => 'test' }) + + states.push(state) + + React.useEffect(() => { + renderCount++ + }, [state]) + + return ( +
+

hello

+
+ ) + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(renderCount).toBe(2) + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test' }) + }) + + it('should be able to remove a query', async () => { + const key = queryKey() + const states: Array> = [] + let count = 0 + + function Page() { + const [, rerender] = React.useState({}) + const state = useQuery({ + queryKey: key, + queryFn: () => ++count, + notifyOnChangeProps: 'all', + }) + + states.push(state) + + return ( +
+ + + data: {state.data ?? 'null'} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('data: 1') + fireEvent.click(rendered.getByRole('button', { name: /remove/i })) + + await vi.advanceTimersByTimeAsync(0) + fireEvent.click(rendered.getByRole('button', { name: /rerender/i })) + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('data: 2') + + expect(states.length).toBe(4) + // Initial + expect(states[0]).toMatchObject({ status: 'pending', data: undefined }) + // Fetched + expect(states[1]).toMatchObject({ status: 'success', data: 1 }) + // Remove + Hook state update, batched + expect(states[2]).toMatchObject({ status: 'pending', data: undefined }) + // Fetched + expect(states[3]).toMatchObject({ status: 'success', data: 2 }) + }) + + it('should create a new query when refetching a removed query', async () => { + const key = queryKey() + const states: Array> = [] + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return ++count + }, + notifyOnChangeProps: 'all', + }) + + states.push(state) + + const { refetch } = state + + return ( +
+ + + data: {state.data ?? 'null'} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 1') + fireEvent.click(rendered.getByRole('button', { name: /remove/i })) + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 2') + + expect(states.length).toBe(4) + // Initial + expect(states[0]).toMatchObject({ data: undefined, dataUpdatedAt: 0 }) + // Fetched + expect(states[1]).toMatchObject({ data: 1 }) + // Switch + expect(states[2]).toMatchObject({ data: undefined, dataUpdatedAt: 0 }) + // Fetched + expect(states[3]).toMatchObject({ data: 2 }) + }) + + it('should share equal data structures between query results', async () => { + const key = queryKey() + + const result1 = [ + { id: '1', done: false }, + { id: '2', done: false }, + ] + + const result2 = [ + { id: '1', done: false }, + { id: '2', done: true }, + ] + + const states: Array> = [] + + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + count++ + return count === 1 ? result1 : result2 + }, + notifyOnChangeProps: 'all', + }) + + states.push(state) + + const { refetch } = state + + return ( +
+ + data: {String(state.data?.[1]?.done)} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: false') + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: true') + + expect(states.length).toBe(4) + + const todos = states[2]?.data + const todo1 = todos?.[0] + const todo2 = todos?.[1] + + const newTodos = states[3]?.data + const newTodo1 = newTodos?.[0] + const newTodo2 = newTodos?.[1] + + expect(todos).toEqual(result1) + expect(newTodos).toEqual(result2) + expect(newTodos).not.toBe(todos) + expect(newTodo1).toBe(todo1) + expect(newTodo2).not.toBe(todo2) + + return null + }) + + it('should use query function from hook when the existing query does not have a query function', async () => { + const key = queryKey() + + queryClient.setQueryData(key, 'set') + + function Page() { + const result = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return 'fetched' + }, + + initialData: 'initial', + staleTime: Infinity, + }) + + return ( +
+
isFetching: {result.isFetching}
+ + data: {result.data} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: set')).toBeInTheDocument() + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: fetched')).toBeInTheDocument() + }) + + it('should update query stale state and refetch when invalidated with invalidateQueries', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + count++ + return count + }, + staleTime: Infinity, + }) + + return ( +
+ + data: {state.data}, isStale: {String(state.isStale)}, isFetching:{' '} + {String(state.isFetching)} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect( + rendered.getByText('data: 1, isStale: false, isFetching: false'), + ).toBeInTheDocument() + fireEvent.click(rendered.getByRole('button', { name: /invalidate/i })) + await vi.advanceTimersByTimeAsync(0) + expect( + rendered.getByText('data: 1, isStale: true, isFetching: true'), + ).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect( + rendered.getByText('data: 2, isStale: false, isFetching: false'), + ).toBeInTheDocument() + }) + + it('should not update disabled query when refetching with refetchQueries', async () => { + const key = queryKey() + const states: Array> = [] + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + count++ + return count + }, + enabled: false, + }) + + states.push(state) + + React.useEffect(() => { + setActTimeout(() => { + queryClient.refetchQueries({ queryKey: key }) + }, 20) + }, []) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(31) + + expect(states.length).toBe(1) + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: false, + isSuccess: false, + isStale: false, + }) + }) + + it('should not refetch disabled query when invalidated with invalidateQueries', () => { + const key = queryKey() + const states: Array> = [] + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + count++ + return count + }, + enabled: false, + }) + + states.push(state) + + React.useEffect(() => { + setActTimeout(() => { + queryClient.invalidateQueries({ queryKey: key }) + }, 10) + }, []) + + return null + } + + renderWithClient(queryClient, ) + + expect(states.length).toBe(1) + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: false, + isSuccess: false, + isStale: false, + }) + }) + + it('should not fetch when switching to a disabled query', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const [count, setCount] = React.useState(0) + + const state = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await sleep(5) + return count + }, + enabled: count === 0, + }) + + states.push(state) + + return ( +
+ +
data: {state.data ?? 'undefined'}
+
count: {count}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('data: 0') + + fireEvent.click(rendered.getByRole('button', { name: /increment/i })) + + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('count: 1') + rendered.getByText('data: undefined') + + expect(states.length).toBe(3) + + // Fetch query + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + isSuccess: false, + }) + // Fetched query + expect(states[1]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + }) + // Switch to disabled query + expect(states[2]).toMatchObject({ + data: undefined, + isFetching: false, + isSuccess: false, + }) + }) + + it('should keep the previous data when placeholderData is set', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const [count, setCount] = React.useState(0) + + const state = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await sleep(10) + return count + }, + placeholderData: keepPreviousData, + }) + + states.push(state) + + return ( +
+
data: {state.data}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 0') + + fireEvent.click(rendered.getByRole('button', { name: 'setCount' })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 1') + + // Initial + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + isSuccess: false, + isPlaceholderData: false, + }) + // Fetched + expect(states[1]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + // Set state + expect(states[2]).toMatchObject({ + data: 0, + isFetching: true, + isSuccess: true, + isPlaceholderData: true, + }) + // New data + expect(states[3]).toMatchObject({ + data: 1, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + }) + + it('should keep the previous data when placeholderData is set and select fn transform is used', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const [count, setCount] = React.useState(0) + + const state = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await sleep(10) + return { + count, + } + }, + select(data) { + return data.count + }, + placeholderData: keepPreviousData, + }) + + states.push(state) + + return ( +
+
data: {state.data}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 0') + + fireEvent.click(rendered.getByRole('button', { name: 'setCount' })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 1') + + // Initial + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + isSuccess: false, + isPlaceholderData: false, + }) + // Fetched + expect(states[1]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + // Set state + expect(states[2]).toMatchObject({ + data: 0, + isFetching: true, + isSuccess: true, + isPlaceholderData: true, + }) + // New data + expect(states[3]).toMatchObject({ + data: 1, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + }) + + it('should keep the previous queryKey (from prevQuery) between multiple pending queries when placeholderData is set and select fn transform is used', async () => { + const keys: Array | null> = [] + const key = queryKey() + const states: Array> = [] + + function Page() { + const [count, setCount] = React.useState(0) + + const state = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await sleep(10) + return { + count, + } + }, + select(data) { + return data.count + }, + placeholderData: (prevData, prevQuery) => { + if (prevQuery) { + keys.push(prevQuery.queryKey) + } + return prevData + }, + }) + + states.push(state) + + return ( +
+
data: {state.data}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 0') + + fireEvent.click(rendered.getByRole('button', { name: 'setCount' })) + fireEvent.click(rendered.getByRole('button', { name: 'setCount' })) + fireEvent.click(rendered.getByRole('button', { name: 'setCount' })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 3') + + const allPreviousKeysAreTheFirstQueryKey = keys.every( + (k) => JSON.stringify(k) === JSON.stringify([key, 0]), + ) + + expect(allPreviousKeysAreTheFirstQueryKey).toBe(true) + }) + + it('should show placeholderData between multiple pending queries when select fn transform is used', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const [count, setCount] = React.useState(0) + + const state = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await sleep(10) + return { + count, + } + }, + select(data) { + return data.count + }, + placeholderData: keepPreviousData, + }) + + states.push(state) + + return ( +
+
data: {state.data}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 0') + + fireEvent.click(rendered.getByRole('button', { name: 'setCount' })) + fireEvent.click(rendered.getByRole('button', { name: 'setCount' })) + fireEvent.click(rendered.getByRole('button', { name: 'setCount' })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 3') + // Initial + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + isSuccess: false, + isPlaceholderData: false, + }) + // Fetched + expect(states[1]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + // Set state -> count = 1 + expect(states[2]).toMatchObject({ + data: 0, + isFetching: true, + isSuccess: true, + isPlaceholderData: true, + }) + // Set state -> count = 2 + expect(states[3]).toMatchObject({ + data: 0, + isFetching: true, + isSuccess: true, + isPlaceholderData: true, + }) + // Set state -> count = 3 + expect(states[4]).toMatchObject({ + data: 0, + isFetching: true, + isSuccess: true, + isPlaceholderData: true, + }) + // New data + expect(states[5]).toMatchObject({ + data: 3, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + }) + + it('should transition to error state when placeholderData is set', async () => { + const key = queryKey() + const states: Array> = [] + + function Page({ count }: { count: number }) { + const state = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await sleep(10) + if (count === 2) { + throw new Error('Error test') + } + return Promise.resolve(count) + }, + retry: false, + placeholderData: keepPreviousData, + }) + + states.push(state) + + return ( +
+

data: {state.data}

+

error: {state.error?.message}

+

placeholder data: {state.isPlaceholderData}

+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 0') + rendered.rerender() + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 1') + rendered.rerender() + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('error: Error test') + + expect(states.length).toBe(6) + // Initial + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + status: 'pending', + error: null, + isPlaceholderData: false, + }) + // Fetched + expect(states[1]).toMatchObject({ + data: 0, + isFetching: false, + status: 'success', + error: null, + isPlaceholderData: false, + }) + // rerender Page 1 + expect(states[2]).toMatchObject({ + data: 0, + isFetching: true, + status: 'success', + error: null, + isPlaceholderData: true, + }) + // New data + expect(states[3]).toMatchObject({ + data: 1, + isFetching: false, + status: 'success', + error: null, + isPlaceholderData: false, + }) + // rerender Page 2 + expect(states[4]).toMatchObject({ + data: 1, + isFetching: true, + status: 'success', + error: null, + isPlaceholderData: true, + }) + // Error + expect(states[5]).toMatchObject({ + data: undefined, + isFetching: false, + status: 'error', + isPlaceholderData: false, + }) + expect(states[5]!.error).toHaveProperty('message', 'Error test') + }) + + it('should not show initial data from next query if placeholderData is set', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const [count, setCount] = React.useState(0) + + const state = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await sleep(10) + return count + }, + initialData: 99, + placeholderData: keepPreviousData, + }) + + states.push(state) + + return ( +
+

+ data: {state.data}, count: {count}, isFetching:{' '} + {String(state.isFetching)} +

+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 0, count: 0, isFetching: false') + + fireEvent.click(rendered.getByRole('button', { name: 'inc' })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 1, count: 1, isFetching: false') + + expect(states.length).toBe(4) + + // Initial + expect(states[0]).toMatchObject({ + data: 99, + isFetching: true, + isSuccess: true, + isPlaceholderData: false, + }) + // Fetched + expect(states[1]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + // Set state + expect(states[2]).toMatchObject({ + data: 99, + isFetching: true, + isSuccess: true, + isPlaceholderData: false, + }) + // New data + expect(states[3]).toMatchObject({ + data: 1, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + }) + + it('should keep the previous data on disabled query when placeholderData is set', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const [count, setCount] = React.useState(0) + + const state = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await sleep(10) + return count + }, + enabled: false, + placeholderData: keepPreviousData, + notifyOnChangeProps: 'all', + }) + + states.push(state) + + return ( +
+ + +
data: {state.data ?? 'undefined'}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('data: undefined') + + fireEvent.click(rendered.getByRole('button', { name: 'refetch' })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 0') + + fireEvent.click(rendered.getByRole('button', { name: 'setCount' })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 0') + + fireEvent.click(rendered.getByRole('button', { name: 'refetch' })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 1') + + expect(states.length).toBe(6) + + // Disabled query + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: false, + isSuccess: false, + isPlaceholderData: false, + }) + // Fetching query + expect(states[1]).toMatchObject({ + data: undefined, + isFetching: true, + isSuccess: false, + isPlaceholderData: false, + }) + // Fetched query + expect(states[2]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + // Set state + expect(states[3]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + isPlaceholderData: true, + }) + // Fetching new query + expect(states[4]).toMatchObject({ + data: 0, + isFetching: true, + isSuccess: true, + isPlaceholderData: true, + }) + // Fetched new query + expect(states[5]).toMatchObject({ + data: 1, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + }) + + it('should keep the previous data on disabled query when placeholderData is set and switching query key multiple times', async () => { + const key = queryKey() + const states: Array> = [] + + queryClient.setQueryData([key, 10], 10) + + await vi.advanceTimersByTimeAsync(10) + + function Page() { + const [count, setCount] = React.useState(10) + + const state = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await sleep(10) + return count + }, + enabled: false, + placeholderData: keepPreviousData, + notifyOnChangeProps: 'all', + }) + + states.push(state) + + const { refetch } = state + + React.useEffect(() => { + setActTimeout(() => { + setCount(11) + }, 20) + setActTimeout(() => { + setCount(12) + }, 30) + setActTimeout(() => { + refetch() + }, 40) + }, [refetch]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(51) + + expect(states.length).toBe(5) + + // Disabled query + expect(states[0]).toMatchObject({ + data: 10, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + // Set state + expect(states[1]).toMatchObject({ + data: 10, + isFetching: false, + isSuccess: true, + isPlaceholderData: true, + }) + // State update + expect(states[2]).toMatchObject({ + data: 10, + isFetching: false, + isSuccess: true, + isPlaceholderData: true, + }) + // Refetch + expect(states[3]).toMatchObject({ + data: 10, + isFetching: true, + isSuccess: true, + isPlaceholderData: true, + }) + // Refetch done + expect(states[4]).toMatchObject({ + data: 12, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + }) + + it('should use the correct query function when components use different configurations', async () => { + const key = queryKey() + const states: Array> = [] + + function FirstComponent() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return 1 + }, + notifyOnChangeProps: 'all', + }) + const refetch = state.refetch + + states.push(state) + + return ( +
+ + data: {state.data} +
+ ) + } + + function SecondComponent() { + useQuery({ queryKey: key, queryFn: () => 2, notifyOnChangeProps: 'all' }) + return null + } + + function Page() { + return ( + <> + + + + ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 1') + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + + await vi.advanceTimersByTimeAsync(11) + expect(states.length).toBe(4) + + expect(states[0]).toMatchObject({ + data: undefined, + }) + expect(states[1]).toMatchObject({ + data: 1, + }) + expect(states[2]).toMatchObject({ + data: 1, + }) + // This state should be 1 instead of 2 + expect(states[3]).toMatchObject({ + data: 1, + }) + }) + + it('should be able to set different stale times for a query', async () => { + const key = queryKey() + const states1: Array> = [] + const states2: Array> = [] + + queryClient.prefetchQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return 'prefetch' + }, + }) + + await vi.advanceTimersByTimeAsync(20) + + function FirstComponent() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return 'one' + }, + + staleTime: 100, + }) + states1.push(state) + return null + } + + function SecondComponent() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return 'two' + }, + + staleTime: 10, + }) + states2.push(state) + return null + } + + function Page() { + return ( + <> + + + + ) + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(200) + + expect(states1.length).toBe(4) + expect(states2.length).toBe(3) + + expect(states1).toMatchObject([ + // First render + { + data: 'prefetch', + isStale: false, + }, + // Second useQuery started fetching + { + data: 'prefetch', + isStale: false, + }, + // Second useQuery data came in + { + data: 'two', + isStale: false, + }, + // Data became stale after 100ms + { + data: 'two', + isStale: true, + }, + ]) + + expect(states2).toMatchObject([ + // First render, data is stale and starts fetching + { + data: 'prefetch', + isStale: true, + }, + // Second useQuery data came in + { + data: 'two', + isStale: false, + }, + // Data became stale after 5ms + { + data: 'two', + isStale: true, + }, + ]) + }) + + it('should re-render when a query becomes stale', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => 'test', + staleTime: 50, + }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(100) + + expect(states.length).toBe(3) + expect(states[0]).toMatchObject({ isStale: true }) + expect(states[1]).toMatchObject({ isStale: false }) + expect(states[2]).toMatchObject({ isStale: true }) + }) + + it('should re-render disabled observers when other observers trigger a query (#8741)', async () => { + const key = queryKey() + + const useUserInfoQuery = ({ + id, + enabled, + }: { + id: number | null + enabled: boolean + }) => { + return useQuery({ + queryKey: [key, id], + queryFn: async () => { + await sleep(10) + return { id, name: 'John' } + }, + enabled: !!id && enabled, + }) + } + + const Page = () => { + const [id, setId] = React.useState(null) + + const searchQuery = useUserInfoQuery({ id, enabled: false }) + + return ( + <> +
User fetching status is {searchQuery.fetchStatus}
+ + + + ) + } + + function UserInfo({ id }: { id: number | null }) { + const searchQuery = useUserInfoQuery({ id, enabled: true }) + + return
UserInfo data is {JSON.stringify(searchQuery.data)}
+ } + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('User fetching status is idle') + + fireEvent.click(rendered.getByRole('button', { name: /set id/i })) + + await vi.advanceTimersByTimeAsync(0) + expect( + rendered.getByText('User fetching status is fetching'), + ).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect( + rendered.getByText('UserInfo data is {"id":42,"name":"John"}'), + ).toBeInTheDocument() + + expect( + rendered.getByText('User fetching status is idle'), + ).toBeInTheDocument() + }) + + describe('notifyOnChangeProps', () => { + it('should not re-render when it should only re-render only data change and the selected data did not change', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => ({ name: 'test' }), + select: (data) => data.name, + notifyOnChangeProps: ['data'], + }) + + states.push(state) + + return ( +
+
{state.data}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('test') + + fireEvent.click(rendered.getByRole('button', { name: 'refetch' })) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('test') + + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test' }) + + // make sure no additional renders happen + await vi.advanceTimersByTimeAsync(50) + expect(states.length).toBe(2) + }) + it('should not re-render when it should only re-render on data changes and the data did not change', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(5) + return 'test' + }, + + notifyOnChangeProps: ['data'], + }) + + states.push(state) + + return ( + <> + + +
{state.data}
+ + ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('test') + + fireEvent.click(rendered.getByRole('button', { name: 'refetch' })) + + // sleep is required to make sure no additional renders happen after click + await vi.advanceTimersByTimeAsync(20) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: undefined, + status: 'pending', + isFetching: true, + }) + expect(states[1]).toMatchObject({ + data: 'test', + status: 'success', + isFetching: false, + }) + }) + + // See https://github.com/TanStack/query/discussions/5588 + describe('function', () => { + it('should not re-render when it should only re-render on data changes and the data did not change', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(5) + return 'test' + }, + notifyOnChangeProps: () => ['data'], + }) + + states.push(state) + + return ( + <> + + +
{state.data}
+ + ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('test') + + fireEvent.click(rendered.getByRole('button', { name: 'refetch' })) + + await vi.advanceTimersByTimeAsync(20) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: undefined, + status: 'pending', + isFetching: true, + }) + expect(states[1]).toMatchObject({ + data: 'test', + status: 'success', + isFetching: false, + }) + }) + + it('should not re-render when change props are not actively being tracked', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const fetchCounterRef = React.useRef(0) + const trackChangesRef = React.useRef(true) + + const notifyOnChangeProps = React.useCallback(() => { + return trackChangesRef.current ? 'all' : [] + }, []) + + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(5) + fetchCounterRef.current++ + return `fetch counter: ${fetchCounterRef.current}` + }, + notifyOnChangeProps, + }) + + states.push(state) + + return ( + <> + + + + +
{state.data}
+ + ) + } + + const rendered = renderWithClient(queryClient, ) + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('fetch counter: 1') + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + status: 'pending', + }) + expect(states[1]).toMatchObject({ + data: 'fetch counter: 1', + status: 'success', + isFetching: false, + }) + + // disable tracking and refetch to check for re-renders + fireEvent.click( + rendered.getByRole('button', { name: 'disableTracking' }), + ) + fireEvent.click(rendered.getByRole('button', { name: 'refetch' })) + + await vi.advanceTimersByTimeAsync(20) + // still expect to only have two re-renders from the initial fetch + expect(states.length).toBe(2) + + // enable tracking and refetch to check for re-renders + fireEvent.click( + rendered.getByRole('button', { name: 'enableTracking' }), + ) + fireEvent.click(rendered.getByRole('button', { name: 'refetch' })) + + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('fetch counter: 3') + await vi.advanceTimersByTimeAsync(20) + + expect(states.length).toBe(4) + expect(states[2]).toMatchObject({ + data: 'fetch counter: 2', + status: 'success', + isFetching: true, + }) + expect(states[3]).toMatchObject({ + data: 'fetch counter: 3', + status: 'success', + isFetching: false, + }) + }) + }) + }) + + // See https://github.com/tannerlinsley/react-query/issues/137 + it('should not override initial data in dependent queries', () => { + const key1 = queryKey() + const key2 = queryKey() + + function Page() { + const first = useQuery({ + queryKey: key1, + queryFn: () => 'data', + enabled: false, + initialData: 'init', + }) + + const second = useQuery({ + queryKey: key2, + queryFn: () => 'data', + enabled: false, + initialData: 'init', + }) + + return ( +
+

First Data: {first.data}

+

Second Data: {second.data}

+
First Status: {first.status}
+
Second Status: {second.status}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('First Data: init')).toBeInTheDocument() + expect(rendered.getByText('Second Data: init')).toBeInTheDocument() + expect(rendered.getByText('First Status: success')).toBeInTheDocument() + expect(rendered.getByText('Second Status: success')).toBeInTheDocument() + }) + + it('should update query options', () => { + const key = queryKey() + + const queryFn = async () => { + await sleep(10) + return 'data1' + } + + function Page() { + useQuery({ queryKey: key, queryFn, retryDelay: 10 }) + useQuery({ queryKey: key, queryFn, retryDelay: 20 }) + return null + } + + renderWithClient(queryClient, ) + + expect(queryCache.find({ queryKey: key })!.options.retryDelay).toBe(20) + }) + + it('should batch re-renders', async () => { + const key = queryKey() + + let renders = 0 + + const queryFn = async () => { + await sleep(15) + return 'data' + } + + function Page() { + const query1 = useQuery({ queryKey: key, queryFn }) + const query2 = useQuery({ queryKey: key, queryFn }) + renders++ + + return ( +
+ {query1.data} {query2.data} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(16) + rendered.getByText('data data') + + // Should be 2 instead of 3 + expect(renders).toBe(2) + }) + + it('should render latest data even if react has discarded certain renders', async () => { + const key = queryKey() + + function Page() { + const [, setNewState] = React.useState('state') + const state = useQuery({ queryKey: key, queryFn: () => 'data' }) + React.useEffect(() => { + setActTimeout(() => { + queryClient.setQueryData(key, 'new') + // Update with same state to make react discard the next render + setNewState('state') + }, 10) + }, []) + return
{state.data}
+ } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('new')).toBeInTheDocument() + }) + + // See https://github.com/tannerlinsley/react-query/issues/170 + it('should start with status pending, fetchStatus idle if enabled is false', async () => { + const key1 = queryKey() + const key2 = queryKey() + + function Page() { + const first = useQuery({ + queryKey: key1, + queryFn: async () => { + await sleep(10) + return 'data' + }, + enabled: false, + }) + const second = useQuery({ + queryKey: key2, + queryFn: async () => { + await sleep(10) + return 'data' + }, + }) + + return ( +
+
+ First Status: {first.status}, {first.fetchStatus} +
+
+ Second Status: {second.status}, {second.fetchStatus} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + // use "act" to wait for state update and prevent console warning + + expect( + rendered.getByText('First Status: pending, idle'), + ).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(0) + expect( + rendered.getByText('Second Status: pending, fetching'), + ).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect( + rendered.getByText('Second Status: success, idle'), + ).toBeInTheDocument() + }) + + // See https://github.com/tannerlinsley/react-query/issues/144 + it('should be in "pending" state by default', () => { + const key = queryKey() + + function Page() { + const { status } = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return 'test' + }, + }) + + return
status: {status}
+ } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('status: pending')).toBeInTheDocument() + }) + + it('should not refetch query on focus when `enabled` is set to `false`', async () => { + const key = queryKey() + const queryFn = vi + .fn<(...args: Array) => string>() + .mockReturnValue('data') + + function Page() { + const { data = 'default' } = useQuery({ + queryKey: key, + queryFn, + enabled: false, + }) + + return ( +
+

{data}

+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('default') + + act(() => { + window.dispatchEvent(new Event('visibilitychange')) + }) + + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should not refetch stale query on focus when `refetchOnWindowFocus` is set to `false`', async () => { + const key = queryKey() + const states: Array> = [] + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => count++, + staleTime: 0, + refetchOnWindowFocus: false, + }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(10) + + act(() => { + window.dispatchEvent(new Event('visibilitychange')) + }) + + await vi.advanceTimersByTimeAsync(10) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined, isFetching: true }) + expect(states[1]).toMatchObject({ data: 0, isFetching: false }) + }) + + it('should not refetch stale query on focus when `refetchOnWindowFocus` is set to a function that returns `false`', async () => { + const key = queryKey() + const states: Array> = [] + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => count++, + staleTime: 0, + refetchOnWindowFocus: () => false, + }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(10) + + act(() => { + window.dispatchEvent(new Event('visibilitychange')) + }) + + await vi.advanceTimersByTimeAsync(10) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined, isFetching: true }) + expect(states[1]).toMatchObject({ data: 0, isFetching: false }) + }) + + it('should not refetch fresh query on focus when `refetchOnWindowFocus` is set to `true`', async () => { + const key = queryKey() + const states: Array> = [] + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => count++, + staleTime: Infinity, + refetchOnWindowFocus: true, + }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(10) + + act(() => { + window.dispatchEvent(new Event('visibilitychange')) + }) + + await vi.advanceTimersByTimeAsync(10) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined, isFetching: true }) + expect(states[1]).toMatchObject({ data: 0, isFetching: false }) + }) + + it('should refetch fresh query on focus when `refetchOnWindowFocus` is set to `always`', async () => { + const key = queryKey() + const states: Array> = [] + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return count++ + }, + + staleTime: Infinity, + refetchOnWindowFocus: 'always', + }) + states.push(state) + return ( +
+
+ data: {state.data}, isFetching: {String(state.isFetching)} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 0, isFetching: false')).toBeInTheDocument() + + act(() => { + window.dispatchEvent(new Event('visibilitychange')) + }) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 1, isFetching: false')).toBeInTheDocument() + }) + + it('should calculate focus behavior for `refetchOnWindowFocus` depending on function', async () => { + const key = queryKey() + const states: Array> = [] + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return count++ + }, + + staleTime: 0, + retry: 0, + refetchOnWindowFocus: (query) => (query.state.data || 0) < 1, + }) + states.push(state) + return
data: {String(state.data)}
+ } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 0') + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined, isFetching: true }) + expect(states[1]).toMatchObject({ data: 0, isFetching: false }) + + act(() => { + window.dispatchEvent(new Event('visibilitychange')) + }) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 1') + + // refetch should happen + expect(states.length).toBe(4) + + expect(states[2]).toMatchObject({ data: 0, isFetching: true }) + expect(states[3]).toMatchObject({ data: 1, isFetching: false }) + + act(() => { + window.dispatchEvent(new Event('visibilitychange')) + }) + + await vi.advanceTimersByTimeAsync(20) + + // no more refetch now + expect(states.length).toBe(4) + }) + + it('should refetch fresh query when refetchOnMount is set to always', async () => { + const key = queryKey() + const states: Array> = [] + + await queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => 'prefetched', + }) + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => 'data', + refetchOnMount: 'always', + staleTime: Infinity, + }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: 'prefetched', + isStale: false, + isFetching: true, + }) + expect(states[1]).toMatchObject({ + data: 'data', + isStale: false, + isFetching: false, + }) + }) + + it('should refetch stale query when refetchOnMount is set to true', async () => { + const key = queryKey() + const states: Array> = [] + + await queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => 'prefetched', + }) + + await vi.advanceTimersByTimeAsync(0) + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => 'data', + refetchOnMount: true, + staleTime: 0, + }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: 'prefetched', + isStale: true, + isFetching: true, + }) + expect(states[1]).toMatchObject({ + data: 'data', + isStale: true, + isFetching: false, + }) + }) + + it('should set status to error if queryFn throws', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + function Page() { + const { status, error } = useQuery({ + queryKey: key, + queryFn: () => { + return Promise.reject(new Error('Error test')) + }, + retry: false, + }) + + return ( +
+

{status}

+

{error?.message}

+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('error')).toBeInTheDocument() + expect(rendered.getByText('Error test')).toBeInTheDocument() + consoleMock.mockRestore() + }) + + it('should throw error if queryFn throws and throwOnError is in use', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + function Page() { + const { status, error } = useQuery({ + queryKey: key, + queryFn: () => Promise.reject(new Error('Error test')), + retry: false, + throwOnError: true, + }) + + return ( +
+

{status}

+

{error}

+
+ ) + } + + const rendered = renderWithClient( + queryClient, +
error boundary
}> + +
, + ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + consoleMock.mockRestore() + }) + + it('should update with data if we observe no properties and throwOnError', async () => { + const key = queryKey() + + let result: UseQueryResult | undefined + + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: () => Promise.resolve('data'), + throwOnError: true, + }) + + React.useEffect(() => { + result = query + }) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(queryClient.isFetching()).toBe(0) + + expect(result?.data).toBe('data') + }) + + it('should set status to error instead of throwing when error should not be thrown', async () => { + const key = queryKey() + + function Page() { + const { status, error } = useQuery({ + queryKey: key, + queryFn: () => Promise.reject(new Error('Local Error')), + + retry: false, + throwOnError: (err) => err.message !== 'Local Error', + }) + + return ( +
+

{status}

+

{error?.message}

+
+ ) + } + + const rendered = renderWithClient( + queryClient, +
error boundary
}> + +
, + ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('error')).toBeInTheDocument() + expect(rendered.getByText('Local Error')).toBeInTheDocument() + }) + + it('should throw error instead of setting status when error should be thrown', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const key = queryKey() + + function Page() { + const { status, error } = useQuery({ + queryKey: key, + queryFn: () => Promise.reject(new Error('Remote Error')), + + retry: false, + throwOnError: (err) => err.message !== 'Local Error', + }) + + return ( +
+

{status}

+

{error?.message ?? ''}

+
+ ) + } + + const rendered = renderWithClient( + queryClient, + ( +
+
error boundary
+
{error?.message}
+
+ )} + > + +
, + ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('Remote Error')).toBeInTheDocument() + consoleMock.mockRestore() + }) + + it('should continue retries when observers unmount and remount while waiting for a retry (#3031)', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const result = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return Promise.reject(new Error('some error')) + }, + + retry: 2, + retryDelay: 100, + }) + + return ( +
+
error: {result.error?.message ?? 'null'}
+
failureCount: {result.failureCount}
+
failureReason: {result.failureReason?.message}
+
+ ) + } + + function App() { + const [show, toggle] = React.useReducer((x) => !x, true) + + return ( +
+ + {show && } +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('failureCount: 1')).toBeInTheDocument() + expect(rendered.getByText('failureReason: some error')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(90) + fireEvent.click(rendered.getByRole('button', { name: /hide/i })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByRole('button', { name: /show/i })).toBeInTheDocument() + fireEvent.click(rendered.getByRole('button', { name: /show/i })) + await vi.advanceTimersByTimeAsync(11) + await vi.advanceTimersByTimeAsync(110) + await vi.advanceTimersByTimeAsync(110) + expect(rendered.getByText('error: some error')).toBeInTheDocument() + + expect(count).toBe(4) + }) + + it('should restart when observers unmount and remount while waiting for a retry when query was cancelled in between (#3031)', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const result = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return Promise.reject(new Error('some error')) + }, + + retry: 2, + retryDelay: 100, + }) + + return ( +
+
error: {result.error?.message ?? 'null'}
+
failureCount: {result.failureCount}
+
failureReason: {result.failureReason?.message}
+
+ ) + } + + function App() { + const [show, toggle] = React.useReducer((x) => !x, true) + + return ( +
+ + + {show && } +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('failureCount: 1')).toBeInTheDocument() + expect(rendered.getByText('failureReason: some error')).toBeInTheDocument() + fireEvent.click(rendered.getByRole('button', { name: /hide/i })) + fireEvent.click(rendered.getByRole('button', { name: /cancel/i })) + expect(rendered.getByRole('button', { name: /show/i })).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(1) + fireEvent.click(rendered.getByRole('button', { name: /show/i })) + await vi.advanceTimersByTimeAsync(11) + await vi.advanceTimersByTimeAsync(110) + await vi.advanceTimersByTimeAsync(110) + expect(rendered.getByText('error: some error')).toBeInTheDocument() + + // initial fetch (1), which will be cancelled, followed by new mount(2) + 2 retries = 4 + expect(count).toBe(4) + }) + + it('should always fetch if refetchOnMount is set to always', async () => { + const key = queryKey() + const states: Array> = [] + + await queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => 'prefetched', + }) + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => 'data', + refetchOnMount: 'always', + staleTime: 50, + }) + states.push(state) + return ( +
+
data: {state.data ?? 'null'}
+
isFetching: {state.isFetching}
+
isStale: {state.isStale}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('data: data') + await vi.advanceTimersByTimeAsync(52) + expect(states.length).toBe(3) + + expect(states[0]).toMatchObject({ + data: 'prefetched', + isStale: false, + isFetching: true, + }) + expect(states[1]).toMatchObject({ + data: 'data', + isStale: false, + isFetching: false, + }) + expect(states[2]).toMatchObject({ + data: 'data', + isStale: true, + isFetching: false, + }) + }) + + it('should fetch if initial data is set', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => 'data', + initialData: 'initial', + }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + + expect(states.length).toBe(2) + + expect(states[0]).toMatchObject({ + data: 'initial', + isStale: true, + isFetching: true, + }) + expect(states[1]).toMatchObject({ + data: 'data', + isStale: true, + isFetching: false, + }) + }) + + it('should not fetch if initial data is set with a stale time', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => 'data', + staleTime: 50, + initialData: 'initial', + }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(52) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: 'initial', + isStale: false, + isFetching: false, + }) + expect(states[1]).toMatchObject({ + data: 'initial', + isStale: true, + isFetching: false, + }) + }) + + it('should fetch if initial data updated at is older than stale time', async () => { + const key = queryKey() + const states: Array> = [] + + const oneSecondAgo = Date.now() - 1000 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => 'data', + staleTime: 50, + initialData: 'initial', + initialDataUpdatedAt: oneSecondAgo, + }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(52) + + expect(states.length).toBe(3) + expect(states[0]).toMatchObject({ + data: 'initial', + isStale: true, + isFetching: true, + }) + expect(states[1]).toMatchObject({ + data: 'data', + isStale: false, + isFetching: false, + }) + expect(states[2]).toMatchObject({ + data: 'data', + isStale: true, + isFetching: false, + }) + }) + + it('should fetch if "initial data updated at" is exactly 0', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => 'data', + staleTime: 10 * 1000, // 10 seconds + initialData: 'initial', + initialDataUpdatedAt: 0, + }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: 'initial', + isStale: true, + isFetching: true, + }) + expect(states[1]).toMatchObject({ + data: 'data', + isStale: false, + isFetching: false, + }) + }) + + it('should keep initial data when the query key changes', async () => { + const key = queryKey() + const states: Array> = [] + + function Page() { + const [count, setCount] = React.useState(0) + const state = useQuery({ + queryKey: [key, count], + queryFn: () => ({ count: 10 }), + staleTime: Infinity, + initialData: () => ({ count }), + }) + states.push(state) + + React.useEffect(() => { + setActTimeout(() => { + setCount(1) + }, 10) + }, []) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + + expect(states.length).toBe(2) + // Initial + expect(states[0]).toMatchObject({ data: { count: 0 } }) + // Set state + expect(states[1]).toMatchObject({ data: { count: 1 } }) + }) + + it('should retry specified number of times', async () => { + const key = queryKey() + + const queryFn = vi.fn<(...args: Array) => unknown>() + queryFn.mockImplementation(() => { + return Promise.reject(new Error('Error test Barrett')) + }) + + function Page() { + const { status, failureCount, failureReason } = useQuery({ + queryKey: key, + queryFn, + retry: 1, + retryDelay: 1, + }) + + return ( +
+

{status}

+

Failed {failureCount} times

+

Failed because {failureReason?.message}

+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('pending') + await vi.advanceTimersByTimeAsync(2) + rendered.getByText('error') + + // query should fail `retry + 1` times, since first time isn't a "retry" + rendered.getByText('Failed 2 times') + rendered.getByText('Failed because Error test Barrett') + + expect(queryFn).toHaveBeenCalledTimes(2) + }) + + it('should not retry if retry function `false`', async () => { + const key = queryKey() + + const queryFn = vi.fn<(...args: Array) => unknown>() + + queryFn.mockImplementationOnce(() => { + return Promise.reject(new Error('Error test Tanner')) + }) + + queryFn.mockImplementation(() => { + return Promise.reject(new Error('NoRetry')) + }) + + function Page() { + const { status, failureCount, failureReason, error } = useQuery({ + queryKey: key, + queryFn, + retryDelay: 1, + retry: (_failureCount, err) => err.message !== 'NoRetry', + }) + + return ( +
+

{status}

+

Failed {failureCount} times

+

Failed because {failureReason?.message}

+

{error?.message}

+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('pending') + await vi.advanceTimersByTimeAsync(2) + rendered.getByText('error') + + rendered.getByText('Failed 2 times') + rendered.getByText('Failed because NoRetry') + + rendered.getByText('NoRetry') + + expect(queryFn).toHaveBeenCalledTimes(2) + }) + + it('should extract retryDelay from error', async () => { + const key = queryKey() + + type DelayError = { delay: number } + + const queryFn = vi.fn<(...args: Array) => unknown>() + queryFn.mockImplementation(() => { + return Promise.reject({ delay: 50 }) + }) + + function Page() { + const { status, failureCount, failureReason } = useQuery({ + queryKey: key, + queryFn, + retry: 1, + retryDelay: (_, error: DelayError) => error.delay, + }) + + return ( +
+

{status}

+

Failed {failureCount} times

+

Failed because DelayError: {failureReason?.delay}ms

+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + + expect(queryFn).toHaveBeenCalledTimes(1) + + rendered.getByText('Failed because DelayError: 50ms') + await vi.advanceTimersByTimeAsync(51) + rendered.getByText('Failed 2 times') + + expect(queryFn).toHaveBeenCalledTimes(2) + }) + + // See https://github.com/tannerlinsley/react-query/issues/160 + it('should continue retry after focus regain', async () => { + const key = queryKey() + + // make page unfocused + const visibilityMock = mockVisibilityState('hidden') + + let count = 0 + + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: () => { + count++ + return Promise.reject(`fetching error ${count}`) + }, + retry: 3, + retryDelay: 1, + }) + + return ( +
+
error {String(query.error)}
+
status {query.status}
+
failureCount {query.failureCount}
+
failureReason {query.failureReason}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + // The query should display the first error result + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('failureCount 1')).toBeInTheDocument() + expect( + rendered.getByText('failureReason fetching error 1'), + ).toBeInTheDocument() + expect(rendered.getByText('status pending')).toBeInTheDocument() + expect(rendered.getByText('error null')).toBeInTheDocument() + + // Check if the query really paused + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('failureCount 1')).toBeInTheDocument() + expect( + rendered.getByText('failureReason fetching error 1'), + ).toBeInTheDocument() + + act(() => { + // reset visibilityState to original value + visibilityMock.mockRestore() + window.dispatchEvent(new Event('visibilitychange')) + }) + + // Wait for the final result + await vi.advanceTimersByTimeAsync(4) + expect(rendered.getByText('failureCount 4')).toBeInTheDocument() + expect( + rendered.getByText('failureReason fetching error 4'), + ).toBeInTheDocument() + expect(rendered.getByText('status error')).toBeInTheDocument() + expect(rendered.getByText('error fetching error 4')).toBeInTheDocument() + + // Check if the query really stopped + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('failureCount 4')).toBeInTheDocument() + expect( + rendered.getByText('failureReason fetching error 4'), + ).toBeInTheDocument() + }) + + it('should fetch on mount when a query was already created with setQueryData', async () => { + const key = queryKey() + const states: Array> = [] + + queryClient.setQueryData(key, 'prefetched') + + function Page() { + const state = useQuery({ queryKey: key, queryFn: () => 'data' }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + + expect(states.length).toBe(2) + expect(states).toMatchObject([ + { + data: 'prefetched', + isFetching: true, + isStale: true, + }, + { + data: 'data', + isFetching: false, + isStale: true, + }, + ]) + }) + + it('should refetch after focus regain', async () => { + const key = queryKey() + const states: Array> = [] + + // make page unfocused + const visibilityMock = mockVisibilityState('hidden') + + // set data in cache to check if the hook query fn is actually called + queryClient.setQueryData(key, 'prefetched') + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return 'data' + }, + }) + states.push(state) + return ( +
+ {state.data}, {state.isStale}, {state.isFetching} +
+ ) + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(states.length).toBe(2) + + act(() => { + // reset visibilityState to original value + visibilityMock.mockRestore() + window.dispatchEvent(new Event('visibilitychange')) + }) + + await vi.advanceTimersByTimeAsync(11) + expect(states.length).toBe(4) + + expect(states).toMatchObject([ + { + data: 'prefetched', + isFetching: true, + isStale: true, + }, + { + data: 'data', + isFetching: false, + isStale: true, + }, + { + data: 'data', + isFetching: true, + isStale: true, + }, + { + data: 'data', + isFetching: false, + isStale: true, + }, + ]) + }) + + // See https://github.com/tannerlinsley/react-query/issues/195 + it('should refetch if stale after a prefetch', async () => { + const key = queryKey() + const states: Array> = [] + + const queryFn = vi.fn<(...args: Array) => string>() + queryFn.mockImplementation(() => 'data') + + const prefetchQueryFn = vi.fn<(...args: Array) => string>() + prefetchQueryFn.mockImplementation(() => 'not yet...') + + await queryClient.prefetchQuery({ + queryKey: key, + queryFn: prefetchQueryFn, + staleTime: 10, + }) + + await vi.advanceTimersByTimeAsync(10) + + function Page() { + const state = useQuery({ queryKey: key, queryFn }) + states.push(state) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(states.length).toBe(2) + + expect(prefetchQueryFn).toHaveBeenCalledTimes(1) + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should not refetch if not stale after a prefetch', async () => { + const key = queryKey() + + const queryFn = vi.fn<(...args: Array) => string>() + queryFn.mockImplementation(() => 'data') + + const prefetchQueryFn = + vi.fn<(...args: Array) => Promise>() + prefetchQueryFn.mockImplementation(async () => { + await sleep(10) + return 'not yet...' + }) + + queryClient.prefetchQuery({ + queryKey: key, + queryFn: prefetchQueryFn, + staleTime: 1000, + }) + + await vi.advanceTimersByTimeAsync(0) + + function Page() { + useQuery({ queryKey: key, queryFn, staleTime: 1000 }) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + + expect(prefetchQueryFn).toHaveBeenCalledTimes(1) + expect(queryFn).toHaveBeenCalledTimes(0) + }) + + // See https://github.com/tannerlinsley/react-query/issues/190 + it('should reset failureCount on successful fetch', async () => { + const key = queryKey() + + let counter = 0 + + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: () => { + if (counter < 2) { + counter++ + return Promise.reject(new Error('error')) + } else { + return Promise.resolve('data') + } + }, + retryDelay: 10, + }) + + return ( +
+
failureCount {query.failureCount}
+
failureReason {query.failureReason?.message ?? 'null'}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('failureCount 2')).toBeInTheDocument() + expect(rendered.getByText('failureReason error')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('failureCount 0')).toBeInTheDocument() + expect(rendered.getByText('failureReason null')).toBeInTheDocument() + }) + + // See https://github.com/tannerlinsley/react-query/issues/199 + it('should use prefetched data for dependent query', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const [enabled, setEnabled] = React.useState(false) + const [isPrefetched, setPrefetched] = React.useState(false) + + const query = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return count + }, + + enabled, + }) + + React.useEffect(() => { + async function prefetch() { + await queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => Promise.resolve('prefetched data'), + }) + act(() => setPrefetched(true)) + } + + prefetch() + }, []) + + return ( +
+ {isPrefetched &&
isPrefetched
} + +
data: {query.data}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('isPrefetched')).toBeInTheDocument() + fireEvent.click(rendered.getByText('setKey')) + expect(rendered.getByText('data: prefetched data')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data: 1')).toBeInTheDocument() + expect(count).toBe(1) + }) + + it('should support dependent queries via the enable config option', async () => { + const key = queryKey() + + function Page() { + const [shouldFetch, setShouldFetch] = React.useState(false) + + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return 'data' + }, + enabled: shouldFetch, + }) + + return ( +
+
FetchStatus: {query.fetchStatus}
+

Data: {query.data || 'no data'}

+ {shouldFetch ? null : ( + + )} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('FetchStatus: idle')).toBeInTheDocument() + expect(rendered.getByText('Data: no data')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('fetch')) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('FetchStatus: fetching')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('FetchStatus: idle')).toBeInTheDocument() + expect(rendered.getByText('Data: data')).toBeInTheDocument() + }) + + it('should mark query as fetching, when using initialData', async () => { + const key = queryKey() + const results: Array> = [] + + function Page() { + const result = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return 'serverData' + }, + initialData: 'initialData', + }) + results.push(result) + return
data: {result.data}
+ } + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('data: initialData') + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: serverData') + + expect(results.length).toBe(2) + expect(results[0]).toMatchObject({ data: 'initialData', isFetching: true }) + expect(results[1]).toMatchObject({ data: 'serverData', isFetching: false }) + }) + + it('should initialize state properly, when initialData is falsy', async () => { + const key = queryKey() + const results: Array> = [] + + function Page() { + const result = useQuery({ + queryKey: key, + queryFn: () => 1, + initialData: 0, + }) + results.push(result) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + + expect(results.length).toBe(2) + expect(results[0]).toMatchObject({ data: 0, isFetching: true }) + expect(results[1]).toMatchObject({ data: 1, isFetching: false }) + }) + + it('should show the correct data when switching keys with initialData, placeholderData & staleTime', async () => { + const key = queryKey() + + const ALL_TODOS = [ + { name: 'todo A', priority: 'high' }, + { name: 'todo B', priority: 'medium' }, + ] + + const initialTodos = ALL_TODOS + + function Page() { + const [filter, setFilter] = React.useState('') + const { data: todos } = useQuery({ + queryKey: [...key, filter], + queryFn: () => { + return Promise.resolve( + ALL_TODOS.filter((todo) => + filter ? todo.priority === filter : true, + ), + ) + }, + initialData() { + return filter === '' ? initialTodos : undefined + }, + placeholderData: keepPreviousData, + staleTime: 5000, + }) + + return ( +
+ Current Todos, filter: {filter || 'all'} +
+ + +
    + {(todos ?? []).map((todo) => ( +
  • + {todo.name} - {todo.priority} +
  • + ))} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('Current Todos, filter: all')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /high/i })) + await vi.advanceTimersByTimeAsync(0) + expect( + rendered.getByText('Current Todos, filter: high'), + ).toBeInTheDocument() + fireEvent.click(rendered.getByRole('button', { name: /all/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('todo B - medium')).toBeInTheDocument() + }) + + // // See https://github.com/tannerlinsley/react-query/issues/214 + it('data should persist when enabled is changed to false', async () => { + const key = queryKey() + const results: Array> = [] + + function Page() { + const [shouldFetch, setShouldFetch] = React.useState(true) + + const result = useQuery({ + queryKey: key, + queryFn: () => 'fetched data', + enabled: shouldFetch, + initialData: shouldFetch ? 'initial' : 'initial falsy', + }) + + results.push(result) + + return ( +
+
{result.data}
+
{shouldFetch ? 'enabled' : 'disabled'}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('fetched data')).toBeInTheDocument() + expect(rendered.getByText('enabled')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /enable/i })) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('fetched data')).toBeInTheDocument() + expect(rendered.getByText('disabled')).toBeInTheDocument() + + expect(results.length).toBe(3) + expect(results[0]).toMatchObject({ data: 'initial', isStale: true }) + expect(results[1]).toMatchObject({ data: 'fetched data', isStale: true }) + // disabled observers are not stale + expect(results[2]).toMatchObject({ data: 'fetched data', isStale: false }) + }) + + it('should support enabled:false in query object syntax', () => { + const key = queryKey() + const queryFn = vi.fn<(...args: Array) => string>() + queryFn.mockImplementation(() => 'data') + + function Page() { + const { fetchStatus } = useQuery({ + queryKey: key, + queryFn, + enabled: false, + }) + return
fetchStatus: {fetchStatus}
+ } + + const rendered = renderWithClient(queryClient, ) + + expect(queryFn).not.toHaveBeenCalled() + expect(queryCache.find({ queryKey: key })).not.toBeUndefined() + expect(rendered.getByText('fetchStatus: idle')).toBeInTheDocument() + }) + + // See https://github.com/tannerlinsley/react-query/issues/360 + test('should init to status:pending, fetchStatus:idle when enabled is false', async () => { + const key = queryKey() + + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: () => 'data', + enabled: false, + }) + + return ( +
+
+ status: {query.status}, {query.fetchStatus} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('status: pending, idle')).toBeInTheDocument() + }) + + test('should not schedule garbage collection, if gcTimeout is set to `Infinity`', async () => { + const key = queryKey() + + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: () => 'fetched data', + gcTime: Infinity, + }) + return
{query.data}
+ } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('fetched data') + const setTimeoutSpy = vi.spyOn(globalThis.window, 'setTimeout') + + rendered.unmount() + + expect(setTimeoutSpy).not.toHaveBeenCalled() + }) + + test('should schedule garbage collection, if gcTimeout is not set to infinity', async () => { + const key = queryKey() + + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: () => 'fetched data', + gcTime: 1000 * 60 * 10, // 10 Minutes + }) + return
{query.data}
+ } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('fetched data') + + const setTimeoutSpy = vi.spyOn(globalThis.window, 'setTimeout') + + rendered.unmount() + + expect(setTimeoutSpy).toHaveBeenLastCalledWith( + expect.any(Function), + 1000 * 60 * 10, + ) + }) + + it('should not cause memo churn when data does not change', async () => { + const key = queryKey() + const queryFn = vi + .fn<(...args: Array) => string>() + .mockReturnValue('data') + const memoFn = vi.fn() + + function Page() { + const result = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return ( + queryFn() || { + data: { + nested: true, + }, + } + ) + }, + }) + + React.useMemo(() => { + memoFn() + return result.data + }, [result.data]) + + return ( +
+
status {result.status}
+
isFetching {result.isFetching ? 'true' : 'false'}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('status pending') + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('status success') + fireEvent.click(rendered.getByText('refetch')) + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('isFetching true') + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('isFetching false') + expect(queryFn).toHaveBeenCalledTimes(2) + expect(memoFn).toHaveBeenCalledTimes(2) + }) + + it('should update data upon interval changes', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const [int, setInt] = React.useState(200) + const { data } = useQuery({ + queryKey: key, + queryFn: () => count++, + refetchInterval: int, + }) + + React.useEffect(() => { + if (data === 2) { + setInt(0) + } + }, [data]) + + return
count: {data}
+ } + + const rendered = renderWithClient(queryClient, ) + + // mount + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('count: 0')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(201) + expect(rendered.getByText('count: 1')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(201) + expect(rendered.getByText('count: 2')).toBeInTheDocument() + }) + + it('should refetch in an interval depending on function result', async () => { + const key = queryKey() + let count = 0 + const states: Array> = [] + + function Page() { + const queryInfo = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return count++ + }, + refetchInterval: ({ state: { data = 0 } }) => (data < 2 ? 10 : false), + }) + + states.push(queryInfo) + + return ( +
+

count: {queryInfo.data}

+

status: {queryInfo.status}

+

data: {queryInfo.data}

+

refetch: {queryInfo.isRefetching}

+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(51) + rendered.getByText('count: 2') + + expect(states.length).toEqual(6) + + expect(states).toMatchObject([ + { + status: 'pending', + isFetching: true, + data: undefined, + }, + { + status: 'success', + isFetching: false, + data: 0, + }, + { + status: 'success', + isFetching: true, + data: 0, + }, + { + status: 'success', + isFetching: false, + data: 1, + }, + { + status: 'success', + isFetching: true, + data: 1, + }, + { + status: 'success', + isFetching: false, + data: 2, + }, + ]) + }) + + it('should not interval fetch with a refetchInterval of 0', async () => { + const key = queryKey() + const queryFn = vi.fn(() => 1) + + function Page() { + const queryInfo = useQuery({ + queryKey: key, + queryFn, + refetchInterval: 0, + }) + + return
count: {queryInfo.data}
+ } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('count: 1') + + await vi.advanceTimersByTimeAsync(10) // extra sleep to make sure we're not re-fetching + + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should accept an empty string as query key', async () => { + function Page() { + const result = useQuery({ + queryKey: [''], + queryFn: (ctx) => ctx.queryKey, + }) + return <>{JSON.stringify(result.data)} + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('')).toBeInTheDocument() + }) + + it('should accept an object as query key', async () => { + function Page() { + const result = useQuery({ + queryKey: [{ a: 'a' }], + queryFn: (ctx) => ctx.queryKey, + }) + return <>{JSON.stringify(result.data)} + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('[{"a":"a"}]')).toBeInTheDocument() + }) + + it('should refetch if any query instance becomes enabled', async () => { + const key = queryKey() + + const queryFn = vi + .fn<(...args: Array) => string>() + .mockReturnValue('data') + + function Disabled() { + useQuery({ queryKey: key, queryFn, enabled: false }) + return null + } + + function Page() { + const [enabled, setEnabled] = React.useState(false) + const result = useQuery({ queryKey: key, queryFn, enabled }) + return ( + <> + +
{result.data}
+ + + ) + } + + const rendered = renderWithClient(queryClient, ) + expect(queryFn).toHaveBeenCalledTimes(0) + fireEvent.click(rendered.getByText('enable')) + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('data') + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should use placeholder data while the query loads', async () => { + const key1 = queryKey() + + const states: Array> = [] + + function Page() { + const state = useQuery({ + queryKey: key1, + queryFn: () => 'data', + placeholderData: 'placeholder', + }) + + states.push(state) + + return ( +
+

Data: {state.data}

+
Status: {state.status}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('Data: data') + + expect(states).toMatchObject([ + { + isSuccess: true, + isPlaceholderData: true, + data: 'placeholder', + }, + { + isSuccess: true, + isPlaceholderData: false, + data: 'data', + }, + ]) + }) + + it('should use placeholder data even for disabled queries', async () => { + const key1 = queryKey() + + const states: Array<{ state: UseQueryResult; count: number }> = [] + + function Page() { + const [count, setCount] = React.useState(0) + + const state = useQuery({ + queryKey: key1, + queryFn: () => 'data', + placeholderData: 'placeholder', + enabled: count === 0, + }) + + states.push({ state, count }) + + React.useEffect(() => { + setCount(1) + }, []) + + return ( +
+

Data: {state.data}

+
Status: {state.status}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('Data: data') + + expect(states).toMatchObject([ + { + state: { + isSuccess: true, + isPlaceholderData: true, + data: 'placeholder', + }, + count: 0, + }, + { + state: { + isSuccess: true, + isPlaceholderData: true, + data: 'placeholder', + }, + count: 1, + }, + { + state: { + isSuccess: true, + isPlaceholderData: false, + data: 'data', + }, + count: 1, + }, + ]) + }) + + it('placeholder data should run through select', async () => { + const key1 = queryKey() + + const states: Array> = [] + + function Page() { + const state = useQuery({ + queryKey: key1, + queryFn: () => 1, + placeholderData: 23, + select: (data) => String(data * 2), + }) + + states.push(state) + + return ( +
+

Data: {state.data}

+
Status: {state.status}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('Data: 2') + + expect(states).toMatchObject([ + { + isSuccess: true, + isPlaceholderData: true, + data: '46', + }, + { + isSuccess: true, + isPlaceholderData: false, + data: '2', + }, + ]) + }) + + it('placeholder data function result should run through select', async () => { + const key1 = queryKey() + + const states: Array> = [] + let placeholderFunctionRunCount = 0 + + function Page() { + const state = useQuery({ + queryKey: key1, + queryFn: () => 1, + placeholderData: () => { + placeholderFunctionRunCount++ + return 23 + }, + select: (data) => String(data * 2), + }) + + states.push(state) + + return ( +
+

Data: {state.data}

+
Status: {state.status}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('Data: 2') + + rendered.rerender() + + expect(states).toMatchObject([ + { + isSuccess: true, + isPlaceholderData: true, + data: '46', + }, + { + isSuccess: true, + isPlaceholderData: false, + data: '2', + }, + { + isSuccess: true, + isPlaceholderData: false, + data: '2', + }, + ]) + + expect(placeholderFunctionRunCount).toEqual(1) + }) + + it('select should only run when dependencies change if memoized', async () => { + const key1 = queryKey() + + let selectRun = 0 + + function Page() { + const [count, inc] = React.useReducer((prev) => prev + 1, 2) + + const state = useQuery({ + queryKey: key1, + queryFn: async () => { + await sleep(10) + return 0 + }, + select: React.useCallback( + (data: number) => { + selectRun++ + return `selected ${data + count}` + }, + [count], + ), + placeholderData: 99, + }) + + return ( +
+

Data: {state.data}

+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + rendered.getByText('Data: selected 101') // 99 + 2 + expect(selectRun).toBe(1) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('Data: selected 2') // 0 + 2 + expect(selectRun).toBe(2) + + fireEvent.click(rendered.getByRole('button', { name: /inc/i })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('Data: selected 3') // 0 + 3 + expect(selectRun).toBe(3) + }) + + it('select should always return the correct state', async () => { + const key1 = queryKey() + + function Page() { + const [count, inc] = React.useReducer((prev) => prev + 1, 2) + const [forceValue, forceUpdate] = React.useReducer((prev) => prev + 1, 1) + + const state = useQuery({ + queryKey: key1, + queryFn: async () => { + await sleep(10) + return 0 + }, + + select: React.useCallback( + (data: number) => { + return `selected ${data + count}` + }, + [count], + ), + placeholderData: 99, + }) + + return ( +
+

Data: {state.data}

+

forceValue: {forceValue}

+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('Data: selected 101')).toBeInTheDocument() // 99 + 2 + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('Data: selected 2')).toBeInTheDocument() // 0 + 2 + + fireEvent.click(rendered.getByRole('button', { name: /inc/i })) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('Data: selected 3')).toBeInTheDocument() // 0 + 3 + + fireEvent.click(rendered.getByRole('button', { name: /forceUpdate/i })) + + expect(rendered.getByText('forceValue: 2')).toBeInTheDocument() + // data should still be 3 after an independent re-render + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('Data: selected 3')).toBeInTheDocument() + }) + + it('select should structurally share data', async () => { + const key1 = queryKey() + const states: Array> = [] + + function Page() { + const [forceValue, forceUpdate] = React.useReducer((prev) => prev + 1, 1) + + const state = useQuery({ + queryKey: key1, + queryFn: async () => { + await sleep(10) + return [1, 2] + }, + + select: (res) => res.map((x) => x + 1), + }) + + React.useEffect(() => { + if (state.data) { + states.push(state.data) + } + }, [state.data]) + + return ( +
+

Data: {JSON.stringify(state.data)}

+

forceValue: {forceValue}

+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('Data: [2,3]') + expect(states).toHaveLength(1) + + fireEvent.click(rendered.getByRole('button', { name: /forceUpdate/i })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('forceValue: 2') + rendered.getByText('Data: [2,3]') + + // effect should not be triggered again due to structural sharing + expect(states).toHaveLength(1) + }) + + it('should cancel the query function when there are no more subscriptions', async () => { + const key = queryKey() + let cancelFn: Mock = vi.fn() + + const queryFn = ({ signal }: { signal?: AbortSignal }) => { + const promise = new Promise((resolve, reject) => { + cancelFn = vi.fn(() => reject('Cancelled')) + signal?.addEventListener('abort', cancelFn) + sleep(20).then(() => resolve('OK')) + }) + + return promise + } + + function Page() { + const state = useQuery({ queryKey: key, queryFn }) + return ( +
+

Status: {state.status}

+
+ ) + } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('off') + + expect(cancelFn).toHaveBeenCalled() + }) + + it('should cancel the query if the signal was consumed and there are no more subscriptions', async () => { + const key = queryKey() + const states: Array> = [] + + const queryFn: QueryFunction = async ( + ctx, + ) => { + const [, limit] = ctx.queryKey + const value = limit % 2 && ctx.signal ? 'abort' : `data ${limit}` + await sleep(25) + return value + } + + function Page(props: { limit: number }) { + const state = useQuery({ queryKey: [key, props.limit], queryFn }) + // eslint-disable-next-line react-hooks/immutability + states[props.limit] = state + return ( +
+

Status: {state.status}

+

data: {state.data}

+
+ ) + } + + const rendered = renderWithClient( + queryClient, + + + + + + , + ) + + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('off') + await vi.advanceTimersByTimeAsync(20) + + expect(states).toHaveLength(4) + + expect(queryCache.find({ queryKey: [key, 0] })?.state).toMatchObject({ + data: 'data 0', + status: 'success', + dataUpdateCount: 1, + }) + + expect(queryCache.find({ queryKey: [key, 1] })?.state).toMatchObject({ + data: undefined, + status: 'pending', + fetchStatus: 'idle', + }) + + expect(queryCache.find({ queryKey: [key, 2] })?.state).toMatchObject({ + data: 'data 2', + status: 'success', + dataUpdateCount: 1, + }) + + expect(queryCache.find({ queryKey: [key, 3] })?.state).toMatchObject({ + data: undefined, + status: 'pending', + fetchStatus: 'idle', + }) + }) + + it('should refetch when quickly switching to a failed query', async () => { + const key = queryKey() + const states: Array> = [] + + const queryFn = async () => { + await sleep(50) + return 'OK' + } + + function Page() { + const [id, setId] = React.useState(1) + const [hasChanged, setHasChanged] = React.useState(false) + + const state = useQuery({ queryKey: [key, id], queryFn }) + + states.push(state) + + React.useEffect(() => { + setId((prevId) => (prevId === 1 ? 2 : 1)) + setHasChanged(true) + }, [hasChanged]) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(51) + expect(states.length).toBe(4) + // Load query 1 + expect(states[0]).toMatchObject({ + status: 'pending', + error: null, + }) + // Load query 2 + expect(states[1]).toMatchObject({ + status: 'pending', + error: null, + }) + // Load query 1 + expect(states[2]).toMatchObject({ + status: 'pending', + error: null, + }) + // Loaded query 1 + expect(states[3]).toMatchObject({ + status: 'success', + error: null, + }) + }) + + it('should update query state and refetch when reset with resetQueries', async () => { + const key = queryKey() + const states: Array> = [] + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + count++ + return count + }, + staleTime: Infinity, + }) + + states.push(state) + + return ( +
+ +
data: {state.data ?? 'null'}
+
isFetching: {state.isFetching}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 1') + fireEvent.click(rendered.getByRole('button', { name: /reset/i })) + + await vi.advanceTimersByTimeAsync(11) + expect(states.length).toBe(4) + rendered.getByText('data: 2') + + expect(count).toBe(2) + + expect(states[0]).toMatchObject({ + data: undefined, + isPending: true, + isFetching: true, + isSuccess: false, + isStale: true, + }) + expect(states[1]).toMatchObject({ + data: 1, + isPending: false, + isFetching: false, + isSuccess: true, + isStale: false, + }) + expect(states[2]).toMatchObject({ + data: undefined, + isPending: true, + isFetching: true, + isSuccess: false, + isStale: true, + }) + expect(states[3]).toMatchObject({ + data: 2, + isPending: false, + isFetching: false, + isSuccess: true, + isStale: false, + }) + }) + + it('should update query state and not refetch when resetting a disabled query with resetQueries', async () => { + const key = queryKey() + const states: Array> = [] + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + count++ + return count + }, + staleTime: Infinity, + enabled: false, + notifyOnChangeProps: 'all', + }) + + states.push(state) + + const { refetch } = state + + return ( +
+ + +
data: {state.data ?? 'null'}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('data: null') + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 1') + fireEvent.click(rendered.getByRole('button', { name: /reset/i })) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('data: null') + expect(states.length).toBe(4) + + expect(count).toBe(1) + + expect(states[0]).toMatchObject({ + data: undefined, + isPending: true, + isFetching: false, + isSuccess: false, + isStale: false, + }) + expect(states[1]).toMatchObject({ + data: undefined, + isPending: true, + isFetching: true, + isSuccess: false, + isStale: false, + }) + expect(states[2]).toMatchObject({ + data: 1, + isPending: false, + isFetching: false, + isSuccess: true, + isStale: false, + }) + expect(states[3]).toMatchObject({ + data: undefined, + isPending: true, + isFetching: false, + isSuccess: false, + isStale: false, + }) + }) + + it('should only call the query hash function once each render', async () => { + const key = queryKey() + + let hashes = 0 + let renders = 0 + + function queryKeyHashFn(x: any) { + hashes++ + return JSON.stringify(x) + } + + function Page() { + React.useEffect(() => { + renders++ + }) + + useQuery({ queryKey: key, queryFn: () => 'test', queryKeyHashFn }) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + + expect(renders).toBe(hashes) + }) + + it('should hash query keys that contain bigints given a supported query hash function', async () => { + const key = [queryKey(), 1n] + + function queryKeyHashFn(x: any) { + return JSON.stringify(x, (_, value) => { + if (typeof value === 'bigint') return value.toString() + return value + }) + } + + function Page() { + useQuery({ queryKey: key, queryFn: () => 'test', queryKeyHashFn }) + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + + const query = queryClient.getQueryCache().get(queryKeyHashFn(key)) + expect(query?.state.data).toBe('test') + }) + + it('should refetch when changed enabled to true in error state', async () => { + const queryFn = vi.fn<(...args: Array) => unknown>() + queryFn.mockImplementation(async () => { + await sleep(10) + return Promise.reject(new Error('Suspense Error Bingo')) + }) + + function Page({ enabled }: { enabled: boolean }) { + const { error, isPending } = useQuery({ + queryKey: ['key'], + queryFn, + enabled, + retry: false, + retryOnMount: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + }) + + if (isPending) { + return
status: pending
+ } + if (error instanceof Error) { + return
error
+ } + return
rendered
+ } + + function App() { + const [enabled, toggle] = React.useReducer((x) => !x, true) + + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + // initial state check + rendered.getByText('status: pending') + + // // render error state component + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('error') + expect(queryFn).toBeCalledTimes(1) + + // change to enabled to false + fireEvent.click(rendered.getByLabelText('retry')) + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('error') + expect(queryFn).toBeCalledTimes(1) + + // // change to enabled to true + fireEvent.click(rendered.getByLabelText('retry')) + expect(queryFn).toBeCalledTimes(2) + }) + + it('should refetch when query key changed when previous status is error', async () => { + function Page({ id }: { id: number }) { + const { error, isPending } = useQuery({ + queryKey: [id], + queryFn: async () => { + await sleep(10) + if (id % 2 === 1) { + return Promise.reject(new Error('Error')) + } else { + return 'data' + } + }, + retry: false, + retryOnMount: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + }) + + if (isPending) { + return
status: pending
+ } + if (error instanceof Error) { + return
error
+ } + return
rendered
+ } + + function App() { + const [id, changeId] = React.useReducer((x) => x + 1, 1) + + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + // initial state check + expect(rendered.getByText('status: pending')).toBeInTheDocument() + + // render error state component + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error')).toBeInTheDocument() + + // change to unmount query + fireEvent.click(rendered.getByLabelText('change')) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('rendered')).toBeInTheDocument() + + // change to mount new query + fireEvent.click(rendered.getByLabelText('change')) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error')).toBeInTheDocument() + }) + + it('should refetch when query key changed when switching between erroneous queries', async () => { + function Page({ id }: { id: boolean }) { + const { error, isFetching } = useQuery({ + queryKey: [id], + queryFn: async () => { + await sleep(10) + return Promise.reject(new Error('Error')) + }, + retry: false, + retryOnMount: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + }) + + if (isFetching) { + return
status: fetching
+ } + if (error instanceof Error) { + return
error
+ } + return
rendered
+ } + + function App() { + const [value, toggle] = React.useReducer((x) => !x, true) + + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + // initial state check + expect(rendered.getByText('status: fetching')).toBeInTheDocument() + + // render error state component + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error')).toBeInTheDocument() + + // change to mount second query + fireEvent.click(rendered.getByLabelText('change')) + expect(rendered.getByText('status: fetching')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error')).toBeInTheDocument() + + // change to mount first query again + fireEvent.click(rendered.getByLabelText('change')) + expect(rendered.getByText('status: fetching')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('error')).toBeInTheDocument() + }) + + it('should have no error in pending state when refetching after error occurred', async () => { + const key = queryKey() + const states: Array> = [] + const error = new Error('oops') + + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + if (count === 0) { + count++ + throw error + } + return 5 + }, + retry: false, + }) + + states.push(state) + + if (state.isPending) { + return
status: pending
+ } + if (state.error instanceof Error) { + return ( +
+
error
+ +
+ ) + } + return
data: {state.data}
+ } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('error') + + fireEvent.click(rendered.getByRole('button', { name: 'refetch' })) + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 5') + + expect(states.length).toBe(4) + + expect(states[0]).toMatchObject({ + status: 'pending', + data: undefined, + error: null, + }) + + expect(states[1]).toMatchObject({ + status: 'error', + data: undefined, + error, + }) + + expect(states[2]).toMatchObject({ + status: 'pending', + data: undefined, + error: null, + }) + + expect(states[3]).toMatchObject({ + status: 'success', + data: 5, + error: null, + }) + }) + + describe('networkMode online', () => { + it('online queries should not start fetching if you are offline', async () => { + const onlineMock = mockOnlineManagerIsOnline(false) + + const key = queryKey() + const states: Array = [] + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return 'data' + }, + }) + + React.useEffect(() => { + states.push(state.fetchStatus) + }) + + return ( +
+
+ status: {state.status}, isPaused: {String(state.isPaused)} +
+
data: {state.data}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('status: pending, isPaused: true') + + onlineMock.mockReturnValue(true) + queryClient.getQueryCache().onOnline() + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('status: success, isPaused: false') + expect(rendered.getByText('data: data')).toBeInTheDocument() + + expect(states).toEqual(['paused', 'fetching', 'idle']) + onlineMock.mockRestore() + }) + + it('online queries should not refetch if you are offline', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus}, + failureCount: {state.failureCount} +
+
failureReason: {state.failureReason ?? 'null'}
+
data: {state.data}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: data1') + + const onlineMock = mockOnlineManagerIsOnline(false) + + fireEvent.click(rendered.getByRole('button', { name: /invalidate/i })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText( + 'status: success, fetchStatus: paused, failureCount: 0', + ) + rendered.getByText('failureReason: null') + + onlineMock.mockReturnValue(true) + queryClient.getQueryCache().onOnline() + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText( + 'status: success, fetchStatus: fetching, failureCount: 0', + ) + rendered.getByText('failureReason: null') + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('status: success, fetchStatus: idle, failureCount: 0') + rendered.getByText('failureReason: null') + + expect(rendered.getByText('data: data2')).toBeInTheDocument() + + onlineMock.mockRestore() + }) + + it('online queries should not refetch if you are offline and refocus', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: data1') + + const onlineMock = mockOnlineManagerIsOnline(false) + fireEvent.click(rendered.getByRole('button', { name: /invalidate/i })) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('status: success, fetchStatus: paused') + + window.dispatchEvent(new Event('visibilitychange')) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.queryByText('data: data2')).not.toBeInTheDocument() + expect(count).toBe(1) + onlineMock.mockRestore() + }) + + it('online queries should not refetch while already paused', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+ +
+ ) + } + + const onlineMock = mockOnlineManagerIsOnline(false) + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('status: pending, fetchStatus: paused') + + fireEvent.click(rendered.getByRole('button', { name: /invalidate/i })) + + await vi.advanceTimersByTimeAsync(11) + // invalidation should not trigger a refetch + rendered.getByText('status: pending, fetchStatus: paused') + + expect(count).toBe(0) + onlineMock.mockRestore() + }) + + it('online queries should not refetch while already paused if data is in the cache', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + initialData: 'initial', + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+ +
+ ) + } + + const onlineMock = mockOnlineManagerIsOnline(false) + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('status: success, fetchStatus: paused') + expect(rendered.getByText('data: initial')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /invalidate/i })) + + await vi.advanceTimersByTimeAsync(11) + + // invalidation should not trigger a refetch + rendered.getByText('status: success, fetchStatus: paused') + + expect(count).toBe(0) + onlineMock.mockRestore() + }) + + it('online queries should not get stuck in fetching state when pausing multiple times', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + initialData: 'initial', + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+ +
+ ) + } + + const onlineMock = mockOnlineManagerIsOnline(false) + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('status: success, fetchStatus: paused') + expect(rendered.getByText('data: initial')).toBeInTheDocument() + + // triggers one pause + fireEvent.click(rendered.getByRole('button', { name: /invalidate/i })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('status: success, fetchStatus: paused') + + // triggers a second pause + act(() => { + window.dispatchEvent(new Event('visibilitychange')) + }) + + onlineMock.mockReturnValue(true) + queryClient.getQueryCache().onOnline() + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('status: success, fetchStatus: idle') + expect(rendered.getByText('data: data1')).toBeInTheDocument() + + expect(count).toBe(1) + + onlineMock.mockRestore() + }) + + it('online queries should pause retries if you are offline', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async (): Promise => { + count++ + await sleep(10) + throw new Error('failed' + count) + }, + retry: 2, + retryDelay: 10, + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus}, + failureCount: {state.failureCount} +
+
failureReason: {state.failureReason?.message ?? 'null'}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText(/status: pending, fetchStatus: fetching/i) + + const onlineMock = mockOnlineManagerIsOnline(false) + + await vi.advanceTimersByTimeAsync(31) + + rendered.getByText( + 'status: pending, fetchStatus: paused, failureCount: 1', + ) + rendered.getByText('failureReason: failed1') + + expect(count).toBe(1) + + onlineMock.mockReturnValue(true) + queryClient.getQueryCache().onOnline() + + await vi.advanceTimersByTimeAsync(31) + rendered.getByText('status: error, fetchStatus: idle, failureCount: 3') + rendered.getByText('failureReason: failed3') + + expect(count).toBe(3) + + onlineMock.mockRestore() + }) + + it('online queries should fetch if paused and we go online even if already unmounted (because not cancelled)', async () => { + const key = queryKey() + let count = 0 + + function Component() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+
+ ) + } + + function Page() { + const [show, setShow] = React.useState(true) + + return ( +
+ {show && } + +
+ ) + } + + const onlineMock = mockOnlineManagerIsOnline(false) + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('status: pending, fetchStatus: paused') + + fireEvent.click(rendered.getByRole('button', { name: /hide/i })) + + onlineMock.mockReturnValue(true) + queryClient.getQueryCache().onOnline() + + await vi.advanceTimersByTimeAsync(11) + expect(queryClient.getQueryState(key)).toMatchObject({ + fetchStatus: 'idle', + status: 'success', + }) + + // give it a bit more time to make sure queryFn is not called again + expect(count).toBe(1) + + onlineMock.mockRestore() + }) + + it('online queries should not fetch if paused and we go online when cancelled and no refetchOnReconnect', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + refetchOnReconnect: false, + }) + + return ( +
+ +
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+
+ ) + } + + const onlineMock = mockOnlineManagerIsOnline(false) + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('status: pending, fetchStatus: paused') + + fireEvent.click(rendered.getByRole('button', { name: /cancel/i })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('status: pending, fetchStatus: idle') + + expect(count).toBe(0) + + onlineMock.mockReturnValue(true) + queryClient.getQueryCache().onOnline() + + await vi.advanceTimersByTimeAsync(11) + + rendered.getByText('status: pending, fetchStatus: idle') + + expect(count).toBe(0) + + onlineMock.mockRestore() + }) + + it('online queries should not fetch if paused and we go online if already unmounted when signal consumed', async () => { + const key = queryKey() + let count = 0 + + function Component() { + const state = useQuery({ + queryKey: key, + queryFn: async ({ signal: _signal }) => { + count++ + await sleep(10) + return `signal${count}` + }, + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+
+ ) + } + + function Page() { + const [show, setShow] = React.useState(true) + + return ( +
+ {show && } + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('status: success, fetchStatus: idle') + + const onlineMock = mockOnlineManagerIsOnline(false) + + fireEvent.click(rendered.getByRole('button', { name: /invalidate/i })) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('status: success, fetchStatus: paused') + + fireEvent.click(rendered.getByRole('button', { name: /hide/i })) + + onlineMock.mockReturnValue(true) + queryClient.getQueryCache().onOnline() + + await vi.advanceTimersByTimeAsync(11) + + expect(queryClient.getQueryState(key)).toMatchObject({ + fetchStatus: 'idle', + status: 'success', + }) + + expect(count).toBe(1) + + onlineMock.mockRestore() + }) + }) + + describe('networkMode always', () => { + it('always queries should start fetching even if you are offline', async () => { + const onlineMock = mockOnlineManagerIsOnline(false) + + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data ' + count + }, + networkMode: 'always', + }) + + return ( +
+
+ status: {state.status}, isPaused: {String(state.isPaused)} +
+
data: {state.data}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('status: success, isPaused: false') + expect(rendered.getByText('data: data 1')).toBeInTheDocument() + + onlineMock.mockRestore() + }) + + it('always queries should not pause retries', async () => { + const onlineMock = mockOnlineManagerIsOnline(false) + + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async (): Promise => { + count++ + await sleep(10) + throw new Error('error ' + count) + }, + networkMode: 'always', + retry: 1, + retryDelay: 5, + }) + + return ( +
+
+ status: {state.status}, isPaused: {String(state.isPaused)} +
+
+ error: {state.error instanceof Error && state.error.message} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(26) + rendered.getByText('status: error, isPaused: false') + expect(rendered.getByText('error: error 2')).toBeInTheDocument() + + expect(count).toBe(2) + + onlineMock.mockRestore() + }) + }) + + describe('networkMode offlineFirst', () => { + it('offlineFirst queries should start fetching if you are offline, but pause retries', async () => { + const onlineMock = mockOnlineManagerIsOnline(false) + + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async (): Promise => { + count++ + await sleep(10) + throw new Error('failed' + count) + }, + retry: 2, + retryDelay: 1, + networkMode: 'offlineFirst', + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus}, + failureCount: {state.failureCount} +
+
failureReason: {state.failureReason?.message ?? 'null'}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(12) + rendered.getByText( + 'status: pending, fetchStatus: paused, failureCount: 1', + ) + rendered.getByText('failureReason: failed1') + + expect(count).toBe(1) + + onlineMock.mockReturnValue(true) + queryClient.getQueryCache().onOnline() + + await vi.advanceTimersByTimeAsync(22) + rendered.getByText('status: error, fetchStatus: idle, failureCount: 3') + rendered.getByText('failureReason: failed3') + + expect(count).toBe(3) + onlineMock.mockRestore() + }) + }) + + describe('subscribed', () => { + it('should be able to toggle subscribed', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('data')) + function Page() { + const [subscribed, setSubscribed] = React.useState(true) + const { data } = useQuery({ + queryKey: key, + queryFn, + subscribed, + }) + return ( +
+ data: {data} + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('data: data') + + expect( + queryClient.getQueryCache().find({ queryKey: key })!.observers.length, + ).toBe(1) + + fireEvent.click(rendered.getByRole('button', { name: 'toggle' })) + + expect( + queryClient.getQueryCache().find({ queryKey: key })!.observers.length, + ).toBe(0) + + expect(queryFn).toHaveBeenCalledTimes(1) + + fireEvent.click(rendered.getByRole('button', { name: 'toggle' })) + + // background refetch when we re-subscribe + await vi.advanceTimersByTimeAsync(0) + expect(queryFn).toHaveBeenCalledTimes(2) + expect( + queryClient.getQueryCache().find({ queryKey: key })!.observers.length, + ).toBe(1) + }) + + it('should not be attached to the query when subscribed is false', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('data')) + function Page() { + const { data } = useQuery({ + queryKey: key, + queryFn, + subscribed: false, + }) + return ( +
+ data: {data} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('data:') + + expect( + queryClient.getQueryCache().find({ queryKey: key })!.observers.length, + ).toBe(0) + + expect(queryFn).toHaveBeenCalledTimes(0) + }) + + it('should not re-render when data is added to the cache when subscribed is false', async () => { + const key = queryKey() + let renders = 0 + function Page() { + const { data } = useQuery({ + queryKey: key, + queryFn: () => Promise.resolve('data'), + subscribed: false, + }) + renders++ + return ( +
+ {data ? 'has data' + data : 'no data'} + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('no data') + + fireEvent.click(rendered.getByRole('button', { name: 'set data' })) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('no data') + + expect(renders).toBe(1) + }) + }) + + it('should have status=error on mount when a query has failed', async () => { + const key = queryKey() + const states: Array> = [] + const error = new Error('oops') + + const queryFn = (): Promise => { + return Promise.reject(error) + } + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn, + retry: false, + retryOnMount: false, + }) + + states.push(state) + + return <> + } + + await queryClient.prefetchQuery({ queryKey: key, queryFn }) + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(states).toHaveLength(1) + + expect(states[0]).toMatchObject({ + status: 'error', + error, + }) + }) + + it('setQueryData - should respect updatedAt', async () => { + const key = queryKey() + + function Page() { + const state = useQuery({ queryKey: key, queryFn: () => 'data' }) + return ( +
+
data: {state.data}
+
dataUpdatedAt: {state.dataUpdatedAt}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('data: data') + fireEvent.click(rendered.getByRole('button', { name: /setQueryData/i })) + await vi.advanceTimersByTimeAsync(0) + rendered.getByText('data: newData') + expect(rendered.getByText('dataUpdatedAt: 100')).toBeInTheDocument() + }) + + it('errorUpdateCount should increased on each fetch failure', async () => { + const key = queryKey() + const error = new Error('oops') + + function Page() { + const { refetch, errorUpdateCount } = useQuery({ + queryKey: key, + queryFn: (): Promise => { + return Promise.reject(error) + }, + retry: false, + }) + return ( +
+ + data: {errorUpdateCount} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + const fetchBtn = rendered.getByRole('button', { name: 'refetch' }) + expect(rendered.getByText('data: 1')).toBeInTheDocument() + fireEvent.click(fetchBtn) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('data: 2')).toBeInTheDocument() + fireEvent.click(fetchBtn) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('data: 3')).toBeInTheDocument() + }) + + it('should use provided custom queryClient', async () => { + const key = queryKey() + const queryFn = async () => { + return Promise.resolve('custom client') + } + + function Page() { + const { data } = useQuery( + { + queryKey: key, + queryFn, + }, + queryClient, + ) + + return
data: {data}
+ } + + const rendered = render() + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('data: custom client')).toBeInTheDocument() + }) + + it('should be notified of updates between create and subscribe', async () => { + const key = queryKey() + + function Page() { + const { data, status } = useQuery({ + enabled: false, + queryKey: key, + queryFn: async () => { + await sleep(10) + return 5 + }, + }) + + const mounted = React.useRef(null) + // this simulates a synchronous update between the time the query is created + // and the time it is subscribed to that could be missed otherwise + if (mounted.current === null) { + mounted.current = true + queryClient.setQueryData(key, 1) + } + + return ( +
+ status: {status} + data: {data} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('status: success')).toBeInTheDocument() + expect(rendered.getByText('data: 1')).toBeInTheDocument() + }) + it('should reuse same data object reference when queryKey changes back to some cached data', async () => { + const key = queryKey() + const spy = vi.fn() + + async function fetchNumber(id: number) { + await sleep(5) + return { numbers: { current: { id } } } + } + function Test() { + const [id, setId] = React.useState(1) + + const { data } = useQuery({ + select: selector, + queryKey: [key, 'user', id], + queryFn: () => fetchNumber(id), + }) + + React.useEffect(() => { + spy(data) + }, [data]) + + return ( +
+ + + Rendered Id: {data?.id} +
+ ) + } + + function selector(data: any) { + return data.numbers.current + } + + const rendered = renderWithClient(queryClient, ) + expect(spy).toHaveBeenCalledTimes(1) + + spy.mockClear() + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('Rendered Id: 1') + expect(spy).toHaveBeenCalledTimes(1) + + spy.mockClear() + fireEvent.click(rendered.getByRole('button', { name: /2/ })) + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('Rendered Id: 2') + expect(spy).toHaveBeenCalledTimes(2) // called with undefined because id changed + + spy.mockClear() + fireEvent.click(rendered.getByRole('button', { name: /1/ })) + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('Rendered Id: 1') + expect(spy).toHaveBeenCalledTimes(1) + + spy.mockClear() + fireEvent.click(rendered.getByRole('button', { name: /2/ })) + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('Rendered Id: 2') + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should reuse same data object reference when queryKey changes and placeholderData is present', async () => { + const key = queryKey() + const spy = vi.fn() + + async function fetchNumber(id: number) { + await sleep(5) + return { numbers: { current: { id } } } + } + function Test() { + const [id, setId] = React.useState(1) + + const { data } = useQuery({ + select: selector, + queryKey: [key, 'user', id], + queryFn: () => fetchNumber(id), + placeholderData: { numbers: { current: { id: 99 } } }, + }) + + React.useEffect(() => { + spy(data) + }, [data]) + + return ( +
+ + + Rendered Id: {data?.id} +
+ ) + } + + function selector(data: any) { + return data.numbers.current + } + + const rendered = renderWithClient(queryClient, ) + expect(spy).toHaveBeenCalledTimes(1) + + spy.mockClear() + rendered.getByText('Rendered Id: 99') + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('Rendered Id: 1') + expect(spy).toHaveBeenCalledTimes(1) + + spy.mockClear() + fireEvent.click(rendered.getByRole('button', { name: /2/ })) + rendered.getByText('Rendered Id: 99') + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('Rendered Id: 2') + expect(spy).toHaveBeenCalledTimes(2) // called with undefined because id changed + + spy.mockClear() + fireEvent.click(rendered.getByRole('button', { name: /1/ })) + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('Rendered Id: 1') + expect(spy).toHaveBeenCalledTimes(1) + + spy.mockClear() + fireEvent.click(rendered.getByRole('button', { name: /2/ })) + await vi.advanceTimersByTimeAsync(6) + rendered.getByText('Rendered Id: 2') + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should not cause an infinite render loop when using unstable callback ref', async () => { + const key = queryKey() + + function Test() { + const [_, setRef] = React.useState() + + const { data } = useQuery({ + queryKey: [key], + queryFn: async () => { + await sleep(5) + return 'Works' + }, + }) + + return
setRef(value)}>{data}
+ } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(6) + expect(rendered.getByText('Works')).toBeInTheDocument() + }) + + it('should keep the previous data when placeholderData is set and cache is used', async () => { + const key = queryKey() + const states: Array> = [] + const steps = [0, 1, 0, 2] + + function Page() { + const [count, setCount] = React.useState(0) + + const state = useQuery({ + staleTime: Infinity, + queryKey: [key, steps[count]], + queryFn: async () => { + await sleep(10) + return steps[count] + }, + placeholderData: keepPreviousData, + }) + + states.push(state) + + return ( +
+
data: {state.data}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 0') + + fireEvent.click(rendered.getByRole('button', { name: 'setCount' })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 1') + + fireEvent.click(rendered.getByRole('button', { name: 'setCount' })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 0') + + fireEvent.click(rendered.getByRole('button', { name: 'setCount' })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: 2') + + // Initial + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + isSuccess: false, + isPlaceholderData: false, + }) + // Fetched + expect(states[1]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + // Set state + expect(states[2]).toMatchObject({ + data: 0, + isFetching: true, + isSuccess: true, + isPlaceholderData: true, + }) + // New data + expect(states[3]).toMatchObject({ + data: 1, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + // Set state with existing data + expect(states[4]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + // Set state where the placeholder value should come from cache request + expect(states[5]).toMatchObject({ + data: 0, + isFetching: true, + isSuccess: true, + isPlaceholderData: true, + }) + // New data + expect(states[6]).toMatchObject({ + data: 2, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + }) + + // For Project without TS, when migrating from v4 to v5, make sure invalid calls due to bad parameters are tracked. + it('should throw in case of bad arguments to enhance DevX', () => { + // Mock console error to avoid noise when test is run + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const key = queryKey() + const queryFn = () => 'data' + + function Page() { + // Invalid call on purpose + // @ts-expect-error + useQuery(key, { queryFn }) + return
Does not matter
+ } + + expect(() => render()).toThrow('Bad argument type') + consoleMock.mockRestore() + }) + + it('should respect skipToken and refetch when skipToken is taken away', async () => { + const key = queryKey() + + function Page({ enabled }: { enabled: boolean }) { + const { data, status } = useQuery({ + queryKey: [key], + queryFn: enabled + ? async () => { + await sleep(10) + + return Promise.resolve('data') + } + : skipToken, + retry: false, + retryOnMount: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + }) + + return ( +
+
status: {status}
+
data: {String(data)}
+
+ ) + } + + function App() { + const [enabled, toggle] = React.useReducer((x) => !x, false) + + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('status: pending')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: 'enable' })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('status: success')).toBeInTheDocument() + expect(rendered.getByText('data: data')).toBeInTheDocument() + }) + + it('should allow enabled: true and queryFn: skipToken', () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + function App() { + const query = useQuery({ + queryKey: key, + queryFn: skipToken, + enabled: true, + }) + + return ( +
+
+ status: {query.status}, fetchStatus: {query.fetchStatus} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('status: pending, fetchStatus: idle') + + // no warnings expected about skipToken / missing queryFn + expect(consoleMock).toHaveBeenCalledTimes(0) + consoleMock.mockRestore() + }) + + it('should return correct optimistic result when fetching after error', async () => { + const key = queryKey() + const error = new Error('oh no') + + const results: Array> = [] + + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return Promise.reject(error) + }, + retry: false, + notifyOnChangeProps: 'all', + }) + + results.push(query) + + return ( +
+
+ status: {query.status}, {query.fetchStatus} +
+
error: {query.error?.message}
+
+ ) + } + + function App() { + const [enabled, setEnabled] = React.useState(true) + + return ( +
+ + {enabled && } +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('status: error, idle') + + fireEvent.click(rendered.getByRole('button', { name: 'toggle' })) + fireEvent.click(rendered.getByRole('button', { name: 'toggle' })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('status: error, idle') + + expect(results).toHaveLength(4) + + // initial fetch + expect(results[0]).toMatchObject({ + status: 'pending', + fetchStatus: 'fetching', + error: null, + errorUpdatedAt: 0, + errorUpdateCount: 0, + isLoading: true, + failureCount: 0, + failureReason: null, + }) + + // error state + expect(results[1]).toMatchObject({ + status: 'error', + fetchStatus: 'idle', + error, + errorUpdateCount: 1, + isLoading: false, + failureCount: 1, + failureReason: error, + }) + expect(results[1]?.errorUpdatedAt).toBeGreaterThan(0) + + // refetch, optimistic state, no errors anymore + expect(results[2]).toMatchObject({ + status: 'pending', + fetchStatus: 'fetching', + error: null, + errorUpdateCount: 1, + isLoading: true, + failureCount: 0, + failureReason: null, + }) + expect(results[2]?.errorUpdatedAt).toBeGreaterThan(0) + + // final state + expect(results[3]).toMatchObject({ + status: 'error', + fetchStatus: 'idle', + error: error, + errorUpdateCount: 2, + isLoading: false, + failureCount: 1, + failureReason: error, + }) + expect(results[3]?.errorUpdatedAt).toBeGreaterThan(0) + }) + + it('should pick up an initialPromise', async () => { + const key = queryKey() + + const serverQueryClient = new QueryClient({ + defaultOptions: { dehydrate: { shouldDehydrateQuery: () => true } }, + }) + + void serverQueryClient.prefetchQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return Promise.resolve('server') + }, + }) + + const dehydrated = dehydrate(serverQueryClient) + + let count = 0 + + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return Promise.resolve('client') + }, + }) + + return ( +
+
data: {query.data}
+ +
+ ) + } + + const clientQueryClient = new QueryClient() + hydrate(clientQueryClient, dehydrated) + + const rendered = renderWithClient(clientQueryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: server') + expect(count).toBe(0) + + fireEvent.click(rendered.getByRole('button', { name: 'refetch' })) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('data: client') + expect(count).toBe(1) + }) + + it('should retry failed initialPromise on the client', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + const serverQueryClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + void serverQueryClient.prefetchQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return Promise.reject(new Error('server error')) + }, + }) + + const dehydrated = dehydrate(serverQueryClient) + + let count = 0 + + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return Promise.resolve('client') + }, + }) + + return ( +
+
failure: {query.failureReason?.message}
+
data: {query.data}
+
+ ) + } + + const clientQueryClient = new QueryClient({ + defaultOptions: { hydrate: { queries: { retry: 1, retryDelay: 10 } } }, + }) + hydrate(clientQueryClient, dehydrated) + + const rendered = renderWithClient(clientQueryClient, ) + + await vi.advanceTimersByTimeAsync(11) + rendered.getByText('failure: redacted') + await vi.advanceTimersByTimeAsync(21) + rendered.getByText('data: client') + expect(count).toBe(1) + + const query = clientQueryClient.getQueryCache().find({ queryKey: key }) + + expect(consoleMock).toHaveBeenCalledTimes(1) + expect(consoleMock).toHaveBeenCalledWith( + `A query that was dehydrated as pending ended up rejecting. [${query?.queryHash}]: Error: server error; The error will be redacted in production builds`, + ) + + consoleMock.mockRestore() + }) + + it('should console.error when there is no queryFn', () => { + const consoleErrorMock = vi.spyOn(console, 'error') + const key = queryKey() + function Example() { + useQuery({ queryKey: key }) + return <> + } + renderWithClient(queryClient, ) + + expect(consoleErrorMock).toHaveBeenCalledTimes(1) + expect(consoleErrorMock).toHaveBeenCalledWith( + `[${queryClient.getQueryCache().find({ queryKey: key })?.queryHash}]: No queryFn was passed as an option, and no default queryFn was found. The queryFn parameter is only optional when using a default queryFn. More info here: https://tanstack.com/query/latest/docs/framework/react/guides/default-query-function`, + ) + + consoleErrorMock.mockRestore() + }) +}) diff --git a/packages/preact-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx b/packages/preact-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx new file mode 100644 index 0000000000..49cdb7939b --- /dev/null +++ b/packages/preact-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx @@ -0,0 +1,93 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest' +import { skipToken } from '@tanstack/query-core' +import { useSuspenseInfiniteQuery } from '../useSuspenseInfiniteQuery' +import type { InfiniteData } from '@tanstack/query-core' + +describe('useSuspenseInfiniteQuery', () => { + it('should always have data defined', () => { + const { data } = useSuspenseInfiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + initialPageParam: 1, + getNextPageParam: () => 1, + }) + + expectTypeOf(data).toEqualTypeOf>() + }) + + it('should not allow skipToken in queryFn', () => { + assertType( + useSuspenseInfiniteQuery({ + queryKey: ['key'], + // @ts-expect-error + queryFn: skipToken, + }), + ) + + assertType( + useSuspenseInfiniteQuery({ + queryKey: ['key'], + // @ts-expect-error + queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), + }), + ) + }) + + it('should not have pending status', () => { + const { status } = useSuspenseInfiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + initialPageParam: 1, + getNextPageParam: () => 1, + }) + + expectTypeOf(status).toEqualTypeOf<'error' | 'success'>() + }) + + it('should not allow placeholderData, enabled or throwOnError props', () => { + assertType( + useSuspenseInfiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + initialPageParam: 1, + getNextPageParam: () => 1, + // @ts-expect-error TS2345 + placeholderData: 5, + enabled: true, + }), + ) + + assertType( + useSuspenseInfiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + initialPageParam: 1, + getNextPageParam: () => 1, + // @ts-expect-error TS2345 + enabled: true, + }), + ) + + assertType( + useSuspenseInfiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + initialPageParam: 1, + getNextPageParam: () => 1, + // @ts-expect-error TS2345 + throwOnError: true, + }), + ) + }) + + it('should not return isPlaceholderData', () => { + const query = useSuspenseInfiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + initialPageParam: 1, + getNextPageParam: () => 1, + }) + + expectTypeOf(query).not.toHaveProperty('isPlaceholderData') + }) +}) diff --git a/packages/preact-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx b/packages/preact-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx new file mode 100644 index 0000000000..277bdc0b7e --- /dev/null +++ b/packages/preact-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' +import * as React from 'react' +import { queryKey } from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + skipToken, + useSuspenseInfiniteQuery, +} from '..' +import { renderWithClient } from './utils' + +describe('useSuspenseInfiniteQuery', () => { + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + + it('should log an error when skipToken is passed as queryFn', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + const key = queryKey() + + function Page() { + useSuspenseInfiniteQuery({ + queryKey: key, + initialPageParam: 1, + getNextPageParam: () => 1, + // @ts-expect-error + // eslint-disable-next-line react-hooks/purity + queryFn: Math.random() >= 0 ? skipToken : () => Promise.resolve(5), + }) + + return null + } + + function App() { + return ( + + + + ) + } + + renderWithClient(queryClient, ) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'skipToken is not allowed for useSuspenseInfiniteQuery', + ) + consoleErrorSpy.mockRestore() + }) + + it('should log an error when skipToken is used in development environment', () => { + const envCopy = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + function Page() { + useSuspenseInfiniteQuery({ + queryKey: key, + queryFn: skipToken as any, + initialPageParam: 1, + getNextPageParam: () => 1, + }) + + return null + } + + renderWithClient( + queryClient, + + + , + ) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'skipToken is not allowed for useSuspenseInfiniteQuery', + ) + + consoleErrorSpy.mockRestore() + process.env.NODE_ENV = envCopy + }) + + it('should not log an error when skipToken is used in production environment', () => { + const envCopy = process.env.NODE_ENV + process.env.NODE_ENV = 'production' + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + function Page() { + useSuspenseInfiniteQuery({ + queryKey: key, + queryFn: skipToken as any, + initialPageParam: 1, + getNextPageParam: () => 1, + }) + + return null + } + + renderWithClient( + queryClient, + + + , + ) + + expect(consoleErrorSpy).not.toHaveBeenCalled() + + consoleErrorSpy.mockRestore() + process.env.NODE_ENV = envCopy + }) +}) diff --git a/packages/preact-query/src/__tests__/useSuspenseQueries.test-d.tsx b/packages/preact-query/src/__tests__/useSuspenseQueries.test-d.tsx new file mode 100644 index 0000000000..76fb13a9f5 --- /dev/null +++ b/packages/preact-query/src/__tests__/useSuspenseQueries.test-d.tsx @@ -0,0 +1,256 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest' +import { skipToken, useSuspenseQueries } from '..' +import { queryOptions } from '../queryOptions' +import type { OmitKeyof } from '..' +import type { UseQueryOptions, UseSuspenseQueryResult } from '../types' + +describe('UseSuspenseQueries config object overload', () => { + it('TData should always be defined', () => { + const query1 = { + queryKey: ['key1'], + queryFn: () => { + return { + wow: true, + } + }, + initialData: { + wow: false, + }, + } + + const query2 = { + queryKey: ['key2'], + queryFn: () => 'Query Data', + } + + const queryResults = useSuspenseQueries({ queries: [query1, query2] }) + + const query1Data = queryResults[0].data + const query2Data = queryResults[1].data + + expectTypeOf(query1Data).toEqualTypeOf<{ wow: boolean }>() + expectTypeOf(query2Data).toEqualTypeOf() + }) + + it('TData should be defined when passed through queryOptions', () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: () => { + return { + wow: true, + } + }, + }) + const queryResults = useSuspenseQueries({ queries: [options] }) + + const data = queryResults[0].data + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() + }) + + it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQuery', () => { + const query1 = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + select: (data) => data > 1, + }) + + const query2 = { + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + select: (data: number) => data > 1, + } + + const queryResults = useSuspenseQueries({ queries: [query1, query2] }) + const query1Data = queryResults[0].data + const query2Data = queryResults[1].data + + expectTypeOf(query1Data).toEqualTypeOf() + expectTypeOf(query2Data).toEqualTypeOf() + }) + + it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => { + const queryResults = useSuspenseQueries({ + queries: [ + { + queryKey: ['key'], + queryFn: () => { + return { + wow: true, + } + }, + initialData: () => undefined as { wow: boolean } | undefined, + }, + ], + }) + + const data = queryResults[0].data + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() + }) + + it('should not allow skipToken in queryFn', () => { + assertType( + useSuspenseQueries({ + queries: [ + { + queryKey: ['key'], + // @ts-expect-error + queryFn: skipToken, + }, + ], + }), + ) + + assertType( + useSuspenseQueries({ + queries: [ + { + queryKey: ['key'], + // @ts-expect-error + queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), + }, + ], + }), + ) + }) + + it('TData should have correct type when conditional skipToken is passed', () => { + const queryResults = useSuspenseQueries({ + queries: [ + { + queryKey: ['withSkipToken'], + // @ts-expect-error + queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), + }, + ], + }) + + const firstResult = queryResults[0] + + expectTypeOf(firstResult).toEqualTypeOf< + UseSuspenseQueryResult + >() + expectTypeOf(firstResult.data).toEqualTypeOf() + }) + + describe('custom hook', () => { + it('should allow custom hooks using UseQueryOptions', () => { + type Data = string + + const useCustomQueries = ( + options?: OmitKeyof, 'queryKey' | 'queryFn'>, + ) => { + return useSuspenseQueries({ + queries: [ + { + ...options, + queryKey: ['todos-key'], + queryFn: () => Promise.resolve('data'), + }, + ], + }) + } + + const queryResults = useCustomQueries() + const data = queryResults[0].data + + expectTypeOf(data).toEqualTypeOf() + }) + }) + + it('should return correct data for dynamic queries with mixed result types', () => { + const Queries1 = { + get: () => + queryOptions({ + queryKey: ['key1'], + queryFn: () => Promise.resolve(1), + }), + } + const Queries2 = { + get: () => + queryOptions({ + queryKey: ['key2'], + queryFn: () => Promise.resolve(true), + }), + } + + const queries1List = [1, 2, 3].map(() => ({ ...Queries1.get() })) + const result = useSuspenseQueries({ + queries: [ + ...queries1List, + { + ...Queries2.get(), + select(data: boolean) { + return data + }, + }, + ], + }) + + expectTypeOf(result).toEqualTypeOf< + [ + ...Array>, + UseSuspenseQueryResult, + ] + >() + }) + + it('queryOptions with initialData works on useSuspenseQueries', () => { + const query1 = queryOptions({ + queryKey: ['key1'], + queryFn: () => 'Query Data', + initialData: 'initial data', + }) + + const queryResults = useSuspenseQueries({ queries: [query1] }) + const query1Data = queryResults[0].data + + expectTypeOf(query1Data).toEqualTypeOf() + }) + + it('queryOptions with skipToken in queryFn should not work on useSuspenseQueries', () => { + assertType( + useSuspenseQueries({ + queries: [ + // @ts-expect-error + queryOptions({ + queryKey: ['key1'], + queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), + }), + ], + }), + ) + + assertType( + useSuspenseQueries({ + queries: [ + // @ts-expect-error + queryOptions({ + queryKey: ['key1'], + queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), + initialData: 5, + }), + ], + }), + ) + }) + + it('should not show type error when using rest queryOptions', () => { + assertType( + useSuspenseQueries({ + queries: [ + { + ...queryOptions({ + queryKey: ['key1'], + queryFn: () => 'Query Data', + }), + select(data: string) { + return data + }, + }, + ], + }), + ) + }) +}) diff --git a/packages/preact-query/src/__tests__/useSuspenseQueries.test.tsx b/packages/preact-query/src/__tests__/useSuspenseQueries.test.tsx new file mode 100644 index 0000000000..7b523aea99 --- /dev/null +++ b/packages/preact-query/src/__tests__/useSuspenseQueries.test.tsx @@ -0,0 +1,836 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest' +import { act, fireEvent, render } from '@testing-library/react' +import * as React from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { + QueryClient, + skipToken, + useSuspenseQueries, + useSuspenseQuery, +} from '..' +import { renderWithClient } from './utils' +import type { UseSuspenseQueryOptions } from '..' + +type NumberQueryOptions = UseSuspenseQueryOptions + +const QUERY_DURATION = 1000 + +const createQuery: (id: number) => NumberQueryOptions = (id) => ({ + queryKey: [id], + queryFn: () => sleep(QUERY_DURATION).then(() => id), +}) +const resolveQueries = () => vi.advanceTimersByTimeAsync(QUERY_DURATION) + +const queryClient = new QueryClient() + +describe('useSuspenseQueries', () => { + const onSuspend = vi.fn() + const onQueriesResolution = vi.fn() + + beforeAll(() => { + vi.useFakeTimers() + }) + + afterAll(() => { + vi.useRealTimers() + }) + + afterEach(() => { + queryClient.clear() + onSuspend.mockClear() + onQueriesResolution.mockClear() + }) + + function SuspenseFallback() { + React.useEffect(() => { + onSuspend() + }, []) + + return
loading
+ } + + const withSuspenseWrapper = (Component: React.FC) => { + function SuspendedComponent(props: T) { + return ( + }> + + + ) + } + + return SuspendedComponent + } + + function QueriesContainer({ + queries, + }: { + queries: Array + }) { + const queriesResults = useSuspenseQueries( + { queries, combine: (results) => results.map((r) => r.data) }, + queryClient, + ) + + React.useEffect(() => { + onQueriesResolution(queriesResults) + }, [queriesResults]) + + return null + } + + const TestComponent = withSuspenseWrapper(QueriesContainer) + + it('should suspend on mount', () => { + render() + + expect(onSuspend).toHaveBeenCalledOnce() + }) + + it('should resolve queries', async () => { + render() + + await act(resolveQueries) + + expect(onQueriesResolution).toHaveBeenCalledTimes(1) + expect(onQueriesResolution).toHaveBeenLastCalledWith([1, 2]) + }) + + it('should not suspend on mount if query has been already fetched', () => { + const query = createQuery(1) + + queryClient.setQueryData(query.queryKey, query.queryFn) + + render() + + expect(onSuspend).not.toHaveBeenCalled() + }) + + it('should not break suspense when queries change without resolving', async () => { + const initQueries = [1, 2].map(createQuery) + const nextQueries = [3, 4, 5, 6].map(createQuery) + + const { rerender } = render() + + rerender() + + await act(resolveQueries) + + expect(onSuspend).toHaveBeenCalledTimes(1) + expect(onQueriesResolution).toHaveBeenCalledTimes(1) + expect(onQueriesResolution).toHaveBeenLastCalledWith([3, 4, 5, 6]) + }) + + it('should suspend only once per queries change', async () => { + const initQueries = [1, 2].map(createQuery) + const nextQueries = [3, 4, 5, 6].map(createQuery) + + const { rerender } = render() + + await act(resolveQueries) + + rerender() + + await act(resolveQueries) + + expect(onSuspend).toHaveBeenCalledTimes(2) + expect(onQueriesResolution).toHaveBeenCalledTimes(2) + expect(onQueriesResolution).toHaveBeenLastCalledWith([3, 4, 5, 6]) + }) + + it('should only call combine after resolving', async () => { + const spy = vi.fn() + const key = queryKey() + + function Page() { + const data = useSuspenseQueries({ + queries: [1, 2, 3].map((value) => ({ + queryKey: [...key, { value }], + queryFn: () => sleep(value * 10).then(() => ({ value: value * 10 })), + })), + combine: (result) => { + spy(result) + return 'data' + }, + }) + + return

{data}

+ } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + + expect(spy).not.toHaveBeenCalled() + + await act(() => vi.advanceTimersByTimeAsync(30)) + expect(rendered.getByText('data')).toBeInTheDocument() + + expect(spy).toHaveBeenCalled() + }) + + it('should handle duplicate query keys without infinite loops', async () => { + const key = queryKey() + const localDuration = 10 + let renderCount = 0 + + function getUserData() { + return { + queryKey: key, + queryFn: async () => { + await sleep(localDuration) + return { name: 'John Doe', age: 50 } + }, + } + } + + function getName() { + return { + ...getUserData(), + select: (data: any) => data.name, + } + } + + function getAge() { + return { + ...getUserData(), + select: (data: any) => data.age, + } + } + + function App() { + renderCount++ + const [{ data }, { data: data2 }] = useSuspenseQueries({ + queries: [getName(), getAge()], + }) + + React.useEffect(() => { + onQueriesResolution({ data, data2 }) + }, [data, data2]) + + return ( +
+

Data

+ {JSON.stringify({ data }, null, 2)} + {JSON.stringify({ data2 }, null, 2)} +
+ ) + } + + renderWithClient( + queryClient, + }> + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(localDuration)) + + expect(onSuspend).toHaveBeenCalledTimes(1) + expect(onQueriesResolution).toHaveBeenCalledTimes(1) + + await act(() => vi.advanceTimersByTimeAsync(100)) + + expect(onQueriesResolution).toHaveBeenCalledTimes(1) + expect(onQueriesResolution).toHaveBeenLastCalledWith({ + data: 'John Doe', + data2: 50, + }) + + // With the infinite loop bug, renderCount would be very high (e.g. > 100) + // Without bug, it should be small (initial suspend + resolution = 2-3) + expect(renderCount).toBeLessThan(10) + }) +}) + +describe('useSuspenseQueries 2', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should suspend all queries in parallel', async () => { + const key1 = queryKey() + const key2 = queryKey() + const results: Array = [] + + function Fallback() { + results.push('loading') + return
loading
+ } + + function Page() { + const result = useSuspenseQueries({ + queries: [ + { + queryKey: key1, + queryFn: () => + sleep(10).then(() => { + results.push('1') + return '1' + }), + }, + { + queryKey: key2, + queryFn: () => + sleep(20).then(() => { + results.push('2') + return '2' + }), + }, + ], + }) + + return ( +
+

data: {result.map((item) => item.data ?? 'null').join(',')}

+
+ ) + } + + const rendered = renderWithClient( + queryClient, + }> + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(20)) + expect(rendered.getByText('data: 1,2')).toBeInTheDocument() + + expect(results).toEqual(['loading', '1', '2']) + }) + + it("shouldn't unmount before all promises fetched", async () => { + const key1 = queryKey() + const key2 = queryKey() + const results: Array = [] + const refs: Array = [] + + function Fallback() { + results.push('loading') + return
loading
+ } + + function Page() { + // eslint-disable-next-line react-hooks/purity + const ref = React.useRef(Math.random()) + const result = useSuspenseQueries({ + queries: [ + { + queryKey: key1, + queryFn: () => + sleep(10).then(() => { + refs.push(ref.current) + results.push('1') + return '1' + }), + }, + { + queryKey: key2, + queryFn: () => + sleep(20).then(() => { + refs.push(ref.current) + results.push('2') + return '2' + }), + }, + ], + }) + + return ( +
+

data: {result.map((item) => item.data ?? 'null').join(',')}

+
+ ) + } + + const rendered = renderWithClient( + queryClient, + }> + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(20)) + expect(rendered.getByText('data: 1,2')).toBeInTheDocument() + + expect(refs.length).toBe(2) + expect(refs[0]).toBe(refs[1]) + }) + + // this addresses the following issue: + // https://github.com/TanStack/query/issues/6344 + it('should suspend on offline when query changes, and data should not be undefined', async () => { + function Page() { + const [id, setId] = React.useState(0) + + const { data } = useSuspenseQuery({ + queryKey: [id], + queryFn: () => sleep(10).then(() => `Data ${id}`), + }) + + // defensive guard here + if (data === undefined) { + throw new Error('data cannot be undefined') + } + + return ( + <> +
{data}
+ + + ) + } + + const rendered = renderWithClient( + queryClient, + loading}> + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('Data 0')).toBeInTheDocument() + + // go offline + document.dispatchEvent(new CustomEvent('offline')) + + fireEvent.click(rendered.getByText('fetch')) + expect(rendered.getByText('Data 0')).toBeInTheDocument() + + // go back online + document.dispatchEvent(new CustomEvent('online')) + + fireEvent.click(rendered.getByText('fetch')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + // query should resume + expect(rendered.getByText('Data 1')).toBeInTheDocument() + }) + + it('should throw error when queryKey changes and new query fails', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + function Page() { + const [fail, setFail] = React.useState(false) + const { data } = useSuspenseQuery({ + queryKey: [key, fail], + queryFn: () => + sleep(10).then(() => { + if (fail) throw new Error('Suspense Error Bingo') + return 'data' + }), + retry: 0, + }) + + return ( +
+ +
rendered: {data}
+
+ ) + } + + const rendered = renderWithClient( + queryClient, +
error boundary
}> + + + +
, + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('rendered: data')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('trigger fail')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + + expect(consoleMock.mock.calls[0]?.[1]).toStrictEqual( + new Error('Suspense Error Bingo'), + ) + + consoleMock.mockRestore() + }) + + it('should keep previous data when wrapped in a transition', async () => { + const key = queryKey() + + function Page() { + const [count, setCount] = React.useState(0) + const [isPending, startTransition] = React.useTransition() + const { data } = useSuspenseQuery({ + queryKey: [key, count], + queryFn: () => sleep(10).then(() => 'data' + count), + }) + + return ( +
+ + +
{isPending ? 'pending' : data}
+
+ ) + } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data0')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('inc')) + expect(rendered.getByText('pending')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data1')).toBeInTheDocument() + }) + + it('should not request old data inside transitions (issue #6486)', async () => { + const key = queryKey() + let queryFnCount = 0 + + function App() { + const [count, setCount] = React.useState(0) + + return ( +
+ + + + +
+ ) + } + + function Page({ count }: { count: number }) { + const { data } = useSuspenseQuery({ + queryKey: [key, count], + queryFn: () => + sleep(10).then(() => { + queryFnCount++ + return 'data' + count + }), + }) + + return ( +
+
{String(data)}
+
+ ) + } + + const rendered = renderWithClient( + queryClient, + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data0')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('inc')) + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data1')).toBeInTheDocument() + + expect(queryFnCount).toBe(2) + }) + + it('should still suspense if queryClient has placeholderData config', async () => { + const key = queryKey() + const queryClientWithPlaceholder = new QueryClient({ + defaultOptions: { + queries: { + placeholderData: (previousData: any) => previousData, + }, + }, + }) + + function Page() { + const [count, setCount] = React.useState(0) + const [isPending, startTransition] = React.useTransition() + const { data } = useSuspenseQuery({ + queryKey: [key, count], + queryFn: () => sleep(10).then(() => 'data' + count), + }) + + return ( +
+ +
{isPending ? 'pending' : data}
+
+ ) + } + + const rendered = renderWithClient( + queryClientWithPlaceholder, + + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data0')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('inc')) + expect(rendered.getByText('pending')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data1')).toBeInTheDocument() + }) + + it('should show error boundary even with gcTime:0 (#7853)', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + let count = 0 + + function Page() { + useSuspenseQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => { + count++ + throw new Error('Query failed') + }), + gcTime: 0, + retry: false, + }) + + return null + } + + function App() { + return ( + + { + return
There was an error!
+ }} + > + +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('There was an error!')).toBeInTheDocument() + + expect(count).toBe(1) + + consoleMock.mockRestore() + }) + + describe('gc (with fake timers)', () => { + beforeAll(() => { + vi.useFakeTimers() + }) + + afterAll(() => { + vi.useRealTimers() + }) + + it('should gc when unmounted while fetching with low gcTime (#8159)', async () => { + const key = queryKey() + + function Page() { + return ( + + + + ) + } + + function Component() { + const { data } = useSuspenseQuery({ + queryKey: key, + queryFn: () => sleep(3000).then(() => 'data'), + gcTime: 1000, + }) + + return
{data}
+ } + + function Page2() { + return
page2
+ } + + function App() { + const [show, setShow] = React.useState(true) + + return ( +
+ {show ? : } + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('hide')) + expect(rendered.getByText('page2')).toBeInTheDocument() + // wait for query to be resolved + await act(() => vi.advanceTimersByTimeAsync(3000)) + expect(queryClient.getQueryData(key)).toBe('data') + // wait for gc + await act(() => vi.advanceTimersByTimeAsync(1000)) + expect(queryClient.getQueryData(key)).toBe(undefined) + }) + }) + + it('should log an error when skipToken is passed as queryFn', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + const key = queryKey() + + function Page() { + useSuspenseQueries({ + queries: [ + { + queryKey: key, + // @ts-expect-error + // eslint-disable-next-line react-hooks/purity + queryFn: Math.random() >= 0 ? skipToken : () => Promise.resolve(5), + }, + ], + }) + + return null + } + + function App() { + return ( + + + + ) + } + + renderWithClient(queryClient, ) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'skipToken is not allowed for useSuspenseQueries', + ) + consoleErrorSpy.mockRestore() + }) + + it('should log an error when skipToken is used in development environment', () => { + const envCopy = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + function Page() { + useSuspenseQueries({ + queries: [ + { + queryKey: key, + queryFn: skipToken as any, + }, + ], + }) + + return null + } + + renderWithClient( + queryClient, + + + , + ) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'skipToken is not allowed for useSuspenseQueries', + ) + consoleErrorSpy.mockRestore() + process.env.NODE_ENV = envCopy + }) + + it('should not log an error when skipToken is used in production environment', () => { + const envCopy = process.env.NODE_ENV + process.env.NODE_ENV = 'production' + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + function Page() { + useSuspenseQueries({ + queries: [ + { + queryKey: key, + queryFn: skipToken as any, + }, + ], + }) + + return null + } + + renderWithClient( + queryClient, + + + , + ) + + expect(consoleErrorSpy).not.toHaveBeenCalled() + consoleErrorSpy.mockRestore() + process.env.NODE_ENV = envCopy + }) +}) diff --git a/packages/preact-query/src/__tests__/useSuspenseQuery.test-d.tsx b/packages/preact-query/src/__tests__/useSuspenseQuery.test-d.tsx new file mode 100644 index 0000000000..e78b8a907d --- /dev/null +++ b/packages/preact-query/src/__tests__/useSuspenseQuery.test-d.tsx @@ -0,0 +1,88 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest' +import { skipToken } from '@tanstack/query-core' +import { useSuspenseQuery } from '../useSuspenseQuery' + +describe('useSuspenseQuery', () => { + it('should always have data defined', () => { + const { data } = useSuspenseQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }) + + expectTypeOf(data).toEqualTypeOf() + }) + + it('should not have pending status', () => { + const { status } = useSuspenseQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }) + + expectTypeOf(status).toEqualTypeOf<'error' | 'success'>() + }) + + it('should not allow skipToken in queryFn', () => { + assertType( + useSuspenseQuery({ + queryKey: ['key'], + // @ts-expect-error + queryFn: skipToken, + }), + ) + assertType( + useSuspenseQuery({ + queryKey: ['key'], + // @ts-expect-error + queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), + }), + ) + }) + + it('should not allow placeholderData, enabled or throwOnError props', () => { + assertType( + useSuspenseQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + // @ts-expect-error TS2345 + placeholderData: 5, + enabled: true, + }), + ) + assertType( + useSuspenseQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + // @ts-expect-error TS2345 + enabled: true, + }), + ) + assertType( + useSuspenseQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + // @ts-expect-error TS2345 + throwOnError: true, + }), + ) + }) + + it('should not return isPlaceholderData', () => { + expectTypeOf( + useSuspenseQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }), + ).not.toHaveProperty('isPlaceholderData') + }) + + it('should type-narrow the error field', () => { + const query = useSuspenseQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }) + + if (query.status === 'error') { + expectTypeOf(query.error).toEqualTypeOf() + } + }) +}) diff --git a/packages/preact-query/src/__tests__/useSuspenseQuery.test.tsx b/packages/preact-query/src/__tests__/useSuspenseQuery.test.tsx new file mode 100644 index 0000000000..8a0e6c5b7c --- /dev/null +++ b/packages/preact-query/src/__tests__/useSuspenseQuery.test.tsx @@ -0,0 +1,996 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent } from '@testing-library/react' +import * as React from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + QueryErrorResetBoundary, + skipToken, + useQueryErrorResetBoundary, + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from '..' +import { renderWithClient } from './utils' +import type { + InfiniteData, + UseSuspenseInfiniteQueryResult, + UseSuspenseQueryResult, +} from '..' + +describe('useSuspenseQuery', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + + it('should render the correct amount of times in Suspense mode', async () => { + const key = queryKey() + const states: Array> = [] + + let count = 0 + let renders = 0 + + function Page() { + renders++ + + const [stateKey, setStateKey] = React.useState(key) + + const state = useSuspenseQuery({ + queryKey: stateKey, + queryFn: () => sleep(10).then(() => ++count), + }) + + states.push(state) + + return ( +
+
+ ) + } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: 1')).toBeInTheDocument() + + fireEvent.click(rendered.getByLabelText('toggle')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: 2')).toBeInTheDocument() + + expect(renders).toBe(6) + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: 1, status: 'success' }) + expect(states[1]).toMatchObject({ data: 2, status: 'success' }) + }) + + it('should return the correct states for a successful infinite query', async () => { + const key = queryKey() + const states: Array>> = + [] + + function Page() { + const [multiplier, setMultiplier] = React.useState(1) + const state = useSuspenseInfiniteQuery({ + queryKey: [`${key}_${multiplier}`], + queryFn: ({ pageParam }) => + sleep(10).then(() => pageParam * multiplier), + initialPageParam: 1, + getNextPageParam: (lastPage) => lastPage + 1, + }) + + states.push(state) + + return ( +
+ + data: {state.data?.pages.join(',')} +
+ ) + } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: 1')).toBeInTheDocument() + + expect(states.length).toBe(1) + expect(states[0]).toMatchObject({ + data: { pages: [1], pageParams: [1] }, + status: 'success', + }) + + fireEvent.click(rendered.getByText('next')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: 2')).toBeInTheDocument() + + expect(states.length).toBe(2) + expect(states[1]).toMatchObject({ + data: { pages: [2], pageParams: [1] }, + status: 'success', + }) + }) + + it('should not call the queryFn twice when used in Suspense mode', async () => { + const key = queryKey() + + const queryFn = vi.fn(() => sleep(10).then(() => 'data')) + + function Page() { + useSuspenseQuery({ queryKey: [key], queryFn }) + + return <>rendered + } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('rendered')).toBeInTheDocument() + + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should remove query instance when component unmounted', async () => { + const key = queryKey() + + function Page() { + useSuspenseQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + + return <>rendered + } + + function App() { + const [show, setShow] = React.useState(false) + + return ( + <> + {show && } + + + )} + > + + + + + )} + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(70)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('retry')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('rendered')).toBeInTheDocument() + + expect(consoleMock.mock.calls[0]?.[1]).toStrictEqual( + new Error('Suspense Error Bingo'), + ) + + consoleMock.mockRestore() + }) + + it('should retry fetch if the reset error boundary has been reset', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + let succeed = false + + function Page() { + useSuspenseQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Suspense Error Bingo') + return 'data' + }), + retry: false, + }) + return
rendered
+ } + + const rendered = renderWithClient( + queryClient, + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + + + +
+ )} +
, + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('retry')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + succeed = true + + fireEvent.click(rendered.getByText('retry')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('rendered')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + + it('should set staleTime when having passed a function', async () => { + const key = queryKey() + let count = 0 + + function Component() { + const result = useSuspenseQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => ++count), + staleTime: () => 60 * 1000, + }) + return ( +
+ data: {result.data} +
+ ) + } + + function Page() { + return ( + + + + ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: 1')).toBeInTheDocument() + + expect( + typeof queryClient.getQueryCache().find({ queryKey: key })?.observers[0] + ?.options.staleTime, + ).toBe('function') + }) + + it('should suspend when switching to a new query', async () => { + const key1 = queryKey() + const key2 = queryKey() + + function Component(props: { queryKey: Array }) { + const result = useSuspenseQuery({ + queryKey: props.queryKey, + queryFn: () => sleep(10).then(() => props.queryKey), + retry: false, + }) + return
data: {result.data}
+ } + + function Page() { + const [key, setKey] = React.useState(key1) + return ( +
+ + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText(`data: ${key1}`)).toBeInTheDocument() + + fireEvent.click(rendered.getByText('switch')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText(`data: ${key2}`)).toBeInTheDocument() + }) + + it('should retry fetch if the reset error boundary has been reset with global hook', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + let succeed = false + + function Page() { + useSuspenseQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Suspense Error Bingo') + return 'data' + }), + retry: false, + }) + return
rendered
+ } + + function App() { + const { reset } = useQueryErrorResetBoundary() + return ( + ( +
+
error boundary
+ +
+ )} + > + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('retry')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(rendered.getByText('retry')).toBeInTheDocument() + + succeed = true + + fireEvent.click(rendered.getByText('retry')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('rendered')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + + it('should throw errors to the error boundary by default', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + function Page() { + useSuspenseQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('Suspense Error a1x'))), + retry: false, + }) + return
rendered
+ } + + function App() { + return ( + ( +
+
error boundary
+
+ )} + > + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + + it('should throw select errors to the error boundary by default', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + function Page() { + useSuspenseQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => ({ a: { b: 'c' } })), + select: () => { + throw new Error('foo') + }, + }) + return
rendered
+ } + + function App() { + return ( + ( +
+
error boundary
+
+ )} + > + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + + it('should error caught in error boundary without infinite loop', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + let succeed = true + + function Page() { + const [nonce] = React.useState(0) + const queryKeys = [`${key}-${succeed}`] + const result = useSuspenseQuery({ + queryKey: queryKeys, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Suspense Error Bingo') + return nonce + }), + retry: false, + }) + return ( +
+ rendered {result.data} + +
+ ) + } + + function App() { + const { reset } = useQueryErrorResetBoundary() + return ( +
error boundary
} + > + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + // render suspense fallback (loading) + expect(rendered.getByText('loading')).toBeInTheDocument() + // resolve promise -> render Page (rendered) + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('rendered')).toBeInTheDocument() + + // change query key + succeed = false + + // reset query -> and throw error + fireEvent.click(rendered.getByLabelText('fail')) + // render error boundary fallback (error boundary) + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + + expect(consoleMock.mock.calls[0]?.[1]).toStrictEqual( + new Error('Suspense Error Bingo'), + ) + + consoleMock.mockRestore() + }) + + it('should error caught in error boundary without infinite loop when query keys changed', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + let succeed = true + + function Page() { + const [key, rerender] = React.useReducer((x) => x + 1, 0) + const queryKeys = [key, succeed] + + const result = useSuspenseQuery({ + queryKey: queryKeys, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Suspense Error Bingo') + return 'data' + }), + retry: false, + }) + + if (result.error) { + throw result.error + } + + return ( +
+ rendered {result.data} + +
+ ) + } + + function App() { + const { reset } = useQueryErrorResetBoundary() + return ( +
error boundary
} + > + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + // render suspense fallback (loading) + expect(rendered.getByText('loading')).toBeInTheDocument() + // resolve promise -> render Page (rendered) + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('rendered')).toBeInTheDocument() + + // change promise result to error + succeed = false + + // change query key + fireEvent.click(rendered.getByLabelText('fail')) + expect(rendered.getByText('loading')).toBeInTheDocument() + // render error boundary fallback (error boundary) + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + + expect(consoleMock.mock.calls[0]?.[1]).toStrictEqual( + new Error('Suspense Error Bingo'), + ) + + consoleMock.mockRestore() + }) + + it('should render the correct amount of times in Suspense mode when gcTime is set to 0', async () => { + const key = queryKey() + let state: UseSuspenseQueryResult | null = null + + let count = 0 + let renders = 0 + + function Page() { + renders++ + + state = useSuspenseQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => ++count), + gcTime: 0, + }) + + return ( +
+ rendered +
+ ) + } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('rendered')).toBeInTheDocument() + + expect(state).toMatchObject({ + data: 1, + status: 'success', + }) + expect(renders).toBe(3) + }) + + it('should not throw background errors to the error boundary', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + let succeed = true + const key = queryKey() + + function Page() { + const result = useSuspenseQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Suspense Error Bingo') + return 'data' + }), + retry: false, + }) + + return ( +
+ + rendered {result.data} {result.status} + + +
+ ) + } + + function App() { + const { reset } = useQueryErrorResetBoundary() + return ( +
error boundary
} + > + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + // render suspense fallback (loading) + expect(rendered.getByText('loading')).toBeInTheDocument() + // resolve promise -> render Page (rendered) + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('rendered data success')).toBeInTheDocument() + + // change promise result to error + succeed = false + + // refetch + fireEvent.click(rendered.getByRole('button', { name: 'refetch' })) + // we are now in error state but still have data to show + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('rendered data error')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + + it('should still suspense if queryClient has placeholderData config', async () => { + const key = queryKey() + const queryClientWithPlaceholder = new QueryClient({ + defaultOptions: { + queries: { + placeholderData: (previousData: any) => previousData, + }, + }, + }) + const states: Array> = [] + + let count = 0 + + function Page() { + const [stateKey, setStateKey] = React.useState(key) + + const state = useSuspenseQuery({ + queryKey: stateKey, + queryFn: async () => sleep(10).then(() => ++count), + }) + + states.push(state) + + return ( +
+
+ ) + } + + const rendered = renderWithClient( + queryClientWithPlaceholder, + + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: 1')).toBeInTheDocument() + + fireEvent.click(rendered.getByLabelText('toggle')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: 2')).toBeInTheDocument() + }) + + it('should log an error when skipToken is passed as queryFn', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + const key = queryKey() + + function Page() { + useSuspenseQuery({ + queryKey: key, + // @ts-expect-error + // eslint-disable-next-line react-hooks/purity + queryFn: Math.random() >= 0 ? skipToken : () => Promise.resolve(5), + }) + + return null + } + + function App() { + return ( + + + + ) + } + + renderWithClient(queryClient, ) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'skipToken is not allowed for useSuspenseQuery', + ) + consoleErrorSpy.mockRestore() + }) + + it('should properly refresh data when refetchInterval is set', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const state = useSuspenseQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => ++count), + refetchInterval: 10, + }) + + return
count: {state.data}
+ } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('count: 1')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(21)) + expect(rendered.getByText('count: 2')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(21)) + expect(rendered.getByText('count: 3')).toBeInTheDocument() + + expect(count).toBeGreaterThanOrEqual(3) + }) + + it('should log an error when skipToken is used in development environment', () => { + const envCopy = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + function Page() { + useSuspenseQuery({ + queryKey: key, + queryFn: skipToken as any, + }) + + return null + } + + renderWithClient( + queryClient, + + + , + ) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'skipToken is not allowed for useSuspenseQuery', + ) + + consoleErrorSpy.mockRestore() + process.env.NODE_ENV = envCopy + }) + + it('should not log an error when skipToken is used in production environment', () => { + const envCopy = process.env.NODE_ENV + process.env.NODE_ENV = 'production' + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + function Page() { + useSuspenseQuery({ + queryKey: key, + queryFn: skipToken as any, + }) + + return null + } + + renderWithClient( + queryClient, + + + , + ) + + expect(consoleErrorSpy).not.toHaveBeenCalled() + + consoleErrorSpy.mockRestore() + process.env.NODE_ENV = envCopy + }) +}) diff --git a/packages/preact-query/src/__tests__/utils.tsx b/packages/preact-query/src/__tests__/utils.tsx new file mode 100644 index 0000000000..3734c1caf6 --- /dev/null +++ b/packages/preact-query/src/__tests__/utils.tsx @@ -0,0 +1,72 @@ +import { vi } from 'vitest' +import * as React from 'react' +import { act, render } from '@testing-library/react' +import * as utils from '@tanstack/query-core' +import { QueryClientProvider, onlineManager } from '..' +import type { QueryClient } from '..' +import type { MockInstance } from 'vitest' + +export function renderWithClient( + client: QueryClient, + ui: React.ReactElement, +): ReturnType { + const { rerender, ...result } = render( + {ui}, + ) + return { + ...result, + rerender: (rerenderUi: React.ReactElement) => + rerender( + {rerenderUi}, + ), + } as any +} + +export function Blink({ + duration, + children, +}: { + duration: number + children: React.ReactNode +}) { + const [shouldShow, setShouldShow] = React.useState(true) + + React.useEffect(() => { + setShouldShow(true) + const timeout = setActTimeout(() => setShouldShow(false), duration) + return () => { + clearTimeout(timeout) + } + }, [duration, children]) + + return shouldShow ? <>{children} : <>off +} + +export function mockOnlineManagerIsOnline( + value: boolean, +): MockInstance<() => boolean> { + return vi.spyOn(onlineManager, 'isOnline').mockReturnValue(value) +} + +export function setActTimeout(fn: () => void, ms?: number) { + return setTimeout(() => { + act(() => { + fn() + }) + }, ms) +} + +// This monkey-patches the isServer-value from utils, +// so that we can pretend to be in a server environment +export function setIsServer(isServer: boolean) { + const original = utils.isServer + Object.defineProperty(utils, 'isServer', { + get: () => isServer, + }) + + return () => { + Object.defineProperty(utils, 'isServer', { + get: () => original, + }) + } +} diff --git a/packages/preact-query/src/errorBoundaryUtils.ts b/packages/preact-query/src/errorBoundaryUtils.ts new file mode 100644 index 0000000000..28c11c0b10 --- /dev/null +++ b/packages/preact-query/src/errorBoundaryUtils.ts @@ -0,0 +1,76 @@ +'use client' +import * as React from 'react' +import { shouldThrowError } from '@tanstack/query-core' +import type { + DefaultedQueryObserverOptions, + Query, + QueryKey, + QueryObserverResult, + ThrowOnError, +} from '@tanstack/query-core' +import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary' + +export const ensurePreventErrorBoundaryRetry = < + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey extends QueryKey, +>( + options: DefaultedQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >, + errorResetBoundary: QueryErrorResetBoundaryValue, +) => { + if ( + options.suspense || + options.throwOnError || + options.experimental_prefetchInRender + ) { + // Prevent retrying failed query if the error boundary has not been reset yet + if (!errorResetBoundary.isReset()) { + options.retryOnMount = false + } + } +} + +export const useClearResetErrorBoundary = ( + errorResetBoundary: QueryErrorResetBoundaryValue, +) => { + React.useEffect(() => { + errorResetBoundary.clearReset() + }, [errorResetBoundary]) +} + +export const getHasError = < + TData, + TError, + TQueryFnData, + TQueryData, + TQueryKey extends QueryKey, +>({ + result, + errorResetBoundary, + throwOnError, + query, + suspense, +}: { + result: QueryObserverResult + errorResetBoundary: QueryErrorResetBoundaryValue + throwOnError: ThrowOnError + query: Query | undefined + suspense: boolean | undefined +}) => { + return ( + result.isError && + !errorResetBoundary.isReset() && + !result.isFetching && + query && + ((suspense && result.data === undefined) || + shouldThrowError(throwOnError, [result.error, query])) + ) +} diff --git a/packages/preact-query/src/index.ts b/packages/preact-query/src/index.ts new file mode 100644 index 0000000000..36ea8da7af --- /dev/null +++ b/packages/preact-query/src/index.ts @@ -0,0 +1,56 @@ +/* istanbul ignore file */ + +// Re-export core +export * from '@tanstack/query-core' + +// React Query +export * from './types' +export { useQueries } from './useQueries' +export type { QueriesResults, QueriesOptions } from './useQueries' +export { useQuery } from './useQuery' +export { useSuspenseQuery } from './useSuspenseQuery' +export { useSuspenseInfiniteQuery } from './useSuspenseInfiniteQuery' +export { useSuspenseQueries } from './useSuspenseQueries' +export type { + SuspenseQueriesResults, + SuspenseQueriesOptions, +} from './useSuspenseQueries' +export { usePrefetchQuery } from './usePrefetchQuery' +export { usePrefetchInfiniteQuery } from './usePrefetchInfiniteQuery' +export { queryOptions } from './queryOptions' +export type { + DefinedInitialDataOptions, + UndefinedInitialDataOptions, + UnusedSkipTokenOptions, +} from './queryOptions' +export { infiniteQueryOptions } from './infiniteQueryOptions' +export type { + DefinedInitialDataInfiniteOptions, + UndefinedInitialDataInfiniteOptions, + UnusedSkipTokenInfiniteOptions, +} from './infiniteQueryOptions' +export { + QueryClientContext, + QueryClientProvider, + useQueryClient, +} from './QueryClientProvider' +export type { QueryClientProviderProps } from './QueryClientProvider' +export type { QueryErrorResetBoundaryProps } from './QueryErrorResetBoundary' +export { HydrationBoundary } from './HydrationBoundary' +export type { HydrationBoundaryProps } from './HydrationBoundary' +export type { + QueryErrorClearResetFunction, + QueryErrorIsResetFunction, + QueryErrorResetBoundaryFunction, + QueryErrorResetFunction, +} from './QueryErrorResetBoundary' +export { + QueryErrorResetBoundary, + useQueryErrorResetBoundary, +} from './QueryErrorResetBoundary' +export { useIsFetching } from './useIsFetching' +export { useIsMutating, useMutationState } from './useMutationState' +export { useMutation } from './useMutation' +export { mutationOptions } from './mutationOptions' +export { useInfiniteQuery } from './useInfiniteQuery' +export { useIsRestoring, IsRestoringProvider } from './IsRestoringProvider' diff --git a/packages/preact-query/src/infiniteQueryOptions.ts b/packages/preact-query/src/infiniteQueryOptions.ts new file mode 100644 index 0000000000..5e8c371a59 --- /dev/null +++ b/packages/preact-query/src/infiniteQueryOptions.ts @@ -0,0 +1,149 @@ +import type { + DataTag, + DefaultError, + InfiniteData, + InitialDataFunction, + NonUndefinedGuard, + OmitKeyof, + QueryKey, + SkipToken, +} from '@tanstack/query-core' +import type { UseInfiniteQueryOptions } from './types' + +export type UndefinedInitialDataInfiniteOptions< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = UseInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam +> & { + initialData?: + | undefined + | NonUndefinedGuard> + | InitialDataFunction< + NonUndefinedGuard> + > +} + +export type UnusedSkipTokenInfiniteOptions< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = OmitKeyof< + UseInfiniteQueryOptions, + 'queryFn' +> & { + queryFn?: Exclude< + UseInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >['queryFn'], + SkipToken | undefined + > +} + +export type DefinedInitialDataInfiniteOptions< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = UseInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam +> & { + initialData: + | NonUndefinedGuard> + | (() => NonUndefinedGuard>) + | undefined +} + +export function infiniteQueryOptions< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: DefinedInitialDataInfiniteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, +): DefinedInitialDataInfiniteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam +> & { + queryKey: DataTag, TError> +} + +export function infiniteQueryOptions< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: UnusedSkipTokenInfiniteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, +): UnusedSkipTokenInfiniteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam +> & { + queryKey: DataTag, TError> +} + +export function infiniteQueryOptions< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: UndefinedInitialDataInfiniteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, +): UndefinedInitialDataInfiniteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam +> & { + queryKey: DataTag, TError> +} + +export function infiniteQueryOptions(options: unknown) { + return options +} diff --git a/packages/preact-query/src/mutationOptions.ts b/packages/preact-query/src/mutationOptions.ts new file mode 100644 index 0000000000..1ee3110b19 --- /dev/null +++ b/packages/preact-query/src/mutationOptions.ts @@ -0,0 +1,41 @@ +import type { DefaultError, WithRequired } from '@tanstack/query-core' +import type { UseMutationOptions } from './types' + +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + options: WithRequired< + UseMutationOptions, + 'mutationKey' + >, +): WithRequired< + UseMutationOptions, + 'mutationKey' +> +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + options: Omit< + UseMutationOptions, + 'mutationKey' + >, +): Omit< + UseMutationOptions, + 'mutationKey' +> +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + options: UseMutationOptions, +): UseMutationOptions { + return options +} diff --git a/packages/preact-query/src/queryOptions.ts b/packages/preact-query/src/queryOptions.ts new file mode 100644 index 0000000000..644ac26788 --- /dev/null +++ b/packages/preact-query/src/queryOptions.ts @@ -0,0 +1,87 @@ +import type { + DataTag, + DefaultError, + InitialDataFunction, + NonUndefinedGuard, + OmitKeyof, + QueryFunction, + QueryKey, + SkipToken, +} from '@tanstack/query-core' +import type { UseQueryOptions } from './types' + +export type UndefinedInitialDataOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = UseQueryOptions & { + initialData?: + | undefined + | InitialDataFunction> + | NonUndefinedGuard +} + +export type UnusedSkipTokenOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = OmitKeyof< + UseQueryOptions, + 'queryFn' +> & { + queryFn?: Exclude< + UseQueryOptions['queryFn'], + SkipToken | undefined + > +} + +export type DefinedInitialDataOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Omit, 'queryFn'> & { + initialData: + | NonUndefinedGuard + | (() => NonUndefinedGuard) + queryFn?: QueryFunction +} + +export function queryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: DefinedInitialDataOptions, +): DefinedInitialDataOptions & { + queryKey: DataTag +} + +export function queryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UnusedSkipTokenOptions, +): UnusedSkipTokenOptions & { + queryKey: DataTag +} + +export function queryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UndefinedInitialDataOptions, +): UndefinedInitialDataOptions & { + queryKey: DataTag +} + +export function queryOptions(options: unknown) { + return options +} diff --git a/packages/preact-query/src/suspense.ts b/packages/preact-query/src/suspense.ts new file mode 100644 index 0000000000..d87569f103 --- /dev/null +++ b/packages/preact-query/src/suspense.ts @@ -0,0 +1,80 @@ +import type { + DefaultError, + DefaultedQueryObserverOptions, + Query, + QueryKey, + QueryObserver, + QueryObserverResult, +} from '@tanstack/query-core' +import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary' + +export const defaultThrowOnError = < + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + _error: TError, + query: Query, +) => query.state.data === undefined + +export const ensureSuspenseTimers = ( + defaultedOptions: DefaultedQueryObserverOptions, +) => { + if (defaultedOptions.suspense) { + // Handle staleTime to ensure minimum 1000ms in Suspense mode + // This prevents unnecessary refetching when components remount after suspending + const MIN_SUSPENSE_TIME_MS = 1000 + + const clamp = (value: number | 'static' | undefined) => + value === 'static' + ? value + : Math.max(value ?? MIN_SUSPENSE_TIME_MS, MIN_SUSPENSE_TIME_MS) + + const originalStaleTime = defaultedOptions.staleTime + defaultedOptions.staleTime = + typeof originalStaleTime === 'function' + ? (...args) => clamp(originalStaleTime(...args)) + : clamp(originalStaleTime) + + if (typeof defaultedOptions.gcTime === 'number') { + defaultedOptions.gcTime = Math.max( + defaultedOptions.gcTime, + MIN_SUSPENSE_TIME_MS, + ) + } + } +} + +export const willFetch = ( + result: QueryObserverResult, + isRestoring: boolean, +) => result.isLoading && result.isFetching && !isRestoring + +export const shouldSuspend = ( + defaultedOptions: + | DefaultedQueryObserverOptions + | undefined, + result: QueryObserverResult, +) => defaultedOptions?.suspense && result.isPending + +export const fetchOptimistic = < + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey extends QueryKey, +>( + defaultedOptions: DefaultedQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >, + observer: QueryObserver, + errorResetBoundary: QueryErrorResetBoundaryValue, +) => + observer.fetchOptimistic(defaultedOptions).catch(() => { + errorResetBoundary.clearReset() + }) diff --git a/packages/preact-query/src/types.ts b/packages/preact-query/src/types.ts new file mode 100644 index 0000000000..2c52ac5b65 --- /dev/null +++ b/packages/preact-query/src/types.ts @@ -0,0 +1,242 @@ +/* istanbul ignore file */ + +import type { + DefaultError, + DefinedInfiniteQueryObserverResult, + DefinedQueryObserverResult, + DistributiveOmit, + FetchQueryOptions, + InfiniteQueryObserverOptions, + InfiniteQueryObserverResult, + MutateFunction, + MutationObserverOptions, + MutationObserverResult, + OmitKeyof, + Override, + QueryKey, + QueryObserverOptions, + QueryObserverResult, + SkipToken, +} from '@tanstack/query-core' + +export type AnyUseBaseQueryOptions = UseBaseQueryOptions< + any, + any, + any, + any, + any +> +export interface UseBaseQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> extends QueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + > { + /** + * Set this to `false` to unsubscribe this observer from updates to the query cache. + * Defaults to `true`. + */ + subscribed?: boolean +} + +export interface UsePrefetchQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> extends OmitKeyof< + FetchQueryOptions, + 'queryFn' + > { + queryFn?: Exclude< + FetchQueryOptions['queryFn'], + SkipToken + > +} + +export type AnyUseQueryOptions = UseQueryOptions +export interface UseQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> extends OmitKeyof< + UseBaseQueryOptions, + 'suspense' + > {} + +export type AnyUseSuspenseQueryOptions = UseSuspenseQueryOptions< + any, + any, + any, + any +> +export interface UseSuspenseQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> extends OmitKeyof< + UseQueryOptions, + 'queryFn' | 'enabled' | 'throwOnError' | 'placeholderData' + > { + queryFn?: Exclude< + UseQueryOptions['queryFn'], + SkipToken + > +} + +export type AnyUseInfiniteQueryOptions = UseInfiniteQueryOptions< + any, + any, + any, + any, + any +> +export interface UseInfiniteQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> extends OmitKeyof< + InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + 'suspense' + > { + /** + * Set this to `false` to unsubscribe this observer from updates to the query cache. + * Defaults to `true`. + */ + subscribed?: boolean +} + +export type AnyUseSuspenseInfiniteQueryOptions = + UseSuspenseInfiniteQueryOptions +export interface UseSuspenseInfiniteQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> extends OmitKeyof< + UseInfiniteQueryOptions, + 'queryFn' | 'enabled' | 'throwOnError' | 'placeholderData' + > { + queryFn?: Exclude< + UseInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >['queryFn'], + SkipToken + > +} + +export type UseBaseQueryResult< + TData = unknown, + TError = DefaultError, +> = QueryObserverResult + +export type UseQueryResult< + TData = unknown, + TError = DefaultError, +> = UseBaseQueryResult + +export type UseSuspenseQueryResult< + TData = unknown, + TError = DefaultError, +> = DistributiveOmit< + DefinedQueryObserverResult, + 'isPlaceholderData' | 'promise' +> + +export type DefinedUseQueryResult< + TData = unknown, + TError = DefaultError, +> = DefinedQueryObserverResult + +export type UseInfiniteQueryResult< + TData = unknown, + TError = DefaultError, +> = InfiniteQueryObserverResult + +export type DefinedUseInfiniteQueryResult< + TData = unknown, + TError = DefaultError, +> = DefinedInfiniteQueryObserverResult + +export type UseSuspenseInfiniteQueryResult< + TData = unknown, + TError = DefaultError, +> = OmitKeyof< + DefinedInfiniteQueryObserverResult, + 'isPlaceholderData' | 'promise' +> + +export type AnyUseMutationOptions = UseMutationOptions +export interface UseMutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> extends OmitKeyof< + MutationObserverOptions, + '_defaulted' + > {} + +export type UseMutateFunction< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> = ( + ...args: Parameters< + MutateFunction + > +) => void + +export type UseMutateAsyncFunction< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> = MutateFunction + +export type UseBaseMutationResult< + TData = unknown, + TError = DefaultError, + TVariables = unknown, + TOnMutateResult = unknown, +> = Override< + MutationObserverResult, + { mutate: UseMutateFunction } +> & { + mutateAsync: UseMutateAsyncFunction< + TData, + TError, + TVariables, + TOnMutateResult + > +} + +export type UseMutationResult< + TData = unknown, + TError = DefaultError, + TVariables = unknown, + TOnMutateResult = unknown, +> = UseBaseMutationResult diff --git a/packages/preact-query/src/useBaseQuery.ts b/packages/preact-query/src/useBaseQuery.ts new file mode 100644 index 0000000000..06690b544f --- /dev/null +++ b/packages/preact-query/src/useBaseQuery.ts @@ -0,0 +1,170 @@ +'use client' +import * as React from 'react' + +import { isServer, noop, notifyManager } from '@tanstack/query-core' +import { useQueryClient } from './QueryClientProvider' +import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary' +import { + ensurePreventErrorBoundaryRetry, + getHasError, + useClearResetErrorBoundary, +} from './errorBoundaryUtils' +import { useIsRestoring } from './IsRestoringProvider' +import { + ensureSuspenseTimers, + fetchOptimistic, + shouldSuspend, + willFetch, +} from './suspense' +import type { + QueryClient, + QueryKey, + QueryObserver, + QueryObserverResult, +} from '@tanstack/query-core' +import type { UseBaseQueryOptions } from './types' + +export function useBaseQuery< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey extends QueryKey, +>( + options: UseBaseQueryOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >, + Observer: typeof QueryObserver, + queryClient?: QueryClient, +): QueryObserverResult { + if (process.env.NODE_ENV !== 'production') { + if (typeof options !== 'object' || Array.isArray(options)) { + throw new Error( + 'Bad argument type. Starting with v5, only the "Object" form is allowed when calling query related functions. Please use the error stack to find the culprit call. More info here: https://tanstack.com/query/latest/docs/react/guides/migrating-to-v5#supports-a-single-signature-one-object', + ) + } + } + + const isRestoring = useIsRestoring() + const errorResetBoundary = useQueryErrorResetBoundary() + const client = useQueryClient(queryClient) + const defaultedOptions = client.defaultQueryOptions(options) + + ;(client.getDefaultOptions().queries as any)?._experimental_beforeQuery?.( + defaultedOptions, + ) + + if (process.env.NODE_ENV !== 'production') { + if (!defaultedOptions.queryFn) { + console.error( + `[${defaultedOptions.queryHash}]: No queryFn was passed as an option, and no default queryFn was found. The queryFn parameter is only optional when using a default queryFn. More info here: https://tanstack.com/query/latest/docs/framework/react/guides/default-query-function`, + ) + } + } + + // Make sure results are optimistically set in fetching state before subscribing or updating options + defaultedOptions._optimisticResults = isRestoring + ? 'isRestoring' + : 'optimistic' + + ensureSuspenseTimers(defaultedOptions) + ensurePreventErrorBoundaryRetry(defaultedOptions, errorResetBoundary) + + useClearResetErrorBoundary(errorResetBoundary) + + // this needs to be invoked before creating the Observer because that can create a cache entry + const isNewCacheEntry = !client + .getQueryCache() + .get(defaultedOptions.queryHash) + + const [observer] = React.useState( + () => + new Observer( + client, + defaultedOptions, + ), + ) + + // note: this must be called before useSyncExternalStore + const result = observer.getOptimisticResult(defaultedOptions) + + const shouldSubscribe = !isRestoring && options.subscribed !== false + React.useSyncExternalStore( + React.useCallback( + (onStoreChange) => { + const unsubscribe = shouldSubscribe + ? observer.subscribe(notifyManager.batchCalls(onStoreChange)) + : noop + + // Update result to make sure we did not miss any query updates + // between creating the observer and subscribing to it. + observer.updateResult() + + return unsubscribe + }, + [observer, shouldSubscribe], + ), + () => observer.getCurrentResult(), + () => observer.getCurrentResult(), + ) + + React.useEffect(() => { + observer.setOptions(defaultedOptions) + }, [defaultedOptions, observer]) + + // Handle suspense + if (shouldSuspend(defaultedOptions, result)) { + throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary) + } + + // Handle error boundary + if ( + getHasError({ + result, + errorResetBoundary, + throwOnError: defaultedOptions.throwOnError, + query: client + .getQueryCache() + .get< + TQueryFnData, + TError, + TQueryData, + TQueryKey + >(defaultedOptions.queryHash), + suspense: defaultedOptions.suspense, + }) + ) { + throw result.error + } + + ;(client.getDefaultOptions().queries as any)?._experimental_afterQuery?.( + defaultedOptions, + result, + ) + + if ( + defaultedOptions.experimental_prefetchInRender && + !isServer && + willFetch(result, isRestoring) + ) { + const promise = isNewCacheEntry + ? // Fetch immediately on render in order to ensure `.promise` is resolved even if the component is unmounted + fetchOptimistic(defaultedOptions, observer, errorResetBoundary) + : // subscribe to the "cache promise" so that we can finalize the currentThenable once data comes in + client.getQueryCache().get(defaultedOptions.queryHash)?.promise + + promise?.catch(noop).finally(() => { + // `.updateResult()` will trigger `.#currentThenable` to finalize + observer.updateResult() + }) + } + + // Handle result property usage tracking + return !defaultedOptions.notifyOnChangeProps + ? observer.trackResult(result) + : result +} diff --git a/packages/preact-query/src/useInfiniteQuery.ts b/packages/preact-query/src/useInfiniteQuery.ts new file mode 100644 index 0000000000..32ebfb7673 --- /dev/null +++ b/packages/preact-query/src/useInfiniteQuery.ts @@ -0,0 +1,81 @@ +'use client' +import { InfiniteQueryObserver } from '@tanstack/query-core' +import { useBaseQuery } from './useBaseQuery' +import type { + DefaultError, + InfiniteData, + QueryClient, + QueryKey, + QueryObserver, +} from '@tanstack/query-core' +import type { + DefinedUseInfiniteQueryResult, + UseInfiniteQueryOptions, + UseInfiniteQueryResult, +} from './types' +import type { + DefinedInitialDataInfiniteOptions, + UndefinedInitialDataInfiniteOptions, +} from './infiniteQueryOptions' + +export function useInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: DefinedInitialDataInfiniteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + queryClient?: QueryClient, +): DefinedUseInfiniteQueryResult + +export function useInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: UndefinedInitialDataInfiniteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + queryClient?: QueryClient, +): UseInfiniteQueryResult + +export function useInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: UseInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + queryClient?: QueryClient, +): UseInfiniteQueryResult + +export function useInfiniteQuery( + options: UseInfiniteQueryOptions, + queryClient?: QueryClient, +) { + return useBaseQuery( + options, + InfiniteQueryObserver as typeof QueryObserver, + queryClient, + ) +} diff --git a/packages/preact-query/src/useIsFetching.ts b/packages/preact-query/src/useIsFetching.ts new file mode 100644 index 0000000000..a6252912f2 --- /dev/null +++ b/packages/preact-query/src/useIsFetching.ts @@ -0,0 +1,24 @@ +'use client' +import * as React from 'react' +import { notifyManager } from '@tanstack/query-core' + +import { useQueryClient } from './QueryClientProvider' +import type { QueryClient, QueryFilters } from '@tanstack/query-core' + +export function useIsFetching( + filters?: QueryFilters, + queryClient?: QueryClient, +): number { + const client = useQueryClient(queryClient) + const queryCache = client.getQueryCache() + + return React.useSyncExternalStore( + React.useCallback( + (onStoreChange) => + queryCache.subscribe(notifyManager.batchCalls(onStoreChange)), + [queryCache], + ), + () => client.isFetching(filters), + () => client.isFetching(filters), + ) +} diff --git a/packages/preact-query/src/useMutation.ts b/packages/preact-query/src/useMutation.ts new file mode 100644 index 0000000000..2c66eb8ba8 --- /dev/null +++ b/packages/preact-query/src/useMutation.ts @@ -0,0 +1,69 @@ +'use client' +import * as React from 'react' +import { + MutationObserver, + noop, + notifyManager, + shouldThrowError, +} from '@tanstack/query-core' +import { useQueryClient } from './QueryClientProvider' +import type { + UseMutateFunction, + UseMutationOptions, + UseMutationResult, +} from './types' +import type { DefaultError, QueryClient } from '@tanstack/query-core' + +// HOOK + +export function useMutation< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + options: UseMutationOptions, + queryClient?: QueryClient, +): UseMutationResult { + const client = useQueryClient(queryClient) + + const [observer] = React.useState( + () => + new MutationObserver( + client, + options, + ), + ) + + React.useEffect(() => { + observer.setOptions(options) + }, [observer, options]) + + const result = React.useSyncExternalStore( + React.useCallback( + (onStoreChange) => + observer.subscribe(notifyManager.batchCalls(onStoreChange)), + [observer], + ), + () => observer.getCurrentResult(), + () => observer.getCurrentResult(), + ) + + const mutate = React.useCallback< + UseMutateFunction + >( + (variables, mutateOptions) => { + observer.mutate(variables, mutateOptions).catch(noop) + }, + [observer], + ) + + if ( + result.error && + shouldThrowError(observer.options.throwOnError, [result.error]) + ) { + throw result.error + } + + return { ...result, mutate, mutateAsync: result.mutate } +} diff --git a/packages/preact-query/src/useMutationState.ts b/packages/preact-query/src/useMutationState.ts new file mode 100644 index 0000000000..dfd0c41da3 --- /dev/null +++ b/packages/preact-query/src/useMutationState.ts @@ -0,0 +1,75 @@ +'use client' +import * as React from 'react' + +import { notifyManager, replaceEqualDeep } from '@tanstack/query-core' +import { useQueryClient } from './QueryClientProvider' +import type { + Mutation, + MutationCache, + MutationFilters, + MutationState, + QueryClient, +} from '@tanstack/query-core' + +export function useIsMutating( + filters?: MutationFilters, + queryClient?: QueryClient, +): number { + const client = useQueryClient(queryClient) + return useMutationState( + { filters: { ...filters, status: 'pending' } }, + client, + ).length +} + +type MutationStateOptions = { + filters?: MutationFilters + select?: (mutation: Mutation) => TResult +} + +function getResult( + mutationCache: MutationCache, + options: MutationStateOptions, +): Array { + return mutationCache + .findAll(options.filters) + .map( + (mutation): TResult => + (options.select ? options.select(mutation) : mutation.state) as TResult, + ) +} + +export function useMutationState( + options: MutationStateOptions = {}, + queryClient?: QueryClient, +): Array { + const mutationCache = useQueryClient(queryClient).getMutationCache() + const optionsRef = React.useRef(options) + const result = React.useRef>(null) + if (result.current === null) { + result.current = getResult(mutationCache, options) + } + + React.useEffect(() => { + optionsRef.current = options + }) + + return React.useSyncExternalStore( + React.useCallback( + (onStoreChange) => + mutationCache.subscribe(() => { + const nextResult = replaceEqualDeep( + result.current, + getResult(mutationCache, optionsRef.current), + ) + if (result.current !== nextResult) { + result.current = nextResult + notifyManager.schedule(onStoreChange) + } + }), + [mutationCache], + ), + () => result.current, + () => result.current, + )! +} diff --git a/packages/preact-query/src/usePrefetchInfiniteQuery.tsx b/packages/preact-query/src/usePrefetchInfiniteQuery.tsx new file mode 100644 index 0000000000..08c2fcdfa3 --- /dev/null +++ b/packages/preact-query/src/usePrefetchInfiniteQuery.tsx @@ -0,0 +1,30 @@ +import { useQueryClient } from './QueryClientProvider' +import type { + DefaultError, + FetchInfiniteQueryOptions, + QueryClient, + QueryKey, +} from '@tanstack/query-core' + +export function usePrefetchInfiniteQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: FetchInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + queryClient?: QueryClient, +) { + const client = useQueryClient(queryClient) + + if (!client.getQueryState(options.queryKey)) { + client.prefetchInfiniteQuery(options) + } +} diff --git a/packages/preact-query/src/usePrefetchQuery.tsx b/packages/preact-query/src/usePrefetchQuery.tsx new file mode 100644 index 0000000000..3f508c3324 --- /dev/null +++ b/packages/preact-query/src/usePrefetchQuery.tsx @@ -0,0 +1,19 @@ +import { useQueryClient } from './QueryClientProvider' +import type { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core' +import type { UsePrefetchQueryOptions } from './types' + +export function usePrefetchQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UsePrefetchQueryOptions, + queryClient?: QueryClient, +) { + const client = useQueryClient(queryClient) + + if (!client.getQueryState(options.queryKey)) { + client.prefetchQuery(options) + } +} diff --git a/packages/preact-query/src/useQueries.ts b/packages/preact-query/src/useQueries.ts new file mode 100644 index 0000000000..a736f5cd1d --- /dev/null +++ b/packages/preact-query/src/useQueries.ts @@ -0,0 +1,332 @@ +'use client' +import * as React from 'react' + +import { + QueriesObserver, + QueryObserver, + noop, + notifyManager, +} from '@tanstack/query-core' +import { useQueryClient } from './QueryClientProvider' +import { useIsRestoring } from './IsRestoringProvider' +import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary' +import { + ensurePreventErrorBoundaryRetry, + getHasError, + useClearResetErrorBoundary, +} from './errorBoundaryUtils' +import { + ensureSuspenseTimers, + fetchOptimistic, + shouldSuspend, + willFetch, +} from './suspense' +import type { + DefinedUseQueryResult, + UseQueryOptions, + UseQueryResult, +} from './types' +import type { + DefaultError, + OmitKeyof, + QueriesObserverOptions, + QueriesPlaceholderDataFunction, + QueryClient, + QueryFunction, + QueryKey, + QueryObserverOptions, + ThrowOnError, +} from '@tanstack/query-core' + +// This defines the `UseQueryOptions` that are accepted in `QueriesOptions` & `GetOptions`. +// `placeholderData` function always gets undefined passed +type UseQueryOptionsForUseQueries< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = OmitKeyof< + UseQueryOptions, + 'placeholderData' | 'subscribed' +> & { + placeholderData?: TQueryFnData | QueriesPlaceholderDataFunction +} + +// Avoid TS depth-limit error in case of large array literal +type MAXIMUM_DEPTH = 20 + +// Widen the type of the symbol to enable type inference even if skipToken is not immutable. +type SkipTokenForUseQueries = symbol + +type GetUseQueryOptionsForUseQueries = + // Part 1: responsible for applying explicit type parameter to function arguments, if object { queryFnData: TQueryFnData, error: TError, data: TData } + T extends { + queryFnData: infer TQueryFnData + error?: infer TError + data: infer TData + } + ? UseQueryOptionsForUseQueries + : T extends { queryFnData: infer TQueryFnData; error?: infer TError } + ? UseQueryOptionsForUseQueries + : T extends { data: infer TData; error?: infer TError } + ? UseQueryOptionsForUseQueries + : // Part 2: responsible for applying explicit type parameter to function arguments, if tuple [TQueryFnData, TError, TData] + T extends [infer TQueryFnData, infer TError, infer TData] + ? UseQueryOptionsForUseQueries + : T extends [infer TQueryFnData, infer TError] + ? UseQueryOptionsForUseQueries + : T extends [infer TQueryFnData] + ? UseQueryOptionsForUseQueries + : // Part 3: responsible for inferring and enforcing type if no explicit parameter was provided + T extends { + queryFn?: + | QueryFunction + | SkipTokenForUseQueries + select?: (data: any) => infer TData + throwOnError?: ThrowOnError + } + ? UseQueryOptionsForUseQueries< + TQueryFnData, + unknown extends TError ? DefaultError : TError, + unknown extends TData ? TQueryFnData : TData, + TQueryKey + > + : // Fallback + UseQueryOptionsForUseQueries + +// A defined initialData setting should return a DefinedUseQueryResult rather than UseQueryResult +type GetDefinedOrUndefinedQueryResult = T extends { + initialData?: infer TInitialData +} + ? unknown extends TInitialData + ? UseQueryResult + : TInitialData extends TData + ? DefinedUseQueryResult + : TInitialData extends () => infer TInitialDataResult + ? unknown extends TInitialDataResult + ? UseQueryResult + : TInitialDataResult extends TData + ? DefinedUseQueryResult + : UseQueryResult + : UseQueryResult + : UseQueryResult + +type GetUseQueryResult = + // Part 1: responsible for mapping explicit type parameter to function result, if object + T extends { queryFnData: any; error?: infer TError; data: infer TData } + ? GetDefinedOrUndefinedQueryResult + : T extends { queryFnData: infer TQueryFnData; error?: infer TError } + ? GetDefinedOrUndefinedQueryResult + : T extends { data: infer TData; error?: infer TError } + ? GetDefinedOrUndefinedQueryResult + : // Part 2: responsible for mapping explicit type parameter to function result, if tuple + T extends [any, infer TError, infer TData] + ? GetDefinedOrUndefinedQueryResult + : T extends [infer TQueryFnData, infer TError] + ? GetDefinedOrUndefinedQueryResult + : T extends [infer TQueryFnData] + ? GetDefinedOrUndefinedQueryResult + : // Part 3: responsible for mapping inferred type to results, if no explicit parameter was provided + T extends { + queryFn?: + | QueryFunction + | SkipTokenForUseQueries + select?: (data: any) => infer TData + throwOnError?: ThrowOnError + } + ? GetDefinedOrUndefinedQueryResult< + T, + unknown extends TData ? TQueryFnData : TData, + unknown extends TError ? DefaultError : TError + > + : // Fallback + UseQueryResult + +/** + * QueriesOptions reducer recursively unwraps function arguments to infer/enforce type param + */ +export type QueriesOptions< + T extends Array, + TResults extends Array = [], + TDepth extends ReadonlyArray = [], +> = TDepth['length'] extends MAXIMUM_DEPTH + ? Array + : T extends [] + ? [] + : T extends [infer Head] + ? [...TResults, GetUseQueryOptionsForUseQueries] + : T extends [infer Head, ...infer Tails] + ? QueriesOptions< + [...Tails], + [...TResults, GetUseQueryOptionsForUseQueries], + [...TDepth, 1] + > + : ReadonlyArray extends T + ? T + : // If T is *some* array but we couldn't assign unknown[] to it, then it must hold some known/homogenous type! + // use this to infer the param types in the case of Array.map() argument + T extends Array< + UseQueryOptionsForUseQueries< + infer TQueryFnData, + infer TError, + infer TData, + infer TQueryKey + > + > + ? Array< + UseQueryOptionsForUseQueries< + TQueryFnData, + TError, + TData, + TQueryKey + > + > + : // Fallback + Array + +/** + * QueriesResults reducer recursively maps type param to results + */ +export type QueriesResults< + T extends Array, + TResults extends Array = [], + TDepth extends ReadonlyArray = [], +> = TDepth['length'] extends MAXIMUM_DEPTH + ? Array + : T extends [] + ? [] + : T extends [infer Head] + ? [...TResults, GetUseQueryResult] + : T extends [infer Head, ...infer Tails] + ? QueriesResults< + [...Tails], + [...TResults, GetUseQueryResult], + [...TDepth, 1] + > + : { [K in keyof T]: GetUseQueryResult } + +export function useQueries< + T extends Array, + TCombinedResult = QueriesResults, +>( + { + queries, + ...options + }: { + queries: + | readonly [...QueriesOptions] + | readonly [...{ [K in keyof T]: GetUseQueryOptionsForUseQueries }] + combine?: (result: QueriesResults) => TCombinedResult + subscribed?: boolean + }, + queryClient?: QueryClient, +): TCombinedResult { + const client = useQueryClient(queryClient) + const isRestoring = useIsRestoring() + const errorResetBoundary = useQueryErrorResetBoundary() + + const defaultedQueries = React.useMemo( + () => + queries.map((opts) => { + const defaultedOptions = client.defaultQueryOptions( + opts as QueryObserverOptions, + ) + + // Make sure the results are already in fetching state before subscribing or updating options + defaultedOptions._optimisticResults = isRestoring + ? 'isRestoring' + : 'optimistic' + + return defaultedOptions + }), + [queries, client, isRestoring], + ) + + defaultedQueries.forEach((query) => { + ensureSuspenseTimers(query) + ensurePreventErrorBoundaryRetry(query, errorResetBoundary) + }) + + useClearResetErrorBoundary(errorResetBoundary) + + const [observer] = React.useState( + () => + new QueriesObserver( + client, + defaultedQueries, + options as QueriesObserverOptions, + ), + ) + + // note: this must be called before useSyncExternalStore + const [optimisticResult, getCombinedResult, trackResult] = + observer.getOptimisticResult( + defaultedQueries, + (options as QueriesObserverOptions).combine, + ) + + const shouldSubscribe = !isRestoring && options.subscribed !== false + React.useSyncExternalStore( + React.useCallback( + (onStoreChange) => + shouldSubscribe + ? observer.subscribe(notifyManager.batchCalls(onStoreChange)) + : noop, + [observer, shouldSubscribe], + ), + () => observer.getCurrentResult(), + () => observer.getCurrentResult(), + ) + + React.useEffect(() => { + observer.setQueries( + defaultedQueries, + options as QueriesObserverOptions, + ) + }, [defaultedQueries, options, observer]) + + const shouldAtLeastOneSuspend = optimisticResult.some((result, index) => + shouldSuspend(defaultedQueries[index], result), + ) + + const suspensePromises = shouldAtLeastOneSuspend + ? optimisticResult.flatMap((result, index) => { + const opts = defaultedQueries[index] + + if (opts) { + const queryObserver = new QueryObserver(client, opts) + if (shouldSuspend(opts, result)) { + return fetchOptimistic(opts, queryObserver, errorResetBoundary) + } else if (willFetch(result, isRestoring)) { + void fetchOptimistic(opts, queryObserver, errorResetBoundary) + } + } + return [] + }) + : [] + + if (suspensePromises.length > 0) { + throw Promise.all(suspensePromises) + } + const firstSingleResultWhichShouldThrow = optimisticResult.find( + (result, index) => { + const query = defaultedQueries[index] + return ( + query && + getHasError({ + result, + errorResetBoundary, + throwOnError: query.throwOnError, + query: client.getQueryCache().get(query.queryHash), + suspense: query.suspense, + }) + ) + }, + ) + + if (firstSingleResultWhichShouldThrow?.error) { + throw firstSingleResultWhichShouldThrow.error + } + + return getCombinedResult(trackResult()) +} diff --git a/packages/preact-query/src/useQuery.ts b/packages/preact-query/src/useQuery.ts new file mode 100644 index 0000000000..52d479551d --- /dev/null +++ b/packages/preact-query/src/useQuery.ts @@ -0,0 +1,52 @@ +'use client' +import { QueryObserver } from '@tanstack/query-core' +import { useBaseQuery } from './useBaseQuery' +import type { + DefaultError, + NoInfer, + QueryClient, + QueryKey, +} from '@tanstack/query-core' +import type { + DefinedUseQueryResult, + UseQueryOptions, + UseQueryResult, +} from './types' +import type { + DefinedInitialDataOptions, + UndefinedInitialDataOptions, +} from './queryOptions' + +export function useQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: DefinedInitialDataOptions, + queryClient?: QueryClient, +): DefinedUseQueryResult, TError> + +export function useQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UndefinedInitialDataOptions, + queryClient?: QueryClient, +): UseQueryResult, TError> + +export function useQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UseQueryOptions, + queryClient?: QueryClient, +): UseQueryResult, TError> + +export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) { + return useBaseQuery(options, QueryObserver, queryClient) +} diff --git a/packages/preact-query/src/useSuspenseInfiniteQuery.ts b/packages/preact-query/src/useSuspenseInfiniteQuery.ts new file mode 100644 index 0000000000..3c2fdfdd72 --- /dev/null +++ b/packages/preact-query/src/useSuspenseInfiniteQuery.ts @@ -0,0 +1,50 @@ +'use client' +import { InfiniteQueryObserver, skipToken } from '@tanstack/query-core' +import { useBaseQuery } from './useBaseQuery' +import { defaultThrowOnError } from './suspense' +import type { + DefaultError, + InfiniteData, + InfiniteQueryObserverSuccessResult, + QueryClient, + QueryKey, + QueryObserver, +} from '@tanstack/query-core' +import type { + UseSuspenseInfiniteQueryOptions, + UseSuspenseInfiniteQueryResult, +} from './types' + +export function useSuspenseInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: UseSuspenseInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + queryClient?: QueryClient, +): UseSuspenseInfiniteQueryResult { + if (process.env.NODE_ENV !== 'production') { + if ((options.queryFn as any) === skipToken) { + console.error('skipToken is not allowed for useSuspenseInfiniteQuery') + } + } + + return useBaseQuery( + { + ...options, + enabled: true, + suspense: true, + throwOnError: defaultThrowOnError, + }, + InfiniteQueryObserver as typeof QueryObserver, + queryClient, + ) as InfiniteQueryObserverSuccessResult +} diff --git a/packages/preact-query/src/useSuspenseQueries.ts b/packages/preact-query/src/useSuspenseQueries.ts new file mode 100644 index 0000000000..f014095d01 --- /dev/null +++ b/packages/preact-query/src/useSuspenseQueries.ts @@ -0,0 +1,211 @@ +'use client' +import { skipToken } from '@tanstack/query-core' +import { useQueries } from './useQueries' +import { defaultThrowOnError } from './suspense' +import type { UseSuspenseQueryOptions, UseSuspenseQueryResult } from './types' +import type { + DefaultError, + QueryClient, + QueryFunction, + ThrowOnError, +} from '@tanstack/query-core' + +// Avoid TS depth-limit error in case of large array literal +type MAXIMUM_DEPTH = 20 + +// Widen the type of the symbol to enable type inference even if skipToken is not immutable. +type SkipTokenForUseQueries = symbol + +type GetUseSuspenseQueryOptions = + // Part 1: responsible for applying explicit type parameter to function arguments, if object { queryFnData: TQueryFnData, error: TError, data: TData } + T extends { + queryFnData: infer TQueryFnData + error?: infer TError + data: infer TData + } + ? UseSuspenseQueryOptions + : T extends { queryFnData: infer TQueryFnData; error?: infer TError } + ? UseSuspenseQueryOptions + : T extends { data: infer TData; error?: infer TError } + ? UseSuspenseQueryOptions + : // Part 2: responsible for applying explicit type parameter to function arguments, if tuple [TQueryFnData, TError, TData] + T extends [infer TQueryFnData, infer TError, infer TData] + ? UseSuspenseQueryOptions + : T extends [infer TQueryFnData, infer TError] + ? UseSuspenseQueryOptions + : T extends [infer TQueryFnData] + ? UseSuspenseQueryOptions + : // Part 3: responsible for inferring and enforcing type if no explicit parameter was provided + T extends { + queryFn?: + | QueryFunction + | SkipTokenForUseQueries + select?: (data: any) => infer TData + throwOnError?: ThrowOnError + } + ? UseSuspenseQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey + > + : T extends { + queryFn?: + | QueryFunction + | SkipTokenForUseQueries + throwOnError?: ThrowOnError + } + ? UseSuspenseQueryOptions< + TQueryFnData, + TError, + TQueryFnData, + TQueryKey + > + : // Fallback + UseSuspenseQueryOptions + +type GetUseSuspenseQueryResult = + // Part 1: responsible for mapping explicit type parameter to function result, if object + T extends { queryFnData: any; error?: infer TError; data: infer TData } + ? UseSuspenseQueryResult + : T extends { queryFnData: infer TQueryFnData; error?: infer TError } + ? UseSuspenseQueryResult + : T extends { data: infer TData; error?: infer TError } + ? UseSuspenseQueryResult + : // Part 2: responsible for mapping explicit type parameter to function result, if tuple + T extends [any, infer TError, infer TData] + ? UseSuspenseQueryResult + : T extends [infer TQueryFnData, infer TError] + ? UseSuspenseQueryResult + : T extends [infer TQueryFnData] + ? UseSuspenseQueryResult + : // Part 3: responsible for mapping inferred type to results, if no explicit parameter was provided + T extends { + queryFn?: + | QueryFunction + | SkipTokenForUseQueries + select?: (data: any) => infer TData + throwOnError?: ThrowOnError + } + ? UseSuspenseQueryResult< + unknown extends TData ? TQueryFnData : TData, + unknown extends TError ? DefaultError : TError + > + : T extends { + queryFn?: + | QueryFunction + | SkipTokenForUseQueries + throwOnError?: ThrowOnError + } + ? UseSuspenseQueryResult< + TQueryFnData, + unknown extends TError ? DefaultError : TError + > + : // Fallback + UseSuspenseQueryResult + +/** + * SuspenseQueriesOptions reducer recursively unwraps function arguments to infer/enforce type param + */ +export type SuspenseQueriesOptions< + T extends Array, + TResults extends Array = [], + TDepth extends ReadonlyArray = [], +> = TDepth['length'] extends MAXIMUM_DEPTH + ? Array + : T extends [] + ? [] + : T extends [infer Head] + ? [...TResults, GetUseSuspenseQueryOptions] + : T extends [infer Head, ...infer Tails] + ? SuspenseQueriesOptions< + [...Tails], + [...TResults, GetUseSuspenseQueryOptions], + [...TDepth, 1] + > + : Array extends T + ? T + : // If T is *some* array but we couldn't assign unknown[] to it, then it must hold some known/homogenous type! + // use this to infer the param types in the case of Array.map() argument + T extends Array< + UseSuspenseQueryOptions< + infer TQueryFnData, + infer TError, + infer TData, + infer TQueryKey + > + > + ? Array< + UseSuspenseQueryOptions + > + : // Fallback + Array + +/** + * SuspenseQueriesResults reducer recursively maps type param to results + */ +export type SuspenseQueriesResults< + T extends Array, + TResults extends Array = [], + TDepth extends ReadonlyArray = [], +> = TDepth['length'] extends MAXIMUM_DEPTH + ? Array + : T extends [] + ? [] + : T extends [infer Head] + ? [...TResults, GetUseSuspenseQueryResult] + : T extends [infer Head, ...infer Tails] + ? SuspenseQueriesResults< + [...Tails], + [...TResults, GetUseSuspenseQueryResult], + [...TDepth, 1] + > + : { [K in keyof T]: GetUseSuspenseQueryResult } + +export function useSuspenseQueries< + T extends Array, + TCombinedResult = SuspenseQueriesResults, +>( + options: { + queries: + | readonly [...SuspenseQueriesOptions] + | readonly [...{ [K in keyof T]: GetUseSuspenseQueryOptions }] + combine?: (result: SuspenseQueriesResults) => TCombinedResult + }, + queryClient?: QueryClient, +): TCombinedResult + +export function useSuspenseQueries< + T extends Array, + TCombinedResult = SuspenseQueriesResults, +>( + options: { + queries: readonly [...SuspenseQueriesOptions] + combine?: (result: SuspenseQueriesResults) => TCombinedResult + }, + queryClient?: QueryClient, +): TCombinedResult + +export function useSuspenseQueries(options: any, queryClient?: QueryClient) { + return useQueries( + { + ...options, + queries: options.queries.map((query: any) => { + if (process.env.NODE_ENV !== 'production') { + if (query.queryFn === skipToken) { + console.error('skipToken is not allowed for useSuspenseQueries') + } + } + + return { + ...query, + suspense: true, + throwOnError: defaultThrowOnError, + enabled: true, + placeholderData: undefined, + } + }), + }, + queryClient, + ) +} diff --git a/packages/preact-query/src/useSuspenseQuery.ts b/packages/preact-query/src/useSuspenseQuery.ts new file mode 100644 index 0000000000..7dfdb06477 --- /dev/null +++ b/packages/preact-query/src/useSuspenseQuery.ts @@ -0,0 +1,34 @@ +'use client' +import { QueryObserver, skipToken } from '@tanstack/query-core' +import { useBaseQuery } from './useBaseQuery' +import { defaultThrowOnError } from './suspense' +import type { UseSuspenseQueryOptions, UseSuspenseQueryResult } from './types' +import type { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core' + +export function useSuspenseQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UseSuspenseQueryOptions, + queryClient?: QueryClient, +): UseSuspenseQueryResult { + if (process.env.NODE_ENV !== 'production') { + if ((options.queryFn as any) === skipToken) { + console.error('skipToken is not allowed for useSuspenseQuery') + } + } + + return useBaseQuery( + { + ...options, + enabled: true, + suspense: true, + throwOnError: defaultThrowOnError, + placeholderData: undefined, + }, + QueryObserver, + queryClient, + ) as UseSuspenseQueryResult +} diff --git a/packages/preact-query/test-setup.ts b/packages/preact-query/test-setup.ts new file mode 100644 index 0000000000..1103a58b52 --- /dev/null +++ b/packages/preact-query/test-setup.ts @@ -0,0 +1,16 @@ +import '@testing-library/jest-dom/vitest' +import { act, cleanup as cleanupRTL } from '@testing-library/react' +import { cleanup as cleanupRRS } from '@testing-library/react-render-stream' +import { afterEach } from 'vitest' +import { notifyManager } from '@tanstack/query-core' + +// https://testing-library.com/docs/react-testing-library/api#cleanup +afterEach(() => { + cleanupRTL() + cleanupRRS() +}) + +// Wrap notifications with act to make sure React knows about React Query updates +notifyManager.setNotifyFunction((fn) => { + act(fn) +}) diff --git a/packages/preact-query/tsconfig.json b/packages/preact-query/tsconfig.json new file mode 100644 index 0000000000..68d785f0c0 --- /dev/null +++ b/packages/preact-query/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist-ts", + "rootDir": ".", + "jsx": "react-jsx" + }, + "include": ["src", "test-setup.ts", "*.config.*", "package.json"] +} diff --git a/packages/preact-query/tsconfig.legacy.json b/packages/preact-query/tsconfig.legacy.json new file mode 100644 index 0000000000..9c49fb8567 --- /dev/null +++ b/packages/preact-query/tsconfig.legacy.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "./dist-ts/legacy" + }, + "include": ["src"], + "exclude": ["src/__tests__"], + "references": [{ "path": "../query-persist-client-core" }] +} diff --git a/packages/preact-query/tsconfig.prod.json b/packages/preact-query/tsconfig.prod.json new file mode 100644 index 0000000000..0f4c92da06 --- /dev/null +++ b/packages/preact-query/tsconfig.prod.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "incremental": false, + "composite": false, + "rootDir": "../../" + } +} diff --git a/packages/preact-query/tsup.config.ts b/packages/preact-query/tsup.config.ts new file mode 100644 index 0000000000..ef4a978d12 --- /dev/null +++ b/packages/preact-query/tsup.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsup' +import { legacyConfig, modernConfig } from './root.tsup.config.js' + +export default defineConfig([ + modernConfig({ entry: ['src/*.ts', 'src/*.tsx'] }), + legacyConfig({ entry: ['src/*.ts', 'src/*.tsx'] }), +]) diff --git a/packages/preact-query/vite.config.ts b/packages/preact-query/vite.config.ts new file mode 100644 index 0000000000..01ab3b00df --- /dev/null +++ b/packages/preact-query/vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +import packageJson from './package.json' + +export default defineConfig({ + plugins: [react()], + // fix from https://github.com/vitest-dev/vitest/issues/6992#issuecomment-2509408660 + resolve: { + conditions: ['@tanstack/custom-condition'], + }, + environments: { + ssr: { + resolve: { + conditions: ['@tanstack/custom-condition'], + }, + }, + }, + test: { + name: packageJson.name, + dir: './src', + watch: false, + environment: 'jsdom', + setupFiles: ['test-setup.ts'], + coverage: { enabled: true, provider: 'istanbul', include: ['src/**/*'] }, + typecheck: { enabled: true }, + restoreMocks: true, + retry: process.env.CI ? 3 : 0, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17f9a82bf6..f7749932de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -619,6 +619,28 @@ importers: specifier: 5.8.3 version: 5.8.3 + examples/preact/simple: + dependencies: + preact: + specifier: ^10.26.9 + version: 10.28.0 + devDependencies: + '@preact/preset-vite': + specifier: ^2.10.2 + version: 2.10.2(@babel/core@7.27.1)(preact@10.28.0)(vite@7.2.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1)) + eslint: + specifier: ^9.36.0 + version: 9.36.0(jiti@2.5.1) + eslint-config-preact: + specifier: ^2.0.0 + version: 2.0.0(eslint@9.36.0(jiti@2.5.1)) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.0.4 + version: 7.2.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1) + examples/react/algolia: dependencies: '@algolia/client-search': @@ -2343,6 +2365,49 @@ importers: specifier: ^5.0.0 version: 5.0.2 + packages/preact-query: + dependencies: + '@tanstack/query-core': + specifier: workspace:* + version: link:../query-core + devDependencies: + '@tanstack/query-persist-client-core': + specifier: workspace:* + version: link:../query-persist-client-core + '@tanstack/query-test-utils': + specifier: workspace:* + version: link:../query-test-utils + '@testing-library/react': + specifier: ^16.1.0 + version: 16.1.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.2(@types/react@19.0.1))(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@testing-library/react-render-stream': + specifier: ^2.0.0 + version: 2.0.0(@jest/globals@29.7.0)(@types/react-dom@19.0.2(@types/react@19.0.1))(@types/react@19.0.1)(expect@29.7.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@types/react': + specifier: ^19.0.1 + version: 19.0.1 + '@types/react-dom': + specifier: ^19.0.2 + version: 19.0.2(@types/react@19.0.1) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.3.4(vite@6.3.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1)) + cpy-cli: + specifier: ^5.0.0 + version: 5.0.0 + npm-run-all2: + specifier: ^5.0.0 + version: 5.0.2 + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + react-error-boundary: + specifier: ^4.1.2 + version: 4.1.2(react@19.0.0) + packages/query-async-storage-persister: dependencies: '@tanstack/query-core': @@ -3112,6 +3177,13 @@ packages: resolution: {integrity: sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==} engines: {node: '>=6.9.0'} + '@babel/eslint-parser@7.28.5': + resolution: {integrity: sha512-fcdRcWahONYo+JRnJg1/AekOacGvKx12Gu0qXJXFi2WBqQA1i7+O5PaxRB7kxE/Op94dExnCiiar6T09pvdHpA==} + engines: {node: ^10.13.0 || ^12.13.0 || >=14.0.0} + peerDependencies: + '@babel/core': ^7.11.0 + eslint: ^7.5.0 || ^8.0.0 || ^9.0.0 + '@babel/generator@7.27.1': resolution: {integrity: sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==} engines: {node: '>=6.9.0'} @@ -5379,6 +5451,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@mdn/browser-compat-data@5.7.6': + resolution: {integrity: sha512-7xdrMX0Wk7grrTZQwAoy1GkvPMFoizStUoL+VmtUkAxegbCCec+3FKwOM6yc/uGU5+BEczQHXAlWiqvM8JeENg==} + '@microsoft/api-extractor-model@7.29.6': resolution: {integrity: sha512-gC0KGtrZvxzf/Rt9oMYD2dHvtN/1KPEYsrQPyMKhLHnlVuO/f4AFN3E4toqZzD2pt4LhkKoYmL2H9tX3yCOyRw==} @@ -5709,6 +5784,9 @@ packages: cpu: [x64] os: [win32] + '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': + resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -6043,6 +6121,29 @@ packages: resolution: {integrity: sha512-aQypoot0HPSJa6gDPEPTntc1GT6QINrSbgRlRhadGW2WaYqUK3tK4Bw9SBMZXhmxd3GeAlZjVcODHgiu+THY7A==} engines: {node: '>=18'} + '@preact/preset-vite@2.10.2': + resolution: {integrity: sha512-K9wHlJOtkE+cGqlyQ5v9kL3Ge0Ql4LlIZjkUTL+1zf3nNdF88F9UZN6VTV8jdzBX9Fl7WSzeNMSDG7qECPmSmg==} + peerDependencies: + '@babel/core': 7.x + vite: 2.x || 3.x || 4.x || 5.x || 6.x || 7.x + + '@prefresh/babel-plugin@0.5.2': + resolution: {integrity: sha512-AOl4HG6dAxWkJ5ndPHBgBa49oo/9bOiJuRDKHLSTyH+Fd9x00shTXpdiTj1W41l6oQIwUOAgJeHMn4QwIDpHkA==} + + '@prefresh/core@1.5.9': + resolution: {integrity: sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==} + peerDependencies: + preact: ^10.0.0 || ^11.0.0-0 + + '@prefresh/utils@1.2.1': + resolution: {integrity: sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==} + + '@prefresh/vite@2.4.11': + resolution: {integrity: sha512-/XjURQqdRiCG3NpMmWqE9kJwrg9IchIOWHzulCfqg2sRe/8oQ1g5De7xrk9lbqPIQLn7ntBkKdqWXIj4E9YXyg==} + peerDependencies: + preact: ^10.4.0 || ^11.0.0-0 + vite: '>=2.0.0' + '@publint/pack@0.1.2': resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==} engines: {node: '>=18'} @@ -6228,6 +6329,10 @@ packages: rollup: optional: true + '@rollup/pluginutils@4.2.1': + resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} + engines: {node: '>= 8.0.0'} + '@rollup/pluginutils@5.1.4': resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==} engines: {node: '>=14.0.0'} @@ -6242,51 +6347,106 @@ packages: cpu: [arm] os: [android] + '@rollup/rollup-android-arm-eabi@4.53.3': + resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} + cpu: [arm] + os: [android] + '@rollup/rollup-android-arm64@4.40.2': resolution: {integrity: sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==} cpu: [arm64] os: [android] + '@rollup/rollup-android-arm64@4.53.3': + resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} + cpu: [arm64] + os: [android] + '@rollup/rollup-darwin-arm64@4.40.2': resolution: {integrity: sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==} cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-arm64@4.53.3': + resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.40.2': resolution: {integrity: sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==} cpu: [x64] os: [darwin] + '@rollup/rollup-darwin-x64@4.53.3': + resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} + cpu: [x64] + os: [darwin] + '@rollup/rollup-freebsd-arm64@4.40.2': resolution: {integrity: sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==} cpu: [arm64] os: [freebsd] + '@rollup/rollup-freebsd-arm64@4.53.3': + resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} + cpu: [arm64] + os: [freebsd] + '@rollup/rollup-freebsd-x64@4.40.2': resolution: {integrity: sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==} cpu: [x64] os: [freebsd] + '@rollup/rollup-freebsd-x64@4.53.3': + resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} + cpu: [x64] + os: [freebsd] + '@rollup/rollup-linux-arm-gnueabihf@4.40.2': resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.40.2': resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.40.2': resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.53.3': + resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-arm64-musl@4.40.2': resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-musl@4.53.3': + resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.53.3': + resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} + cpu: [loong64] + os: [linux] + '@rollup/rollup-linux-loongarch64-gnu@4.40.2': resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==} cpu: [loong64] @@ -6297,46 +6457,101 @@ packages: cpu: [ppc64] os: [linux] + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} + cpu: [ppc64] + os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.40.2': resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==} cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-riscv64-musl@4.40.2': resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==} cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-riscv64-musl@4.53.3': + resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.40.2': resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==} cpu: [s390x] os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.53.3': + resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} + cpu: [s390x] + os: [linux] + '@rollup/rollup-linux-x64-gnu@4.40.2': resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-gnu@4.53.3': + resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} + cpu: [x64] + os: [linux] + '@rollup/rollup-linux-x64-musl@4.40.2': resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-musl@4.53.3': + resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.53.3': + resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} + cpu: [arm64] + os: [openharmony] + '@rollup/rollup-win32-arm64-msvc@4.40.2': resolution: {integrity: sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==} cpu: [arm64] os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.53.3': + resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.40.2': resolution: {integrity: sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==} cpu: [ia32] os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.53.3': + resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.53.3': + resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} + cpu: [x64] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.40.2': resolution: {integrity: sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-msvc@4.53.3': + resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} + cpu: [x64] + os: [win32] + '@rushstack/node-core-library@5.10.1': resolution: {integrity: sha512-BSb/KcyBHmUQwINrgtzo6jiH0HlGFmrUy33vO6unmceuVKTEyL2q+P0fQq2oB5hvXVWOEUhxB2QvlkZluvUEmg==} peerDependencies: @@ -6893,6 +7108,9 @@ packages: '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -7795,6 +8013,14 @@ packages: resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + array-iterate@2.0.1: resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} @@ -7809,14 +8035,34 @@ packages: resolution: {integrity: sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==} engines: {node: '>=0.10.0'} + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + array.prototype.reduce@1.0.7: resolution: {integrity: sha512-mzmiUCVwtiD4lgxYP8g7IYy8El8p2CSMePvIbTS7gchKir/L1fgJrk0yDKmAX6mnRQFKNADYIk8nNlTris5H1Q==} engines: {node: '>= 0.4'} + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + arraybuffer.prototype.slice@1.0.3: resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} engines: {node: '>= 0.4'} + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + arrify@3.0.0: resolution: {integrity: sha512-tLkvA81vQG/XqE2mjDkGQHoOINtMHtysSnemrmoGe6PydDPMRbVugqyk4A6V/WDWEfm3l+0d8anA9r8cv/5Jaw==} engines: {node: '>=12'} @@ -7838,6 +8084,9 @@ packages: resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==} engines: {node: '>=0.10.0'} + ast-metadata-inferer@0.8.1: + resolution: {integrity: sha512-ht3Dm6Zr7SXv6t1Ra6gFo0+kLDglHGrEbYihTkcycrbHw7WCcuhBzPlJYHEsIpycaUwzsJHje+vUcxXUX4ztTA==} + ast-types@0.15.2: resolution: {integrity: sha512-c27loCv9QkZinsa5ProX751khO9DJl/AcB5c2KNtA6NRvHKS0PgLfcftz72KVq504vB0Gku5s2kUZzDBvQWvHg==} engines: {node: '>=4'} @@ -7858,6 +8107,10 @@ packages: async-each@1.0.6: resolution: {integrity: sha512-c646jH1avxr+aVpndVMeAfYw7wAa6idufrlN3LPA4PmKS0QEGp6PIC9nwz0WQkkvBGAMEki3pFdtxaF39J9vvg==} + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + async-limiter@1.0.1: resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} @@ -7976,6 +8229,11 @@ packages: babel-plugin-transform-flow-enums@0.0.2: resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + babel-plugin-transform-hook-names@1.0.2: + resolution: {integrity: sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==} + peerDependencies: + '@babel/core': ^7.12.10 + babel-preset-current-node-syntax@1.1.0: resolution: {integrity: sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==} peerDependencies: @@ -8217,10 +8475,22 @@ packages: resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==} engines: {node: '>=0.10.0'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + call-bind@1.0.7: resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} engines: {node: '>= 0.4'} + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + caller-callsite@2.0.0: resolution: {integrity: sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ==} engines: {node: '>=4'} @@ -8797,14 +9067,26 @@ packages: resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} engines: {node: '>= 0.4'} + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + data-view-byte-length@1.0.1: resolution: {integrity: sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==} engines: {node: '>= 0.4'} + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + data-view-byte-offset@1.0.0: resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} engines: {node: '>= 0.4'} + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} @@ -9021,6 +9303,10 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -9076,6 +9362,10 @@ packages: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} @@ -9208,6 +9498,10 @@ packages: resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} engines: {node: '>= 0.4'} + es-abstract@1.24.0: + resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + engines: {node: '>= 0.4'} + es-array-method-boxes-properly@1.0.0: resolution: {integrity: sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==} @@ -9215,10 +9509,18 @@ packages: resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} engines: {node: '>= 0.4'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-iterator-helpers@1.2.1: + resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -9226,14 +9528,30 @@ packages: resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} engines: {node: '>= 0.4'} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + es-set-tostringtag@2.0.3: resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + es-to-primitive@1.2.1: resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} engines: {node: '>= 0.4'} + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + esbuild-plugin-file-path-extensions@2.1.4: resolution: {integrity: sha512-lNjylaAsJMprYg28zjUyBivP3y0ms9b7RJZ5tdhDUFLa3sCbqZw4wDnbFUSmnyZYWhCYDPxxp7KkXM2TXGw3PQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} @@ -9288,6 +9606,11 @@ packages: peerDependencies: eslint: '>=6.0.0' + eslint-config-preact@2.0.0: + resolution: {integrity: sha512-TFj70lEE7y3R9DQAFJ/clRfVmyaXdwE3q56gA9zm+iTmlpYjtZKtV1jv/jtgdF2LqgvJjlGlGE1rHVwE9yNdkg==} + peerDependencies: + eslint: ^8.57.1 || ^9.0.0 + eslint-import-context@0.1.9: resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -9300,6 +9623,12 @@ packages: eslint-import-resolver-node@0.3.9: resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + eslint-plugin-compat@6.0.2: + resolution: {integrity: sha512-1ME+YfJjmOz1blH0nPZpHgjMGK4kjgEeoYqGCqoBPQ/mGu/dJzdoP0f1C8H2jcWZjzhZjAMccbM/VdXhPORIfA==} + engines: {node: '>=18.x'} + peerDependencies: + eslint: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + eslint-plugin-es-x@7.8.0: resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -9352,6 +9681,12 @@ packages: eslint: ^9.36.0 typescript: ^5.9.2 + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint-plugin-react-hooks@6.1.1: resolution: {integrity: sha512-St9EKZzOAQF704nt2oJvAKZHjhrpg25ClQoaAlHmPZuajFldVLqRDW4VBNAS01NzeiQF0m0qhG1ZA807K6aVaQ==} engines: {node: '>=18'} @@ -9380,6 +9715,12 @@ packages: ts-api-utils: ^2.1.0 typescript: ^5.9.2 + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + eslint-plugin-svelte@3.11.0: resolution: {integrity: sha512-KliWlkieHyEa65aQIkRwUFfHzT5Cn4u3BQQsu3KlkJOs7c1u7ryn84EWaOjEzilbKgttT4OfBURA8Uc4JBSQIw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -9416,6 +9757,10 @@ packages: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@2.1.0: + resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==} + engines: {node: '>=10'} + eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -9781,6 +10126,10 @@ packages: for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + for-in@1.0.2: resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==} engines: {node: '>=0.10.0'} @@ -9879,9 +10228,17 @@ packages: resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} engines: {node: '>= 0.4'} + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + gensequence@7.0.0: resolution: {integrity: sha512-47Frx13aZh01afHJTB3zTtKIlFI6vWY+MYCN9Qpew6i52rfKjnhCF/l1YlC8UmEMvvntZZ6z4PiCcmyuedR2aQ==} engines: {node: '>=18'} @@ -9902,6 +10259,10 @@ packages: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} @@ -9913,6 +10274,10 @@ packages: resolution: {integrity: sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg==} engines: {node: '>=4'} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@4.1.0: resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} engines: {node: '>=6'} @@ -9929,6 +10294,10 @@ packages: resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} engines: {node: '>= 0.4'} + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} @@ -10020,6 +10389,10 @@ packages: gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -10067,10 +10440,18 @@ packages: resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} engines: {node: '>= 0.4'} + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + has-symbols@1.0.3: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} engines: {node: '>= 0.4'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} @@ -10407,6 +10788,10 @@ packages: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + interpret@2.2.0: resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} engines: {node: '>= 0.10'} @@ -10445,15 +10830,27 @@ packages: resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} engines: {node: '>= 0.4'} + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} is-arrayish@0.3.2: resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + is-bigint@1.0.4: resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + is-binary-path@1.0.1: resolution: {integrity: sha512-9fRVlXc0uCxEDj1nQzaWONSpbTfx0FmJfzHF7pwlI8DkWGoHBBea4Pg5Ky0ojwwxQmnSifgbKkI06Qv0Ljgj+Q==} engines: {node: '>=0.10.0'} @@ -10466,6 +10863,10 @@ packages: resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} engines: {node: '>= 0.4'} + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + is-buffer@1.1.6: resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} @@ -10485,10 +10886,18 @@ packages: resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==} engines: {node: '>= 0.4'} + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + is-date-object@1.0.5: resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} engines: {node: '>= 0.4'} + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + is-descriptor@0.1.7: resolution: {integrity: sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==} engines: {node: '>= 0.4'} @@ -10523,6 +10932,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -10535,6 +10948,10 @@ packages: resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} engines: {node: '>=18'} + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + is-glob@3.1.0: resolution: {integrity: sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==} engines: {node: '>=0.10.0'} @@ -10565,6 +10982,10 @@ packages: is-lambda@1.0.1: resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + is-module@1.0.0: resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} @@ -10579,6 +11000,10 @@ packages: resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} engines: {node: '>= 0.4'} + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + is-number@3.0.0: resolution: {integrity: sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==} engines: {node: '>=0.10.0'} @@ -10616,10 +11041,22 @@ packages: resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} engines: {node: '>= 0.4'} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + is-shared-array-buffer@1.0.3: resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} engines: {node: '>= 0.4'} + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + is-stream@1.1.0: resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} engines: {node: '>=0.10.0'} @@ -10636,6 +11073,10 @@ packages: resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} engines: {node: '>= 0.4'} + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -10644,10 +11085,18 @@ packages: resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} engines: {node: '>= 0.4'} + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + is-typed-array@1.1.13: resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} engines: {node: '>= 0.4'} + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -10660,9 +11109,21 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + is-weakref@1.0.2: resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + is-what@3.14.1: resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} @@ -10735,6 +11196,10 @@ packages: resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} engines: {node: '>=8'} + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -10939,6 +11404,10 @@ packages: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + junk@4.0.1: resolution: {integrity: sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==} engines: {node: '>=12.20'} @@ -11227,6 +11696,9 @@ packages: lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -11370,6 +11842,10 @@ packages: match-sorter@6.3.4: resolution: {integrity: sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + md5-file@3.2.3: resolution: {integrity: sha512-3Tkp1piAHaworfcCgH0jKbTvj1jWWFgbvh2cXaNCgHwyTCBxxvD1Y04rmfpvdPm1P4oXMOpm6+2H7sr7v9v8Fw==} engines: {node: '>=0.10'} @@ -12004,6 +12480,9 @@ packages: engines: {node: ^16.14.0 || >=18.0.0} hasBin: true + node-html-parser@6.1.13: + resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==} + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -12146,6 +12625,10 @@ packages: resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} engines: {node: '>= 0.4'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -12162,6 +12645,18 @@ packages: resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==} engines: {node: '>= 0.4'} + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + object.getownpropertydescriptors@2.1.8: resolution: {integrity: sha512-qkHIGe4q0lSYMv0XI4SsBTJz3WaURhLvd0lKSgtVuOsJ2krg4SgMw3PIRQFMp07yi++UR3se2mkcLqsBNpBb/A==} engines: {node: '>= 0.8'} @@ -12170,6 +12665,10 @@ packages: resolution: {integrity: sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==} engines: {node: '>=0.10.0'} + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + oblivious-set@1.4.0: resolution: {integrity: sha512-szyd0ou0T8nsAqHtprRcP3WidfsN1TnAR5yWXf2mFCEr5ek3LEOkT6EZ/92Xfs74HIdyhG5WkGxIssMU0jBaeg==} engines: {node: '>=16'} @@ -12279,6 +12778,10 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + oxc-resolver@11.8.2: resolution: {integrity: sha512-SM31gnF1l4T8YA7dkAcBhA+jc336bc8scy0Tetz6ndzGmV6c0R99SRnx6In0V5ffwvn1Isjo9I9EGSLF4xi3TA==} @@ -12711,6 +13214,9 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + preact@10.28.0: + resolution: {integrity: sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -13000,6 +13506,7 @@ packages: react-native-vector-icons@10.1.0: resolution: {integrity: sha512-fdQjCHIdoXmRoTZ5gvN1FmT4sGLQ2wmQiNZHKJQUYnE2tkIwjGnxNch+6Nd4lHAACvMWO7LOzBNot2u/zlOmkw==} + deprecated: react-native-vector-icons package has moved to a new model of per-icon-family packages. See the https://github.com/oblador/react-native-vector-icons/blob/master/MIGRATION.md on how to migrate hasBin: true react-native-web@0.19.13: @@ -13118,6 +13625,10 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + regenerate-unicode-properties@10.2.0: resolution: {integrity: sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==} engines: {node: '>=4'} @@ -13154,6 +13665,10 @@ packages: resolution: {integrity: sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==} engines: {node: '>= 0.4'} + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + regexpu-core@6.2.0: resolution: {integrity: sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==} engines: {node: '>=4'} @@ -13290,6 +13805,10 @@ packages: resolve@1.7.1: resolution: {integrity: sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw==} + resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + restore-cursor@2.0.0: resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} engines: {node: '>=4'} @@ -13379,6 +13898,11 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rollup@4.53.3: + resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rooks@8.0.0: resolution: {integrity: sha512-wgkKFTZRkcOvf+3dAHBDJb7918Muy4l0vA+nvO/6LilrghCr5Z8DmxdvIDF4gQeNfgjJ3iCubFvI3tMpztjirA==} engines: {node: '>=v10.24.1'} @@ -13409,6 +13933,10 @@ packages: resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} engines: {node: '>=0.4'} + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -13418,10 +13946,18 @@ packages: safe-json-stringify@1.2.0: resolution: {integrity: sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==} + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + safe-regex-test@1.0.3: resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} engines: {node: '>= 0.4'} + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + safe-regex@1.1.0: resolution: {integrity: sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==} @@ -13544,6 +14080,10 @@ packages: resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} engines: {node: '>= 0.4'} + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + set-value@2.0.1: resolution: {integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==} engines: {node: '>=0.10.0'} @@ -13630,10 +14170,26 @@ packages: shiki@3.2.1: resolution: {integrity: sha512-VML/2o1/KGYkEf/stJJ+s9Ypn7jUKQPomGLGYso4JJFMFxVDyPNsjsI3MB3KLjlMOeH44gyaPdXC6rik2WXvUQ==} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + side-channel@1.0.6: resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} engines: {node: '>= 0.4'} + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -13648,6 +14204,9 @@ packages: resolution: {integrity: sha512-PHMifhh3EN4loMcHCz6l3v/luzgT3za+9f8subGgeMNjbJjzH4Ij/YoX3Gvu+kaouJRIlVdTHHCREADYf+ZteA==} engines: {node: ^18.17.0 || >=20.5.0} + simple-code-frame@1.3.0: + resolution: {integrity: sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==} + simple-plist@1.3.1: resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} @@ -13844,6 +14403,10 @@ packages: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} + stack-trace@1.0.0-pre2: + resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==} + engines: {node: '>=16'} + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -13880,6 +14443,10 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + stream-browserify@2.0.2: resolution: {integrity: sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==} @@ -13929,6 +14496,17 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + string.prototype.trim@1.2.9: resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} engines: {node: '>= 0.4'} @@ -13936,6 +14514,10 @@ packages: string.prototype.trimend@1.0.8: resolution: {integrity: sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==} + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + string.prototype.trimstart@1.0.8: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} @@ -14487,18 +15069,34 @@ packages: resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} engines: {node: '>= 0.4'} + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + typed-array-byte-length@1.0.1: resolution: {integrity: sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==} engines: {node: '>= 0.4'} + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + typed-array-byte-offset@1.0.2: resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==} engines: {node: '>= 0.4'} + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + typed-array-length@1.0.6: resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} engines: {node: '>= 0.4'} + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} @@ -14583,6 +15181,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + ua-parser-js@1.0.38: resolution: {integrity: sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==} @@ -14598,6 +15201,10 @@ packages: unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -14965,6 +15572,11 @@ packages: '@testing-library/jest-dom': optional: true + vite-prerender-plugin@0.5.12: + resolution: {integrity: sha512-EiwhbMn+flg14EysbLTmZSzq8NGTxhytgK3bf4aGRF1evWLGwZiHiUJ1KZDvbxgKbMf2pG6fJWGEa3UZXOnR1g==} + peerDependencies: + vite: 5.x || 6.x || 7.x + vite-tsconfig-paths@5.1.4: resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} peerDependencies: @@ -15093,6 +15705,46 @@ packages: yaml: optional: true + vite@7.2.6: + resolution: {integrity: sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitefu@1.0.6: resolution: {integrity: sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==} peerDependencies: @@ -15444,6 +16096,18 @@ packages: which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + which-pm-runs@1.1.0: resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} engines: {node: '>=4'} @@ -15452,6 +16116,10 @@ packages: resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} engines: {node: '>= 0.4'} + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -16145,6 +16813,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/eslint-parser@7.28.5(@babel/core@7.27.1)(eslint@9.36.0(jiti@2.5.1))': + dependencies: + '@babel/core': 7.27.1 + '@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1 + eslint: 9.36.0(jiti@2.5.1) + eslint-visitor-keys: 2.1.0 + semver: 6.3.1 + '@babel/generator@7.27.1': dependencies: '@babel/parser': 7.27.1 @@ -18724,6 +19400,8 @@ snapshots: - encoding - supports-color + '@mdn/browser-compat-data@5.7.6': {} + '@microsoft/api-extractor-model@7.29.6(@types/node@22.15.3)': dependencies: '@microsoft/tsdoc': 0.15.1 @@ -18989,6 +19667,10 @@ snapshots: '@next/swc-win32-x64-msvc@16.0.2': optional: true + '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': + dependencies: + eslint-scope: 5.1.1 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -19271,6 +19953,42 @@ snapshots: '@poppinss/exception@1.2.1': {} + '@preact/preset-vite@2.10.2(@babel/core@7.27.1)(preact@10.28.0)(vite@7.2.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1))': + dependencies: + '@babel/core': 7.27.1 + '@babel/plugin-transform-react-jsx': 7.25.9(@babel/core@7.27.1) + '@babel/plugin-transform-react-jsx-development': 7.25.9(@babel/core@7.27.1) + '@prefresh/vite': 2.4.11(preact@10.28.0)(vite@7.2.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1)) + '@rollup/pluginutils': 4.2.1 + babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.27.1) + debug: 4.4.1 + picocolors: 1.1.1 + vite: 7.2.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1) + vite-prerender-plugin: 0.5.12(vite@7.2.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1)) + transitivePeerDependencies: + - preact + - supports-color + + '@prefresh/babel-plugin@0.5.2': {} + + '@prefresh/core@1.5.9(preact@10.28.0)': + dependencies: + preact: 10.28.0 + + '@prefresh/utils@1.2.1': {} + + '@prefresh/vite@2.4.11(preact@10.28.0)(vite@7.2.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1))': + dependencies: + '@babel/core': 7.27.1 + '@prefresh/babel-plugin': 0.5.2 + '@prefresh/core': 1.5.9(preact@10.28.0) + '@prefresh/utils': 1.2.1 + '@rollup/pluginutils': 4.2.1 + preact: 10.28.0 + vite: 7.2.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1) + transitivePeerDependencies: + - supports-color + '@publint/pack@0.1.2': {} '@react-native-community/cli-debugger-ui@13.6.9': @@ -19562,6 +20280,11 @@ snapshots: optionalDependencies: rollup: 4.40.2 + '@rollup/pluginutils@4.2.1': + dependencies: + estree-walker: 2.0.2 + picomatch: 2.3.1 + '@rollup/pluginutils@5.1.4(rollup@4.40.2)': dependencies: '@types/estree': 1.0.7 @@ -19573,63 +20296,129 @@ snapshots: '@rollup/rollup-android-arm-eabi@4.40.2': optional: true + '@rollup/rollup-android-arm-eabi@4.53.3': + optional: true + '@rollup/rollup-android-arm64@4.40.2': optional: true + '@rollup/rollup-android-arm64@4.53.3': + optional: true + '@rollup/rollup-darwin-arm64@4.40.2': optional: true + '@rollup/rollup-darwin-arm64@4.53.3': + optional: true + '@rollup/rollup-darwin-x64@4.40.2': optional: true + '@rollup/rollup-darwin-x64@4.53.3': + optional: true + '@rollup/rollup-freebsd-arm64@4.40.2': optional: true + '@rollup/rollup-freebsd-arm64@4.53.3': + optional: true + '@rollup/rollup-freebsd-x64@4.40.2': optional: true + '@rollup/rollup-freebsd-x64@4.53.3': + optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.40.2': optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + optional: true + '@rollup/rollup-linux-arm-musleabihf@4.40.2': optional: true + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + optional: true + '@rollup/rollup-linux-arm64-gnu@4.40.2': optional: true + '@rollup/rollup-linux-arm64-gnu@4.53.3': + optional: true + '@rollup/rollup-linux-arm64-musl@4.40.2': optional: true + '@rollup/rollup-linux-arm64-musl@4.53.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.53.3': + optional: true + '@rollup/rollup-linux-loongarch64-gnu@4.40.2': optional: true '@rollup/rollup-linux-powerpc64le-gnu@4.40.2': optional: true + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + optional: true + '@rollup/rollup-linux-riscv64-gnu@4.40.2': optional: true + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + optional: true + '@rollup/rollup-linux-riscv64-musl@4.40.2': optional: true + '@rollup/rollup-linux-riscv64-musl@4.53.3': + optional: true + '@rollup/rollup-linux-s390x-gnu@4.40.2': optional: true + '@rollup/rollup-linux-s390x-gnu@4.53.3': + optional: true + '@rollup/rollup-linux-x64-gnu@4.40.2': optional: true + '@rollup/rollup-linux-x64-gnu@4.53.3': + optional: true + '@rollup/rollup-linux-x64-musl@4.40.2': optional: true + '@rollup/rollup-linux-x64-musl@4.53.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.53.3': + optional: true + '@rollup/rollup-win32-arm64-msvc@4.40.2': optional: true + '@rollup/rollup-win32-arm64-msvc@4.53.3': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.40.2': optional: true + '@rollup/rollup-win32-ia32-msvc@4.53.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.53.3': + optional: true + '@rollup/rollup-win32-x64-msvc@4.40.2': optional: true + '@rollup/rollup-win32-x64-msvc@4.53.3': + optional: true + '@rushstack/node-core-library@5.10.1(@types/node@22.15.3)': dependencies: ajv: 8.13.0 @@ -20382,6 +21171,8 @@ snapshots: '@types/estree@1.0.7': {} + '@types/estree@1.0.8': {} + '@types/graceful-fs@4.1.9': dependencies: '@types/node': 22.15.3 @@ -21444,6 +22235,22 @@ snapshots: call-bind: 1.0.7 is-array-buffer: 3.0.4 + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + array-iterate@2.0.1: {} array-timsort@1.0.3: {} @@ -21452,6 +22259,29 @@ snapshots: array-unique@0.3.2: {} + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + array.prototype.reduce@1.0.7: dependencies: call-bind: 1.0.7 @@ -21462,6 +22292,14 @@ snapshots: es-object-atoms: 1.0.0 is-string: 1.0.7 + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + arraybuffer.prototype.slice@1.0.3: dependencies: array-buffer-byte-length: 1.0.1 @@ -21473,6 +22311,16 @@ snapshots: is-array-buffer: 3.0.4 is-shared-array-buffer: 1.0.3 + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + arrify@3.0.0: {} asap@2.0.6: {} @@ -21492,6 +22340,10 @@ snapshots: assign-symbols@1.0.0: {} + ast-metadata-inferer@0.8.1: + dependencies: + '@mdn/browser-compat-data': 5.7.6 + ast-types@0.15.2: dependencies: tslib: 2.8.1 @@ -21600,6 +22452,8 @@ snapshots: async-each@1.0.6: optional: true + async-function@1.0.0: {} + async-limiter@1.0.1: {} async-sema@3.1.1: {} @@ -21756,6 +22610,10 @@ snapshots: transitivePeerDependencies: - '@babel/core' + babel-plugin-transform-hook-names@1.0.2(@babel/core@7.27.1): + dependencies: + '@babel/core': 7.27.1 + babel-preset-current-node-syntax@1.1.0(@babel/core@7.27.1): dependencies: '@babel/core': 7.27.1 @@ -22130,6 +22988,11 @@ snapshots: union-value: 1.0.1 unset-value: 1.0.0 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + call-bind@1.0.7: dependencies: es-define-property: 1.0.0 @@ -22138,6 +23001,18 @@ snapshots: get-intrinsic: 1.2.4 set-function-length: 1.2.2 + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.0 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + caller-callsite@2.0.0: dependencies: callsites: 2.0.0 @@ -22818,18 +23693,36 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.1 + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + data-view-byte-length@1.0.1: dependencies: call-bind: 1.0.7 es-errors: 1.3.0 is-data-view: 1.0.1 + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + data-view-byte-offset@1.0.0: dependencies: call-bind: 1.0.7 es-errors: 1.3.0 is-data-view: 1.0.1 + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + dataloader@1.4.0: {} dax-sh@0.39.2: @@ -22890,7 +23783,7 @@ snapshots: dependencies: es-define-property: 1.0.0 es-errors: 1.3.0 - gopd: 1.0.1 + gopd: 1.2.0 define-lazy-prop@2.0.0: {} @@ -22984,6 +23877,10 @@ snapshots: dlv@1.1.3: {} + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -23045,6 +23942,12 @@ snapshots: dset@3.1.4: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + duplexer@0.1.2: {} duplexify@3.7.1: @@ -23214,32 +24117,131 @@ snapshots: unbox-primitive: 1.0.2 which-typed-array: 1.1.15 + es-abstract@1.24.0: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.19 + es-array-method-boxes-properly@1.0.0: {} es-define-property@1.0.0: dependencies: get-intrinsic: 1.2.4 + es-define-property@1.0.1: {} + es-errors@1.3.0: {} + es-iterator-helpers@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-set-tostringtag: 2.0.3 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + safe-array-concat: 1.1.3 + es-module-lexer@1.7.0: {} es-object-atoms@1.0.0: dependencies: es-errors: 1.3.0 + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + es-set-tostringtag@2.0.3: dependencies: - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 hasown: 2.0.2 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + es-to-primitive@1.2.1: dependencies: is-callable: 1.2.7 is-date-object: 1.0.5 is-symbol: 1.0.4 + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.0.5 + is-symbol: 1.0.4 + esbuild-plugin-file-path-extensions@2.1.4: {} esbuild-plugin-solid@0.5.0(esbuild@0.25.5)(solid-js@1.9.7): @@ -23351,6 +24353,21 @@ snapshots: eslint: 9.36.0(jiti@2.5.1) semver: 7.7.3 + eslint-config-preact@2.0.0(eslint@9.36.0(jiti@2.5.1)): + dependencies: + '@babel/core': 7.27.1 + '@babel/eslint-parser': 7.28.5(@babel/core@7.27.1)(eslint@9.36.0(jiti@2.5.1)) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.27.1) + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.1) + '@eslint/js': 9.36.0 + eslint: 9.36.0(jiti@2.5.1) + eslint-plugin-compat: 6.0.2(eslint@9.36.0(jiti@2.5.1)) + eslint-plugin-react: 7.37.5(eslint@9.36.0(jiti@2.5.1)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.36.0(jiti@2.5.1)) + globals: 16.4.0 + transitivePeerDependencies: + - supports-color + eslint-import-context@0.1.9(unrs-resolver@1.11.1): dependencies: get-tsconfig: 4.10.1 @@ -23367,6 +24384,18 @@ snapshots: - supports-color optional: true + eslint-plugin-compat@6.0.2(eslint@9.36.0(jiti@2.5.1)): + dependencies: + '@mdn/browser-compat-data': 5.7.6 + ast-metadata-inferer: 0.8.1 + browserslist: 4.24.4 + caniuse-lite: 1.0.30001707 + eslint: 9.36.0(jiti@2.5.1) + find-up: 5.0.0 + globals: 15.14.0 + lodash.memoize: 4.1.2 + semver: 7.7.3 + eslint-plugin-es-x@7.8.0(eslint@9.36.0(jiti@2.5.1)): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0(jiti@2.5.1)) @@ -23481,6 +24510,10 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-react-hooks@5.2.0(eslint@9.36.0(jiti@2.5.1)): + dependencies: + eslint: 9.36.0(jiti@2.5.1) + eslint-plugin-react-hooks@6.1.1(eslint@9.36.0(jiti@2.5.1)): dependencies: '@babel/core': 7.27.1 @@ -23550,6 +24583,28 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-react@7.37.5(eslint@9.36.0(jiti@2.5.1)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.1 + eslint: 9.36.0(jiti@2.5.1) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + eslint-plugin-svelte@3.11.0(eslint@9.36.0(jiti@2.5.1))(svelte@5.39.3)(ts-node@10.9.2(@types/node@22.15.3)(typescript@5.8.3)): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0(jiti@2.5.1)) @@ -23597,6 +24652,8 @@ snapshots: esrecurse: 4.3.0 estraverse: 5.3.0 + eslint-visitor-keys@2.1.0: {} + eslint-visitor-keys@3.4.3: {} eslint-visitor-keys@4.2.1: {} @@ -24048,6 +25105,10 @@ snapshots: dependencies: is-callable: 1.2.7 + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + for-in@1.0.2: {} foreground-child@3.2.1: @@ -24155,8 +25216,19 @@ snapshots: es-abstract: 1.23.3 functions-have-names: 1.2.3 + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + functions-have-names@1.2.3: {} + generator-function@2.0.1: {} + gensequence@7.0.0: {} gensync@1.0.0-beta.2: {} @@ -24173,12 +25245,30 @@ snapshots: has-symbols: 1.0.3 hasown: 2.0.2 + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + get-package-type@0.1.0: {} get-port-please@3.1.2: {} get-port@3.2.0: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-stream@4.1.0: dependencies: pump: 3.0.2 @@ -24193,6 +25283,12 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.2.4 + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + get-tsconfig@4.10.1: dependencies: resolve-pkg-maps: 1.0.0 @@ -24270,7 +25366,7 @@ snapshots: globalthis@1.0.4: dependencies: define-properties: 1.2.1 - gopd: 1.0.1 + gopd: 1.2.0 globby@11.1.0: dependencies: @@ -24308,6 +25404,8 @@ snapshots: dependencies: get-intrinsic: 1.2.4 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} graphemer@1.4.0: {} @@ -24362,11 +25460,17 @@ snapshots: has-proto@1.0.3: {} + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + has-symbols@1.0.3: {} + has-symbols@1.1.0: {} + has-tostringtag@1.0.2: dependencies: - has-symbols: 1.0.3 + has-symbols: 1.1.0 has-value@0.3.1: dependencies: @@ -24769,6 +25873,12 @@ snapshots: hasown: 2.0.2 side-channel: 1.0.6 + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + interpret@2.2.0: {} interpret@3.1.1: {} @@ -24811,14 +25921,32 @@ snapshots: call-bind: 1.0.7 get-intrinsic: 1.2.4 + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-arrayish@0.2.1: {} is-arrayish@0.3.2: {} + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + is-bigint@1.0.4: dependencies: has-bigints: 1.0.2 + is-bigint@1.1.0: + dependencies: + has-bigints: 1.0.2 + is-binary-path@1.0.1: dependencies: binary-extensions: 1.13.1 @@ -24833,6 +25961,11 @@ snapshots: call-bind: 1.0.7 has-tostringtag: 1.0.2 + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-buffer@1.1.6: {} is-callable@1.2.7: {} @@ -24849,10 +25982,21 @@ snapshots: dependencies: is-typed-array: 1.1.13 + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + is-date-object@1.0.5: dependencies: has-tostringtag: 1.0.2 + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-descriptor@0.1.7: dependencies: is-accessor-descriptor: 1.0.1 @@ -24877,6 +26021,10 @@ snapshots: is-extglob@2.1.1: {} + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@4.0.0: {} @@ -24885,6 +26033,14 @@ snapshots: dependencies: get-east-asian-width: 1.3.0 + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + is-glob@3.1.0: dependencies: is-extglob: 2.1.1 @@ -24914,6 +26070,8 @@ snapshots: is-lambda@1.0.1: {} + is-map@2.0.3: {} + is-module@1.0.0: {} is-negative-zero@2.0.3: {} @@ -24924,6 +26082,11 @@ snapshots: dependencies: has-tostringtag: 1.0.2 + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-number@3.0.0: dependencies: kind-of: 3.2.2 @@ -24955,10 +26118,23 @@ snapshots: call-bind: 1.0.7 has-tostringtag: 1.0.2 + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + is-shared-array-buffer@1.0.3: dependencies: call-bind: 1.0.7 + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + is-stream@1.1.0: {} is-stream@2.0.1: {} @@ -24969,6 +26145,11 @@ snapshots: dependencies: has-tostringtag: 1.0.2 + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 @@ -24977,20 +26158,41 @@ snapshots: dependencies: has-symbols: 1.0.3 + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + is-typed-array@1.1.13: dependencies: which-typed-array: 1.1.15 + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.19 + is-unicode-supported@0.1.0: {} is-unicode-supported@1.3.0: {} is-unicode-supported@2.1.0: {} + is-weakmap@2.0.2: {} + is-weakref@1.0.2: dependencies: call-bind: 1.0.7 + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-what@3.14.1: optional: true @@ -25067,6 +26269,15 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.0.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -25380,6 +26591,13 @@ snapshots: jsonparse@1.3.1: {} + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.5 + object.values: 1.2.1 + junk@4.0.1: {} keyv@4.5.4: @@ -25670,6 +26888,8 @@ snapshots: lodash.isarguments@3.1.0: {} + lodash.memoize@4.1.2: {} + lodash.merge@4.6.2: {} lodash.sortby@4.7.0: {} @@ -25846,6 +27066,8 @@ snapshots: '@babel/runtime': 7.26.10 remove-accents: 0.5.0 + math-intrinsics@1.1.0: {} + md5-file@3.2.3: dependencies: buffer-alloc: 1.2.0 @@ -26949,6 +28171,11 @@ snapshots: transitivePeerDependencies: - supports-color + node-html-parser@6.1.13: + dependencies: + css-select: 5.1.0 + he: 1.2.0 + node-int64@0.4.0: {} node-libs-browser@2.2.1: @@ -27173,6 +28400,8 @@ snapshots: object-inspect@1.13.2: {} + object-inspect@1.13.4: {} + object-keys@1.1.1: {} object-path@0.6.0: {} @@ -27188,6 +28417,29 @@ snapshots: has-symbols: 1.0.3 object-keys: 1.1.1 + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + object.getownpropertydescriptors@2.1.8: dependencies: array.prototype.reduce: 1.0.7 @@ -27202,6 +28454,13 @@ snapshots: dependencies: isobject: 3.0.1 + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + oblivious-set@1.4.0: {} ofetch@1.4.1: @@ -27347,6 +28606,12 @@ snapshots: outvariant@1.4.3: {} + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + oxc-resolver@11.8.2: dependencies: napi-postinstall: 0.3.3 @@ -27776,6 +29041,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact@10.28.0: {} + prelude-ls@1.2.1: {} premove@4.0.0: {} @@ -28268,6 +29535,17 @@ snapshots: reflect-metadata@0.2.2: {} + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + regenerate-unicode-properties@10.2.0: dependencies: regenerate: 1.4.2 @@ -28304,9 +29582,18 @@ snapshots: regexp.prototype.flags@1.5.3: dependencies: - call-bind: 1.0.7 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + set-function-name: 2.0.2 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 define-properties: 1.2.1 es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 set-function-name: 2.0.2 regexpu-core@6.2.0: @@ -28468,6 +29755,12 @@ snapshots: dependencies: path-parse: 1.0.7 + resolve@2.0.0-next.5: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@2.0.0: dependencies: onetime: 2.0.1 @@ -28583,6 +29876,34 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.40.2 fsevents: 2.3.3 + rollup@4.53.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.53.3 + '@rollup/rollup-android-arm64': 4.53.3 + '@rollup/rollup-darwin-arm64': 4.53.3 + '@rollup/rollup-darwin-x64': 4.53.3 + '@rollup/rollup-freebsd-arm64': 4.53.3 + '@rollup/rollup-freebsd-x64': 4.53.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 + '@rollup/rollup-linux-arm-musleabihf': 4.53.3 + '@rollup/rollup-linux-arm64-gnu': 4.53.3 + '@rollup/rollup-linux-arm64-musl': 4.53.3 + '@rollup/rollup-linux-loong64-gnu': 4.53.3 + '@rollup/rollup-linux-ppc64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-musl': 4.53.3 + '@rollup/rollup-linux-s390x-gnu': 4.53.3 + '@rollup/rollup-linux-x64-gnu': 4.53.3 + '@rollup/rollup-linux-x64-musl': 4.53.3 + '@rollup/rollup-openharmony-arm64': 4.53.3 + '@rollup/rollup-win32-arm64-msvc': 4.53.3 + '@rollup/rollup-win32-ia32-msvc': 4.53.3 + '@rollup/rollup-win32-x64-gnu': 4.53.3 + '@rollup/rollup-win32-x64-msvc': 4.53.3 + fsevents: 2.3.3 + rooks@8.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: fast-deep-equal: 3.1.3 @@ -28619,6 +29940,14 @@ snapshots: has-symbols: 1.0.3 isarray: 2.0.5 + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -28626,12 +29955,23 @@ snapshots: safe-json-stringify@1.2.0: optional: true + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + safe-regex-test@1.0.3: dependencies: call-bind: 1.0.7 es-errors: 1.3.0 is-regex: 1.1.4 + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + safe-regex@1.1.0: dependencies: ret: 0.1.15 @@ -28801,6 +30141,12 @@ snapshots: functions-have-names: 1.2.3 has-property-descriptors: 1.0.2 + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + set-value@2.0.1: dependencies: extend-shallow: 2.0.1 @@ -28943,6 +30289,26 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + side-channel@1.0.6: dependencies: call-bind: 1.0.7 @@ -28950,6 +30316,14 @@ snapshots: get-intrinsic: 1.2.4 object-inspect: 1.13.2 + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -28967,6 +30341,10 @@ snapshots: transitivePeerDependencies: - supports-color + simple-code-frame@1.3.0: + dependencies: + kolorist: 1.8.0 + simple-plist@1.3.1: dependencies: bplist-creator: 0.1.0 @@ -29173,6 +30551,8 @@ snapshots: stable-hash-x@0.2.0: {} + stack-trace@1.0.0-pre2: {} + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -29200,6 +30580,11 @@ snapshots: stdin-discarder@0.2.2: {} + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + stream-browserify@2.0.2: dependencies: inherits: 2.0.4 @@ -29258,6 +30643,37 @@ snapshots: get-east-asian-width: 1.3.0 strip-ansi: 7.1.0 + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.3 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.23.3 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + string.prototype.trim@1.2.9: dependencies: call-bind: 1.0.7 @@ -29271,6 +30687,13 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + string.prototype.trimstart@1.0.8: dependencies: call-bind: 1.0.7 @@ -29850,6 +31273,12 @@ snapshots: es-errors: 1.3.0 is-typed-array: 1.1.13 + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + typed-array-byte-length@1.0.1: dependencies: call-bind: 1.0.7 @@ -29858,6 +31287,14 @@ snapshots: has-proto: 1.0.3 is-typed-array: 1.1.13 + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.3 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + typed-array-byte-offset@1.0.2: dependencies: available-typed-arrays: 1.0.7 @@ -29867,6 +31304,16 @@ snapshots: has-proto: 1.0.3 is-typed-array: 1.1.13 + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.3 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + typed-array-length@1.0.6: dependencies: call-bind: 1.0.7 @@ -29876,6 +31323,15 @@ snapshots: is-typed-array: 1.1.13 possible-typed-array-names: 1.0.0 + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.3 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.0.0 + reflect.getprototypeof: 1.0.10 + typedarray@0.0.6: {} typedoc-plugin-frontmatter@1.3.0(typedoc-plugin-markdown@4.9.0(typedoc@0.28.14(typescript@5.8.3))): @@ -29933,6 +31389,8 @@ snapshots: typescript@5.8.3: {} + typescript@5.9.3: {} + ua-parser-js@1.0.38: {} uc.micro@2.1.0: {} @@ -29948,6 +31406,13 @@ snapshots: has-symbols: 1.0.3 which-boxed-primitive: 1.0.2 + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.0.2 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + uncrypto@0.1.3: {} unctx@2.4.1: @@ -30434,6 +31899,16 @@ snapshots: transitivePeerDependencies: - supports-color + vite-prerender-plugin@0.5.12(vite@7.2.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1)): + dependencies: + kolorist: 1.8.0 + magic-string: 0.30.19 + node-html-parser: 6.1.13 + simple-code-frame: 1.3.0 + source-map: 0.7.4 + stack-trace: 1.0.0-pre2 + vite: 7.2.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1) + vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1)): dependencies: debug: 4.4.1 @@ -30499,6 +31974,25 @@ snapshots: tsx: 4.20.1 yaml: 2.8.1 + vite@7.2.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1): + dependencies: + esbuild: 0.25.5 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.3 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.15.3 + fsevents: 2.3.3 + jiti: 2.5.1 + less: 4.3.0 + lightningcss: 1.30.1 + sass: 1.88.0 + terser: 5.39.1 + tsx: 4.20.1 + yaml: 2.8.1 + vitefu@1.0.6(vite@6.3.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1)): optionalDependencies: vite: 6.3.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1) @@ -30911,6 +32405,37 @@ snapshots: is-string: 1.0.7 is-symbol: 1.0.4 + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + which-pm-runs@1.1.0: {} which-typed-array@1.1.15: @@ -30921,6 +32446,16 @@ snapshots: gopd: 1.0.1 has-tostringtag: 1.0.2 + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@1.3.1: dependencies: isexe: 2.0.0