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..b8fab29854 --- /dev/null +++ b/examples/preact/simple/package.json @@ -0,0 +1,23 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/preact-query": "workspace:^", + "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..899d4b5781 --- /dev/null +++ b/examples/preact/simple/src/index.tsx @@ -0,0 +1,44 @@ +import { render } from 'preact' +import { + QueryClient, + QueryClientProvider, + useQuery, +} from '@tanstack/preact-query' + +export function App() { + const queryClient = new QueryClient() + return ( + + + + ) +} + +const Example = () => { + const { isPending, error, data, isFetching } = useQuery({ + queryKey: ['repoData'], + queryFn: async () => { + const response = await fetch( + 'https://api.github.com/repos/TanStack/query', + ) + return await response.json() + }, + }) + + if (isPending) return 'Loading...' + + if (error !== null) return 'An error has occurred: ' + error.message + + return ( +
+

{data.full_name}

+

{data.description}

+ 👀 {data.subscribers_count}{' '} + ✨ {data.stargazers_count}{' '} + 🍴 {data.forks_count} +
{isFetching ? 'Updating...' : ''}
+
+ ) +} + +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..83caf4e0a9 --- /dev/null +++ b/packages/preact-query/eslint.config.js @@ -0,0 +1,38 @@ +// @ts-check + +import rootConfig from './root.eslint.config.js' +// @ts-ignore: no types for eslint-config-preact +import preact from 'eslint-config-preact' +import tseslint from 'typescript-eslint' + +export default [ + ...rootConfig, + ...preact, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + parser: tseslint.parser, + parserOptions: { + project: true, + }, + }, + plugins: { + 'typescript-eslint': tseslint.plugin, + }, + rules: { + // Disable base rule to prevent overload false positives + 'no-redeclare': 'off', + 'no-duplicate-imports': 'off', + // TS-aware version handles overloads correctly + '@typescript-eslint/no-redeclare': 'error', + '@typescript-eslint/no-duplicate-imports': '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..47b003df0c --- /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": { + "@preact/preset-vite": "^2.10.2", + "@tanstack/query-persist-client-core": "workspace:*", + "@tanstack/query-test-utils": "workspace:*", + "@testing-library/preact": "^3.2.4", + "@testing-library/react-render-stream": "^2.0.0", + "cpy-cli": "^5.0.0", + "eslint-config-preact": "^2.0.0", + "npm-run-all2": "^5.0.2", + "preact": "^10.28.0", + "preact-iso": "^2.11.1", + "preact-render-to-string": "^6.6.4", + "typescript-eslint": "^8.50.0" + }, + "peerDependencies": { + "preact": "^10.0.0" + } +} 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..7ca9d2546b --- /dev/null +++ b/packages/preact-query/src/HydrationBoundary.tsx @@ -0,0 +1,112 @@ +'use client' + +import { hydrate } from '@tanstack/query-core' +import { useQueryClient } from './QueryClientProvider' +import type { + DehydratedState, + HydrateOptions, + OmitKeyof, + QueryClient, +} from '@tanstack/query-core' +import { useEffect, useMemo, useRef } from 'preact/hooks' +import { ComponentChildren } from 'preact' + +export interface HydrationBoundaryProps { + state: DehydratedState | null | undefined + options?: OmitKeyof & { + defaultOptions?: OmitKeyof< + Exclude, + 'mutations' + > + } + children?: ComponentChildren + queryClient?: QueryClient +} + +export const HydrationBoundary = ({ + children, + options = {}, + state, + queryClient, +}: HydrationBoundaryProps) => { + const client = useQueryClient(queryClient) + + const optionsRef = useRef(options) + 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 = + 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]) + + useEffect(() => { + if (hydrationQueue) { + hydrate(client, { queries: hydrationQueue }, optionsRef.current) + } + }, [client, hydrationQueue]) + + return children as ComponentChildren +} diff --git a/packages/preact-query/src/IsRestoringProvider.ts b/packages/preact-query/src/IsRestoringProvider.ts new file mode 100644 index 0000000000..a12ec14127 --- /dev/null +++ b/packages/preact-query/src/IsRestoringProvider.ts @@ -0,0 +1,9 @@ +'use client' + +import { createContext } from 'preact' +import { useContext } from 'preact/hooks' + +const IsRestoringContext = createContext(false) + +export const useIsRestoring = () => 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..945c8ed00a --- /dev/null +++ b/packages/preact-query/src/QueryClientProvider.tsx @@ -0,0 +1,46 @@ +'use client' + +import type { QueryClient } from '@tanstack/query-core' +import { ComponentChildren, createContext, VNode } from 'preact' +import { useContext, useEffect } from 'preact/hooks' + +export const QueryClientContext = createContext( + undefined, +) + +export const useQueryClient = (queryClient?: QueryClient) => { + const client = 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?: ComponentChildren +} + +export const QueryClientProvider = ({ + client, + children, +}: QueryClientProviderProps): VNode => { + 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..c170634efc --- /dev/null +++ b/packages/preact-query/src/QueryErrorResetBoundary.tsx @@ -0,0 +1,56 @@ +import { ComponentChildren, createContext } from 'preact' +import { useContext, useState } from 'preact/hooks' + +// 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 = createContext(createValue()) + +// HOOK + +export const useQueryErrorResetBoundary = () => + useContext(QueryErrorResetBoundaryContext) + +// COMPONENT + +export type QueryErrorResetBoundaryFunction = ( + value: QueryErrorResetBoundaryValue, +) => ComponentChildren + +export interface QueryErrorResetBoundaryProps { + children: QueryErrorResetBoundaryFunction | ComponentChildren +} + +export const QueryErrorResetBoundary = ({ + children, +}: QueryErrorResetBoundaryProps) => { + const [value] = useState(() => createValue()) + return ( + + {typeof children === 'function' ? children(value) : children} + + ) +} diff --git a/packages/preact-query/src/__tests__/ErrorBoundary/ErrorBoundary.ts b/packages/preact-query/src/__tests__/ErrorBoundary/ErrorBoundary.ts new file mode 100644 index 0000000000..075c5aa353 --- /dev/null +++ b/packages/preact-query/src/__tests__/ErrorBoundary/ErrorBoundary.ts @@ -0,0 +1,130 @@ +import { Component, createElement, ErrorInfo } from 'preact' +import { ErrorBoundaryContext } from './ErrorBoundaryContext' +import { ErrorBoundaryProps, FallbackProps } from './types' + +type ErrorBoundaryState = + | { + didCatch: true + error: any + } + | { + didCatch: false + error: null + } + +const initialState: ErrorBoundaryState = { + didCatch: false, + error: null, +} + +export class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps) { + super(props) + + this.resetErrorBoundary = this.resetErrorBoundary.bind(this) + this.state = initialState + } + + static getDerivedStateFromError(error: Error) { + return { didCatch: true, error } + } + + resetErrorBoundary(...args: any[]) { + const { error } = this.state + + if (error !== null) { + this.props.onReset?.({ + args, + reason: 'imperative-api', + }) + + this.setState(initialState) + } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + /** + * To emulate the react behaviour of console.error + * we add one here to show that the errors bubble up + * to the system and can be seen in the console + */ + console.error('%o\n\n%s', error, info) + this.props.onError?.(error, info) + } + + componentDidUpdate( + prevProps: ErrorBoundaryProps, + prevState: ErrorBoundaryState, + ) { + const { didCatch } = this.state + const { resetKeys } = this.props + + // There's an edge case where if the thing that triggered the error happens to *also* be in the resetKeys array, + // we'd end up resetting the error boundary immediately. + // This would likely trigger a second error to be thrown. + // So we make sure that we don't check the resetKeys on the first call of cDU after the error is set. + + if ( + didCatch && + prevState.error !== null && + hasArrayChanged(prevProps.resetKeys, resetKeys) + ) { + this.props.onReset?.({ + next: resetKeys, + prev: prevProps.resetKeys, + reason: 'keys', + }) + + this.setState(initialState) + } + } + + render() { + const { children, fallbackRender, FallbackComponent, fallback } = this.props + const { didCatch, error } = this.state + + let childToRender = children + + if (didCatch) { + const props: FallbackProps = { + error, + resetErrorBoundary: this.resetErrorBoundary, + } + + if (typeof fallbackRender === 'function') { + childToRender = fallbackRender(props) + } else if (FallbackComponent) { + childToRender = createElement(FallbackComponent, props) + } else if (fallback !== undefined) { + childToRender = fallback + } else { + console.error( + 'preact-error-boundary requires either a fallback, fallbackRender, or FallbackComponent prop', + ) + + throw error + } + } + + return createElement( + ErrorBoundaryContext.Provider, + { + value: { + didCatch, + error, + resetErrorBoundary: this.resetErrorBoundary, + }, + }, + childToRender, + ) + } +} + +function hasArrayChanged(a: any[] = [], b: any[] = []) { + return ( + a.length !== b.length || a.some((item, index) => !Object.is(item, b[index])) + ) +} diff --git a/packages/preact-query/src/__tests__/ErrorBoundary/ErrorBoundaryContext.ts b/packages/preact-query/src/__tests__/ErrorBoundary/ErrorBoundaryContext.ts new file mode 100644 index 0000000000..df79790257 --- /dev/null +++ b/packages/preact-query/src/__tests__/ErrorBoundary/ErrorBoundaryContext.ts @@ -0,0 +1,10 @@ +import { createContext } from 'preact' + +export type ErrorBoundaryContextType = { + didCatch: boolean + error: any + resetErrorBoundary: (...args: any[]) => void +} + +export const ErrorBoundaryContext = + createContext(null) diff --git a/packages/preact-query/src/__tests__/ErrorBoundary/index.ts b/packages/preact-query/src/__tests__/ErrorBoundary/index.ts new file mode 100644 index 0000000000..51ee3ea0df --- /dev/null +++ b/packages/preact-query/src/__tests__/ErrorBoundary/index.ts @@ -0,0 +1,9 @@ +/** + * Custom Error Boundary port from 'react-error-boundary' + * Taken directly from https://github.com/bvaughn/react-error-boundary/ + * and modified to server a preact use case + */ + +export * from './ErrorBoundary' +export * from './ErrorBoundaryContext' +export * from './types' diff --git a/packages/preact-query/src/__tests__/ErrorBoundary/types.ts b/packages/preact-query/src/__tests__/ErrorBoundary/types.ts new file mode 100644 index 0000000000..31e4123c6e --- /dev/null +++ b/packages/preact-query/src/__tests__/ErrorBoundary/types.ts @@ -0,0 +1,48 @@ +import { + ComponentType, + ErrorInfo, + ComponentChildren, + ComponentChild, +} from 'preact' + +export type FallbackProps = { + error: any + resetErrorBoundary: (...args: any[]) => void +} + +export type PropsWithChildren

= P & { + children?: ComponentChildren +} + +type ErrorBoundarySharedProps = PropsWithChildren<{ + onError?: (error: Error, info: ErrorInfo) => void + onReset?: ( + details: + | { reason: 'imperative-api'; args: any[] } + | { reason: 'keys'; prev: any[] | undefined; next: any[] | undefined }, + ) => void + resetKeys?: any[] +}> + +export type ErrorBoundaryPropsWithComponent = ErrorBoundarySharedProps & { + fallback?: never + FallbackComponent: ComponentType + fallbackRender?: never +} + +export type ErrorBoundaryPropsWithRender = ErrorBoundarySharedProps & { + fallback?: never + FallbackComponent?: never + fallbackRender: (props: FallbackProps) => ComponentChild +} + +export type ErrorBoundaryPropsWithFallback = ErrorBoundarySharedProps & { + fallback: ComponentChild + FallbackComponent?: never + fallbackRender?: never +} + +export type ErrorBoundaryProps = + | ErrorBoundaryPropsWithFallback + | ErrorBoundaryPropsWithComponent + | ErrorBoundaryPropsWithRender 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..b9c90ffba5 --- /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 { render } from '@testing-library/preact' +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' +import { startTransition, Suspense } from 'preact/compat' + +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 + }) + } + + startTransition(() => { + rendered.rerender( + + + + + + + + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + }) + + 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..bd5f584bbc --- /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/preact' +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..043fb3ef25 --- /dev/null +++ b/packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx @@ -0,0 +1,867 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent } from '@testing-library/preact' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + QueryErrorResetBoundary, + useQueries, + useQuery, + useSuspenseQueries, + useSuspenseQuery, +} from '..' +import { renderWithClient } from './utils' +import { useEffect, useState } from 'preact/hooks' +import { Suspense } from 'preact/compat' +import { ErrorBoundary } from './ErrorBoundary' + +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] = useState(false) + const { data } = useQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Error') + return 'data' + }), + retry: false, + enabled, + throwOnError: true, + }) + + 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 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 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 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 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 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..ae9bc5d63b --- /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 { + 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' +import { useState } from 'preact/hooks' + +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] = 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] = 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] = 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..ac08a3b553 --- /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/preact' +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..dfc75367d5 --- /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 { renderToString } from 'preact-render-to-string' +import { hydrate as preactHydrate, VNode } from 'preact' +import { + QueryCache, + QueryClient, + QueryClientProvider, + dehydrate, + hydrate, + useQuery, +} from '..' +import { setIsServer } from './utils' +import { act } from '@testing-library/preact' + +const ReactHydrate = (element: VNode, container: Element) => { + let root: any + act(() => { + root = preactHydrate(element, container) + }) + 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 = 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 = 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 = 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..56769d3afb --- /dev/null +++ b/packages/preact-query/src/__tests__/ssr.test.tsx @@ -0,0 +1,176 @@ +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' +import { renderToString } from 'preact-render-to-string' +import { useState } from 'preact/hooks' + +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] = 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..7c6bc5ca9b --- /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 { render } from '@testing-library/preact' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { QueryClient, QueryClientProvider, useSuspenseQuery } from '..' +import type { QueryKey } from '..' +import { Suspense } from 'preact/compat' + +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 vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: data')).toBeInTheDocument() + + rendered.rerender( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(10) + + 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 vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: data')).toBeInTheDocument() + + rendered.rerender( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(10) + + 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 vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: data')).toBeInTheDocument() + + rendered.rerender( + + + + + , + ) + + await 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 vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: data')).toBeInTheDocument() + + rendered.rerender( + + + + + , + ) + + await 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 vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: data')).toBeInTheDocument() + + rendered.rerender( + + + + + , + ) + + await 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..27e070f898 --- /dev/null +++ b/packages/preact-query/src/__tests__/useInfiniteQuery.test.tsx @@ -0,0 +1,1860 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render } from '@testing-library/preact' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { useCallback, useEffect, useRef, useState } from 'preact/hooks' +import { + QueryCache, + QueryClient, + QueryClientProvider, + keepPreviousData, + useInfiniteQuery, +} from '..' +import { renderWithClient, setActTimeout } from './utils' +import type { + InfiniteData, + QueryFunctionContext, + UseInfiniteQueryResult, +} from '..' +import type { Mock } from 'vitest' +import { Suspense } from 'preact/compat' + +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 + + 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] = 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: 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, + }) + + 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, + }) + + 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 + + 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] = useState(true) + + 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] = 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 = 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 = useRef(0) + const [isRemovedLastPage, setIsRemovedLastPage] = 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] = 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 use()', async () => { + // vi.useRealTimers() + + // const key = queryKey() + + // const renderStream = createRenderStream({ snapshotDOM: true }) + + // function Loading() { + // useTrackRenders() + // return <>loading... + // } + + // function MyComponent() { + // useTrackRenders() + // const fetchCountRef = useRef(0) + // const query = useInfiniteQuery({ + // queryFn: ({ pageParam }) => + // fetchItems(pageParam, fetchCountRef.current++), + // getNextPageParam: (lastPage) => lastPage.nextId, + // initialPageParam: 0, + // queryKey: key, + // }) + // const data = 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..614b70d962 --- /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/preact' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { QueryCache, QueryClient, useIsFetching, useQuery } from '..' +import { renderWithClient, setActTimeout } from './utils' +import { useEffect, useState } from 'preact/hooks' + +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] = 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] = useState(false) + + 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] = 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..26f4344f8c --- /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/preact' +import { ErrorBoundary } from './ErrorBoundary' +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' +import { useEffect, useState } from 'preact/hooks' + +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() + }, + }) + + 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() + }, + }) + + 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 + + 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, + }) + + 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] = 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] = 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..8a00db36fd --- /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/preact' +import { sleep } from '@tanstack/query-test-utils' +import { QueryClient, useIsMutating, useMutation, useMutationState } from '..' +import { renderWithClient } from './utils' +import { useEffect } from 'preact/hooks' + +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'), + }) + + 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'), + }) + + 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, + ) + + 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..5a0b8fb0e7 --- /dev/null +++ b/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent } from '@testing-library/preact' +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' +import { Suspense } from 'preact/compat' +import { VNode } from 'preact' + +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) => VNode + }) { + 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 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: VNode }) { + usePrefetchInfiniteQuery(queryOpts) + return <>{children} + } + + function App() { + return ( + }> + +
data: {page.data}
} + /> +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await 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..5804893bee --- /dev/null +++ b/packages/preact-query/src/__tests__/usePrefetchQuery.test.tsx @@ -0,0 +1,291 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent } from '@testing-library/preact' +import { ErrorBoundary } from './ErrorBoundary' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + usePrefetchQuery, + useQueryErrorResetBoundary, + useSuspenseQuery, +} from '..' +import { renderWithClient } from './utils' + +import type { UseSuspenseQueryOptions } from '..' +import { Suspense } from 'preact/compat' +import { VNode } from 'preact' + +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?: VNode + }) { + 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 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: VNode }) { + usePrefetchQuery(queryOpts) + return <>{children} + } + + function App() { + return ( + }> + + + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await 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 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(async () => { + await 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..0b83765cf8 --- /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/preact' +import { ErrorBoundary } from './ErrorBoundary' +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' +import { useCallback, useEffect, useState } from 'preact/hooks' + +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] = 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' }), + }) + + 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: [], + }) + + 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] = 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] = 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: 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] = 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: 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] = 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: 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] = 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..074685ccd4 --- /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 { ErrorBoundary } from './ErrorBoundary' +import { + createRenderStream, + useTrackRenders, +} from '@testing-library/react-render-stream' +import { queryKey } from '@tanstack/query-test-utils' +import { waitFor } from '@testing-library/preact' +import { + QueryClient, + QueryClientProvider, + QueryErrorResetBoundary, + keepPreviousData, + useInfiniteQuery, + useQuery, +} from '..' +import { QueryCache } from '../index' +import { Suspense } from 'preact/compat' + +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 = 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 = 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 = 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 = 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 = 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 = 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 = use(props.promise) + + return <>{data} + } + function Loading() { + useTrackRenders() + + return <>loading.. + } + function Page() { + useTrackRenders() + const [count, setCount] = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = use(props.promise) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const [count, setCount] = 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 = 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 = use(props.promise) + + return <>{data} + } + + function Loading() { + useTrackRenders() + return <>loading.. + } + function Page() { + useTrackRenders() + const [count, setCount] = 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 = use(props.promise) + + renderStream.replaceSnapshot({ data }) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const [count, setCount] = 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 = use(props.promise) + + renderStream.replaceSnapshot({ data }) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const [count, setCount] = 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 = 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] = 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, + }) + + 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..906a571570 --- /dev/null +++ b/packages/preact-query/src/__tests__/useQuery.test.tsx @@ -0,0 +1,6785 @@ +import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest' +import { act, fireEvent, render } from '@testing-library/preact' +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' +import { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'preact/hooks' +import { ErrorBoundary } from './ErrorBoundary' + +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', + }) + + 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', + }) + + 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, + }) + + 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] = 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] = 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] = useReducer(() => ({}), {}) + const state = useQuery({ + queryKey: key, + queryFn: () => (runs === 0 ? 'test' : 'test2'), + + select: 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) + + 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] = 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) + + 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) + + 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] = 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] = 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] = 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] = 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] = 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] = 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] = 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] = 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 + + 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] = 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 = useRef(0) + const trackChangesRef = useRef(true) + + const notifyOnChangeProps = 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] = useState('state') + const state = useQuery({ queryKey: key, queryFn: () => 'data' }) + 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, + }) + + 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] = 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] = 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] = useState(0) + const state = useQuery({ + queryKey: [key, count], + queryFn: () => ({ count: 10 }), + staleTime: Infinity, + initialData: () => ({ count }), + }) + states.push(state) + + 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] = useState(false) + const [isPrefetched, setPrefetched] = useState(false) + + const query = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return count + }, + + enabled, + }) + + 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] = 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] = 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] = 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, + }, + } + ) + }, + }) + + 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] = useState(200) + const { data } = useQuery({ + queryKey: key, + queryFn: () => count++, + refetchInterval: int, + }) + + 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] = 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] = useState(0) + + const state = useQuery({ + queryKey: key1, + queryFn: () => 'data', + placeholderData: 'placeholder', + enabled: count === 0, + }) + + states.push({ state, count }) + + 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] = useReducer((prev) => prev + 1, 2) + + const state = useQuery({ + queryKey: key1, + queryFn: async () => { + await sleep(10) + return 0 + }, + select: 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] = useReducer((prev) => prev + 1, 2) + const [forceValue, forceUpdate] = useReducer((prev) => prev + 1, 1) + + const state = useQuery({ + queryKey: key1, + queryFn: async () => { + await sleep(10) + return 0 + }, + + select: 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] = 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), + }) + + 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] = useState(1) + const [hasChanged, setHasChanged] = useState(false) + + const state = useQuery({ queryKey: [key, id], queryFn }) + + states.push(state) + + 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() { + 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] = 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] = 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] = 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' + }, + }) + + 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] = 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] = 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] = 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 = 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] = useState(1) + + const { data } = useQuery({ + select: selector, + queryKey: [key, 'user', id], + queryFn: () => fetchNumber(id), + }) + + 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] = useState(1) + + const { data } = useQuery({ + select: selector, + queryKey: [key, 'user', id], + queryFn: () => fetchNumber(id), + placeholderData: { numbers: { current: { id: 99 } } }, + }) + + 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] = 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] = 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] = 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] = 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..033089ed18 --- /dev/null +++ b/packages/preact-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' +import { queryKey } from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + skipToken, + useSuspenseInfiniteQuery, +} from '..' +import { renderWithClient } from './utils' +import { Suspense } from 'preact/compat' + +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..6239f0a9e7 --- /dev/null +++ b/packages/preact-query/src/__tests__/useSuspenseQueries.test.tsx @@ -0,0 +1,843 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest' +import { act, fireEvent, render } from '@testing-library/preact' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { + QueryClient, + skipToken, + useSuspenseQueries, + useSuspenseQuery, +} from '..' +import { renderWithClient } from './utils' +import type { UseSuspenseQueryOptions } from '..' +import { startTransition, Suspense, useTransition } from 'preact/compat' +import { useEffect, useRef, useState } from 'preact/hooks' +import { FunctionalComponent } from 'preact' +import { ErrorBoundary } from './ErrorBoundary' + +type NumberQueryOptions = UseSuspenseQueryOptions + +const QUERY_DURATION = 1000 + +const createQuery: (id: number) => NumberQueryOptions = (id) => ({ + queryKey: [id], + queryFn: () => sleep(QUERY_DURATION).then(() => id), +}) +const resolveQueries = async () => { + await 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() { + useEffect(() => { + onSuspend() + }, []) + + return
loading
+ } + + const withSuspenseWrapper = ( + Component: FunctionalComponent, + ) => { + function SuspendedComponent(props: T) { + return ( + }> + + + ) + } + + return SuspendedComponent + } + + function QueriesContainer({ + queries, + }: { + queries: Array + }) { + const queriesResults = useSuspenseQueries( + { queries, combine: (results) => results.map((r) => r.data) }, + queryClient, + ) + + 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).toHaveBeenCalled() + 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 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()], + }) + + useEffect(() => { + onQueriesResolution({ data, data2 }) + }, [data, data2]) + + return ( +
+

Data

+ {JSON.stringify({ data }, null, 2)} + {JSON.stringify({ data2 }, null, 2)} +
+ ) + } + + renderWithClient( + queryClient, + }> + + , + ) + + await vi.advanceTimersByTimeAsync(localDuration) + + expect(onSuspend).toHaveBeenCalledTimes(1) + expect(onQueriesResolution).toHaveBeenCalledTimes(1) + + await 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 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 = 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 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({ id }: { id: number }) { + 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}
+ } + + function TestApp() { + const [id, setId] = useState(0) + + return ( + <> + + loading}> + + + + ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('Data 0')).toBeInTheDocument() + + // go offline + document.dispatchEvent(new CustomEvent('offline')) + + fireEvent.click(rendered.getByText('fetch')) + // Because of state loss during the unmount, Data: 0 is swapped + // out for `loading` (we might need to look into this more) + expect(rendered.getByText('Data 0')).not.toBeInTheDocument() + + // go back online + document.dispatchEvent(new CustomEvent('online')) + + fireEvent.click(rendered.getByText('fetch')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await 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] = 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 vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('rendered: data')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('trigger fail')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await 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] = useState(0) + const [isPending, startTransition] = 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 vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data0')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('inc')) + expect(rendered.getByText('pending')).toBeInTheDocument() + await 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] = 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 vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data0')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('inc')) + await 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] = useState(0) + const [isPending, startTransition] = 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 vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data0')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('inc')) + expect(rendered.getByText('pending')).toBeInTheDocument() + await 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 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] = 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 vi.advanceTimersByTimeAsync(3000) + expect(queryClient.getQueryData(key)).toBe('data') + // wait for gc + await 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..e92827f7a0 --- /dev/null +++ b/packages/preact-query/src/__tests__/useSuspenseQuery.test.tsx @@ -0,0 +1,1044 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent } from '@testing-library/preact' +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 '..' +import { useReducer, useState } from 'preact/hooks' +import { Suspense } from 'preact/compat' +import { ErrorBoundary } from './ErrorBoundary' + +describe('useSuspenseQuery', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + + /** + * Preact Suspense handles the rerenders differently than React. + * This test only checks for 4 renders (vs. React -> 6) + * so, instead of state change reacting and updating (and also losing the state) + * we abstract out the suspense + */ + it('should render correctly when state is lifted above Suspense', async () => { + const states: Array> = [] + + let count = 0 + let renders = 0 + + function TestApp() { + // State lives here, ABOVE the Suspense boundary, so it does not get reset + const [stateKey, setStateKey] = useState(queryKey()) + + return ( + <> + + + + + + ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await 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 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 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] = useState(false) + + return ( + <> + {show && } + + + )} + > + + + + + )} + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await 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 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 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 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 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(async () => { + await 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] = useState(key1) + return ( +
+ + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText(`data: ${key1}`)).toBeInTheDocument() + + fireEvent.click(rendered.getByText('switch')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await 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 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 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 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 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 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() + + function Page({ succeed }: { succeed: boolean }) { + const [nonce] = 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() + const [succeed, setSucceed] = useState(true) + + return ( +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + // render suspense fallback (loading) + expect(rendered.getByText('loading')).toBeInTheDocument() + // resolve promise -> render Page (rendered) + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('rendered')).toBeInTheDocument() + + // change query result to error by updating state above Suspense + fireEvent.click(rendered.getByLabelText('set-fail')) + + // reset query -> and throw error + fireEvent.click(rendered.getByLabelText('fail')) + // render error boundary fallback (error boundary) + await 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 Child({ keyVal }: { keyVal: number }) { + const queryKeys = [keyVal, 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 Page() { + const [key, rerender] = useReducer((x) => x + 1, 0) + + return ( +
+ + + + +
+ ) + } + + 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 vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('rendered')).toBeInTheDocument() + + // change promise result to error + succeed = false + + // change query key (state is above Suspense) + fireEvent.click(rendered.getByLabelText('fail')) + expect(rendered.getByText('loading')).toBeInTheDocument() + // render error boundary fallback (error boundary) + await 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 vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('rendered')).toBeInTheDocument() + + expect(state).toMatchObject({ + data: 1, + status: 'success', + }) + // reducing 1 for strict mode render + expect(renders).toBe(2) + }) + + 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 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 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 TestApp() { + // State lives here, ABOVE the Suspense boundary, so it does not get reset + const [stateKey, setStateKey] = useState(key) + + return ( + <> +