diff --git a/.gitignore b/.gitignore index b743dc3275..6caec63969 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ yarn-debug.log* yarn-error.log* .history size-plugin.json +stats-hydration.json +stats-react.json stats.html .vscode/settings.json diff --git a/docs/src/pages/docs/api.md b/docs/src/pages/docs/api.md index d673e48ef3..8e6353187a 100644 --- a/docs/src/pages/docs/api.md +++ b/docs/src/pages/docs/api.md @@ -324,17 +324,32 @@ const promise = mutate(variables, { The `queryCache` instance is the backbone of React Query that manages all of the state, caching, lifecycle and magic of every query. It supports relatively unrestricted, but safe, access to manipulate query's as you need. Its available properties and methods are: -- [`prefetchQuery`](#querycacheprefetchquery) -- [`getQueryData`](#querycachegetquerydata) -- [`setQueryData`](#querycachesetquerydata) -- [`invalidateQueries`](#querycacheinvalidatequeries) -- [`cancelQueries`](#querycachecancelqueries) -- [`removeQueries`](#querycacheremovequeries) -- [`getQueries`](#querycachegetqueries) -- [`getQuery`](#querycachegetquery) -- [`subscribe`](#querycachesubscribe) -- [`isFetching`](#querycacheisfetching) -- [`clear`](#querycacheclear) +- [`useQuery`](#usequery) +- [`usePaginatedQuery`](#usepaginatedquery) +- [`useInfiniteQuery`](#useinfinitequery) +- [`useMutation`](#usemutation) +- [`queryCache`](#querycache) +- [`queryCache.prefetchQuery`](#querycacheprefetchquery) +- [`queryCache.getQueryData`](#querycachegetquerydata) +- [`queryCache.setQueryData`](#querycachesetquerydata) +- [`queryCache.invalidateQueries`](#querycacheinvalidatequeries) +- [`queryCache.cancelQueries`](#querycachecancelqueries) +- [`queryCache.removeQueries`](#querycacheremovequeries) +- [`queryCache.getQuery`](#querycachegetquery) +- [`queryCache.getQueries`](#querycachegetqueries) +- [`queryCache.isFetching`](#querycacheisfetching) +- [`queryCache.subscribe`](#querycachesubscribe) +- [`queryCache.clear`](#querycacheclear) +- [`makeQueryCache`](#makequerycache) +- [`useQueryCache`](#usequerycache) +- [`useIsFetching`](#useisfetching) +- [`ReactQueryConfigProvider`](#reactqueryconfigprovider) +- [`ReactQueryCacheProvider`](#reactquerycacheprovider) +- [`setConsole`](#setconsole) +- [`hydration/dehydrate`](#hydrationdehydrate) +- [`hydration/hydrate`](#hydrationhydrate) +- [`hydration/useHydrate`](#hydrationusehydrate) +- [`hydration/ReactQueryCacheProvider`](#hydrationreactquerycacheprovider) ## `queryCache.prefetchQuery` @@ -631,6 +646,23 @@ queryCache.clear() - `queries: Array` - This will be an array containing the queries that were found. +## `makeQueryCache` + +`makeQueryCache` creates an empty `queryCache` manually. This is useful together with `ReactQueryCacheProvider` to have multiple caches in your application. + +As opposed to the global cache, caches created by `makeQueryCache` caches data even on the server. + +```js +import { makeQueryCache } from 'react-query' + +const queryCache = makeQueryCache() +``` + +**Returns** + +- `queryCache: QueryCache` + - An empty `queryCache` + ## `useQueryCache` The `useQueryCache` hook returns the current queryCache instance. @@ -734,7 +766,7 @@ function App() { **Options** -- `queryCache: Object` +- `queryCache: QueryCache` - In instance of queryCache, you can use the `makeQueryCache` factory to create this. - If not provided, a new cache will be generated. @@ -757,3 +789,96 @@ setConsole({ - `console: Object` - Must implement the `log`, `warn`, and `error` methods. + +## `hydration/dehydrate` + +`dehydrate` creates a frozen representation of a `queryCache` that can later be hydrated with `useHydrate`, `hydrate` or by passing it into `hydration/ReactQueryCacheProvider`. This is useful for passing prefetched queries from server to client or persisting queries to localstorage. It only includes currently successful queries by default. + +```js +import { dehydrate } from 'react-query/hydration' + +const dehydratedState = dehydrate(queryCache, { + shouldDehydrate +}) +``` + +**Options** + +- `queryCache: QueryCache` + - **Required** + - The `queryCache` that should be dehydrated +- `shouldDehydrate: Function(query: Query) => Boolean` + - This function is called for each query in the cache + - Return `true` to include this query in dehydration, or `false` otherwise + - Default version only includes successful queries, do `shouldDehydrate: () => true` to include all queries + +**Returns** + +- `dehydratedState: DehydratedState` + - This includes everything that is needed to hydrate the `queryCache` at a later point + - You **should not** rely on the exact format of this response, it is not part of the public API and can change at any time + - This result is not in serialized form, you need to do that yourself if desired + +## `hydration/hydrate` + +`hydrate` adds a previously dehydrated state into a `queryCache`. If the queries included in dehydration already exist in the cache, `hydrate` does not overwrite them. + +```js +import {Β hydrate } from 'react-query/hydration' + +hydrate(queryCache, dehydratedState) +``` + +**Options** + +- `queryCache: QueryCache` + - **Required** + - The `queryCache` to hydrate the state into +- `dehydratedState: DehydratedState` + - **Required** + - The state to hydrate into the cache + +## `hydration/useHydrate` + +`useHydrate` adds a previously dehydrated state into the `queryCache` returned by `useQueryCache`. + +```jsx +import { useHydrate } from 'react-query/hydration' + +useHydrate(dehydratedState) +``` + +**Options** + +- `dehydratedState: DehydratedState` + - **Required** + - The state to hydrate + +## `hydration/ReactQueryCacheProvider` + +`hydration/ReactQueryCacheProvider` does the same thing as `ReactQueryCacheProvider` but also supports hydrating an initial state into the cache. + +```js +import { ReactQueryCacheProvider } from 'react-query/hydration' + +function App() { + return ( + + ... + + ) +} +``` + +**Options** + +- `queryCache: QueryCache` + - In instance of queryCache, you can use the `makeQueryCache` factory to create this. + - If not provided, a new cache will be generated. +- `dehydratedState: DehydratedState` + - The state to hydrate +- `hydrationConfig` + - Same config as for `hydrate` diff --git a/docs/src/pages/docs/comparison.md b/docs/src/pages/docs/comparison.md index 633a860c0d..05670a2d84 100644 --- a/docs/src/pages/docs/comparison.md +++ b/docs/src/pages/docs/comparison.md @@ -40,7 +40,7 @@ Feature/Capability Key: | Window Focus Refetching | βœ… | βœ… | πŸ›‘ | | Network Status Refetching | βœ… | βœ… | βœ… | | Automatic Refetch after Mutation3 | πŸ”Ά | πŸ”Ά | βœ… | -| Cache Dehydration/Rehydration | πŸ›‘ (Coming Soon!) | πŸ›‘ | βœ… | +| Cache Dehydration/Rehydration | βœ… | πŸ›‘ | βœ… | | React Suspense (Experimental) | βœ… | βœ… | πŸ›‘ | ### Notes diff --git a/docs/src/pages/docs/guides/ssr.md b/docs/src/pages/docs/guides/ssr.md index b688348689..cc1a302038 100644 --- a/docs/src/pages/docs/guides/ssr.md +++ b/docs/src/pages/docs/guides/ssr.md @@ -5,20 +5,30 @@ title: SSR & Next.js ## Client Side Data Fetching -If your queries are for data that is frequently updating and you don't necessarily need the data to be preset at render time (for SEO or performance purposes), then you don't need any extra configuration for React Query! Just import `useQuery` and fetch data right from within your components. +If your queries are for data that is frequently updating and you don't necessarily need the data to be present at page load time (for SEO or performance purposes), then you don't need any extra configuration for React Query! Just import `useQuery` and fetch data right from within your components. -This approach works well for applications or user-specific pages that might contain private or non-public/non-generic information. SEO is usually not relevant to these types of pages and full SSR of data is rarely needed in said situations. +This approach works well for applications or user-specific pages that might contain private or non-public/non-generic information. SEO is usually not as relevant to these types of pages and full SSR of data is rarely needed in said situations. -## Pre-rendering +## Server Side Rendering Overview -If the page and its data needs to be rendered on the server, React Query comes built in with mechanisms to support this use case. The exact implementation of these mechanisms may vary from platform to platform, but we recommend starting with Next.js which supports [2 forms of pre-rendering](https://nextjs.org/docs/basic-features/data-fetching): +React Query supports two ways of prefetching data on the server and passing that to the client. + +* Prefetch the data yourself and pass it in as `initialData` + * Quick to set up for simple cases + * Has some caveats +* Prefetch the query via React Query and use de/rehydration + * Requires slightly more setup up front + +The exact implementation of these mechanisms may vary from platform to platform, but we recommend starting with Next.js which supports [2 forms of pre-rendering](https://nextjs.org/docs/basic-features/data-fetching): - Static Generation (SSG) - Server-side Rendering (SSR) -With React Query and Next.js, you can pre-render a page for SEO and gracefully upgrade that page's queries during hydration to support caching, invalidation and background refetching on the client side. +React Query supports both of these forms of pre-rendering. + +## Prefetch the data yourself and pass it in as `initialData` -For example, together with Next.js's [`getStaticProps`](https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation), you can pass the pre-fetched data for the page to `useQuery`'s' `initialData` option: +Together with Next.js's [`getStaticProps`](https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation), you can pass the pre-fetched data for the page to `useQuery`'s' `initialData` option: ```jsx export async function getStaticProps() { @@ -26,38 +36,144 @@ export async function getStaticProps() { return { props: { posts } } } -function Props(props) { +function Posts(props) { const { data } = useQuery('posts', getPosts, { initialData: props.posts }) // ... } ``` -This page would be prerendered using the data fetched in `getStaticProps` and be ready for SEO, then, as soon it mounts on the client, will also be cached and refetched/updated in the background as needed. +The setup is minimal and this can be a perfect solution for some cases, but there are a few tradeoffs compared to the full approach: + +* If you are calling `useQuery` in a component deeper down in the tree you need to pass the `initialData` down to that point +* If you are calling `useQuery` with the same query in multiple locations, you need to pass `initialData` to all of them +* There is no way to know at what time the query was fetched on the server, so `updatedAt` and determining if the query needs refetching is based on when the page loaded instead + +## Prefetch the query via React Query and use de/rehydration -## Advanced SSR Concepts +React Query supports prefetching a query on the server and handing off or _dehydrating_ that query to the client. This means the server can prerender markup that is immediately available on page load and as soon as JS is available, React Query can upgrade or _hydrate_ those queries with the full functionality of the library. This includes refetching those queries on the client if they have become stale since the time they were rendered on the server. -When using SSR (server-side-rendering) with React Query there are a few things to note: +### Integrating with Next.js + +To support caching queries on the server and set up hydration, you start with wrapping your application with `` in `_app.js`. + +> Note: You need to import `ReactQueryCacheProvider` from `'react-query/hydration'` for it to support hydration! + +```jsx +// _app.jsx +import { ReactQueryCacheProvider } from 'react-query/hydration' + +export default function MyApp({ Component, pageProps }) { + return ( + + + + ) +} +``` + +Now you are ready to prefetch some data in your pages with either [`getStaticProps`](https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation) (for SSG) or [`getServerSideProps`](https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering) (for SSR). From React Query's perspective, these integrate in the same way, `getStaticProps` is shown below: + +```jsx +// pages/posts.jsx +import { makeQueryCache } from 'react-query' +import { dehydrate } from 'react-query/hydration' + +export async function getStaticProps() { + const queryCache = makeQueryCache() + + await queryCache.prefetchQuery('posts', getPosts) + + return { + props: { + dehydratedState: dehydrate(queryCache) + } + } +} -- If you import and use the global `queryCache` directly, queries are not cached during SSR to avoid leaking sensitive information between requests. -- If you create a `queryCache` manually with `makeQueryCache`, queries will be cached during SSR. Make sure you create a separate cache per request to avoid leaking data. -- Queries rendered on the server will by default use the `initialData` of an unfetched query. This means that by default, `data` will be set to `undefined`. To get around this in SSR, you can either pre-seed a query's cache data using the `config.initialData` option: +function Posts() { + // This useQuery could just as well happen in some deeper child to + // the "Posts"-page, data will be available immediately either way + const { data } = useQuery('posts', getPosts) -```js -const queryInfo = useQuery('todos', fetchTodoList, { - initialData: [{ id: 0, name: 'Implement SSR!' }], -}) + // This query was not prefetched on the server and will not start + // fetching until on the client, both patterns are fine to mix + const { data: otherData } = useQuery('posts-2', getPosts) -// data === [{ id: 0, name: 'Implement SSR!'}] + // ... +} ``` -Or, alternatively you can just destructure from `undefined` in your query results: +As demonstrated, it's fine to prefetch some queries and let some fetch on the client. This means you can control what content server renders or not by adding or removing `prefetchQuery` for a specific query. + +### Integrating with custom SSR solutions or other frameworks + +Since there are many different possible setups for SSR, it's hard to give a detailed guide for each (contributions are welcome!). Here is a thorough high level overview: + +**Server side** + +> Note: The global `queryCache` you can import directly from 'react-query' does not cache queries on the server to avoid leaking sensitive information between requests. + +- Prefetch data + - Create a `prefetchQueryCache` specifically for prefetching by calling `const prefetchQueryCache = makeQueryCache()` + - Call `prefetchQueryCache.prefetchQuery(...)` to prefetch queries + - Dehydrate by using `const dehydratedState = dehydrate(prefetchQueryCache)` +- Render + - Wrap the app in `` from `'react-query/hydration'` and pass in `dehydratedState` + - This makes sure a separate `queryCache` is created specifically for rendering + - **Do not** pass in the `prefetchQueryCache` from the last step, the server and client both needs to render from the dehydrated data to avoid React hydration mismatches. This is because queries with errors are excluded from dehydration by default. +- Serialize and embed `dehydratedState` in the markup + - Security note: Serializing data with `JSON.stringify` can put you at risk for XSS-vulnerabilities, [this blog post explains why and how to solve it](https://medium.com/node-security/the-most-common-xss-vulnerability-in-react-js-applications-2bdffbcc1fa0) + +**Client side** + +- Parse `dehydratedState` from where you put it in the markup +- Render + - Wrap the app in `` from `'react-query/hydration'` and pass in `dehydratedState` + +This list aims to be exhaustive, but depending on your current setup, the above steps can take more or less work. Here is a barebones example: -```js -const { status, data = [{ id: 0, name: 'Implement SSR!' }], error } = useQuery( - 'todos', - fetchTodoList +```jsx +// Server +const prefetchCache = makeQueryCache() +await prefetchCache.prefetchQuery('key', fn) +const dehydratedState = dehydrate(prefetchCache) + +const html = ReactDOM.renderToString( + + + +) +res.send(` + + +
${html}
+ + + +`) + +// Client +const dehydratedState = JSON.parse(window.__REACT_QUERY_INITIAL_QUERIES__) +ReactDOM.hydrate( + + + ) ``` -The query's state will still reflect that it is stale and has not been fetched yet, and once mounted, it will continue as normal and request a fresh copy of the query result. +### Tips, Tricks and Caveats + +**Only successful queries are included in dehydration** + +Any query with an error is automatically excluded from dehydration. This means that the default behaviour is to pretend these queries were never loaded on the server, usually showing a loading state instead, and retrying the queries on the client. This happens regardless of error. + +Sometimes this behavior is not desirable, maybe you want to render an error page with a correct status code instead on certain errors or queries. In those cases, pass `throwOnError: true` to the specific `prefetchQuery` to be able to catch and handle those errors manually. + +**Staleness is measured from when the query was fetched on the server** + +A query is considered stale depending on when it was `updatedAt`. A caveat here is that the server needs to have the correct time for this to work properly, but UTC time is used, so timezones do not factor into this. + +Because `staleTime` defaults to `0`, queries will be refetched in the background on page load by default. You might want to use a higher `staleTime` to avoid this double fetching, especially if you don't cache your markup. + +This refetching of stale queries is a perfect match when caching markup in a CDN! You can set the cache time of the page itself decently high to avoid having to re-render pages on the server, but configure the `staleTime` of the queries lower to make sure data is refetched in the background as soon as a user visits the page. Maybe you want to cache the pages for a week, but refetch the data automatically on page load if it's older than a day? diff --git a/hydration.js b/hydration.js new file mode 100644 index 0000000000..5479665434 --- /dev/null +++ b/hydration.js @@ -0,0 +1,5 @@ +if (process.env.NODE_ENV === 'production') { + module.exports = require('./dist/hydration/react-query-hydration.production.min.js') +} else { + module.exports = require('./dist/hydration/react-query-hydration.development.js') +} diff --git a/jest.config.js b/jest.config.js index 448069c3cf..42e26184ff 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,4 +2,7 @@ module.exports = { collectCoverage: true, coverageReporters: ['json', 'lcov', 'text', 'clover', 'text-summary'], testPathIgnorePatterns: ['/types/'], + moduleNameMapper: { + 'react-query': '/src/react/index.ts', + }, } diff --git a/package.json b/package.json index 5d273749b3..d80fb026c6 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "test:coverage": "yarn test:ci; open coverage/lcov-report/index.html", "test:types": "tsc", "test:eslint": "eslint --ext .ts,.tsx ./src", - "build": "NODE_ENV=production rollup -c", + "build": "NODE_ENV=production rollup -c && rollup-plugin-visualizer stats-react.json stats-hydration.json", "build:types": "tsc --project ./tsconfig.types.json && replace 'import type' 'import' ./types -r && replace 'export type' 'export' ./types -r", "now-build": "yarn && cd www && yarn && yarn build", "start": "rollup -c -w", @@ -57,6 +57,7 @@ "@testing-library/react": "^10.4.7", "@types/jest": "^26.0.4", "@types/react": "^16.9.41", + "@types/react-dom": "^16.9.8", "@typescript-eslint/eslint-plugin": "^3.6.1", "@typescript-eslint/parser": "^3.6.1", "babel-eslint": "^10.1.0", diff --git a/rollup.config.js b/rollup.config.js index f54fa83c7e..78de37919b 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -8,12 +8,18 @@ import visualizer from 'rollup-plugin-visualizer' import replace from '@rollup/plugin-replace' const external = ['react'] +const hydrationExternal = [...external, 'react-query'] const globals = { react: 'React', } +const hydrationGlobals = { + ...globals, + 'react-query': 'ReactQuery', +} const inputSrc = 'src/index.ts' +const hydrationSrc = 'src/hydration/index.ts' const extensions = ['.js', '.jsx', '.es6', '.es', '.mjs', '.ts', '.tsx'] const babelConfig = { extensions } @@ -86,7 +92,82 @@ export default [ externalDeps(), terser(), size(), - visualizer(), + visualizer({ + filename: 'stats-react.json', + json: true, + }), + ], + }, + { + input: hydrationSrc, + output: { + file: 'dist/hydration/react-query-hydration.mjs', + format: 'es', + sourcemap: true, + }, + external: hydrationExternal, + plugins: [ + resolve(resolveConfig), + babel(babelConfig), + commonJS(), + externalDeps(), + ], + }, + { + input: hydrationSrc, + output: { + file: 'dist/hydration/react-query-hydration.min.mjs', + format: 'es', + sourcemap: true, + }, + external: hydrationExternal, + plugins: [ + resolve(resolveConfig), + babel(babelConfig), + commonJS(), + externalDeps(), + terser(), + ], + }, + { + input: hydrationSrc, + output: { + name: 'ReactQueryHydration', + file: 'dist/hydration/react-query-hydration.development.js', + format: 'umd', + sourcemap: true, + globals: hydrationGlobals, + }, + external: hydrationExternal, + plugins: [ + resolve(resolveConfig), + babel(babelConfig), + commonJS(), + externalDeps(), + ], + }, + { + input: hydrationSrc, + output: { + name: 'ReactQueryHydration', + file: 'dist/hydration/react-query-hydration.production.min.js', + format: 'umd', + sourcemap: true, + globals: hydrationGlobals, + }, + external: hydrationExternal, + plugins: [ + replace({ 'process.env.NODE_ENV': `"production"`, delimiters: ['', ''] }), + resolve(resolveConfig), + babel(babelConfig), + commonJS(), + externalDeps(), + terser(), + size(), + visualizer({ + filename: 'stats-hydration.json', + json: true, + }), ], }, ] diff --git a/src/core/config.ts b/src/core/config.ts index 74a1cc71d7..55b639c208 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -47,14 +47,16 @@ export const defaultQueryKeySerializerFn: QueryKeySerializerFunction = ( * 2. Defaults from the query cache. * 3. Query/mutation config provided to the query cache method. */ +export const DEFAULT_STALE_TIME = 0 +export const DEFAULT_CACHE_TIME = 5 * 60 * 1000 export const DEFAULT_CONFIG: ReactQueryConfig = { queries: { queryKeySerializerFn: defaultQueryKeySerializerFn, enabled: true, retry: 3, retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), - staleTime: 0, - cacheTime: 5 * 60 * 1000, + staleTime: DEFAULT_STALE_TIME, + cacheTime: DEFAULT_CACHE_TIME, refetchOnWindowFocus: true, refetchOnReconnect: true, refetchOnMount: true, diff --git a/src/core/query.ts b/src/core/query.ts index 3de938ad9b..12a01f00d6 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -121,7 +121,8 @@ export class Query { private continueFetch?: () => void private isTransportCancelable?: boolean private notifyGlobalListeners: (query: Query) => void - private enableTimeouts: boolean + private enableStaleTimeout: boolean + private enableGarbageCollectionTimeout: boolean constructor(init: QueryInitConfig) { this.config = init.config @@ -131,15 +132,25 @@ export class Query { this.notifyGlobalListeners = init.notifyGlobalListeners this.observers = [] this.state = getDefaultState(init.config) - this.enableTimeouts = false + this.enableStaleTimeout = false + this.enableGarbageCollectionTimeout = false } - activateTimeouts(): void { - this.enableTimeouts = true + activateStaleTimeout(): void { + this.enableStaleTimeout = true this.rescheduleStaleTimeout() + } + + activateGarbageCollectionTimeout(): void { + this.enableGarbageCollectionTimeout = true this.rescheduleGarbageCollection() } + activateTimeouts(): void { + this.activateStaleTimeout() + this.activateGarbageCollectionTimeout() + } + updateConfig(config: QueryConfig): void { this.config = config } @@ -158,7 +169,7 @@ export class Query { this.clearStaleTimeout() if ( - !this.enableTimeouts || + !this.enableStaleTimeout || this.state.isStale || this.state.status !== QueryStatus.Success || this.config.staleTime === Infinity @@ -197,7 +208,7 @@ export class Query { this.clearCacheTimeout() if ( - !this.enableTimeouts || + !this.enableGarbageCollectionTimeout || this.config.cacheTime === Infinity || this.observers.length > 0 ) { diff --git a/src/hydration/hydration.ts b/src/hydration/hydration.ts new file mode 100644 index 0000000000..d73cf6c080 --- /dev/null +++ b/src/hydration/hydration.ts @@ -0,0 +1,98 @@ +import { DEFAULT_STALE_TIME, DEFAULT_CACHE_TIME } from '../core/config' + +import type { Query, QueryCache, QueryKey, QueryConfig } from 'react-query' + +export interface DehydratedQueryConfig { + queryKey: QueryKey + staleTime?: number + cacheTime?: number + initialData?: unknown +} + +export interface DehydratedQuery { + config: DehydratedQueryConfig + updatedAt: number +} + +export interface DehydratedState { + queries: Array +} + +export type ShouldDehydrateFunction = ( + query: Query +) => boolean +export interface DehydrateConfig { + shouldDehydrate?: ShouldDehydrateFunction +} + +function dehydrateQuery( + query: Query +): DehydratedQuery { + const dehydratedQuery: DehydratedQuery = { + config: { + queryKey: query.queryKey, + }, + updatedAt: query.state.updatedAt, + } + + // Most config is not dehydrated but instead meant to configure again when + // consuming the de/rehydrated data, typically with useQuery on the client. + // Sometimes it might make sense to prefetch data on the server and include + // in the html-payload, but not consume it on the initial render. + // We still schedule stale and garbage collection right away, which means + // we need to specifically include staleTime and cacheTime in dehydration. + if (query.config.staleTime !== DEFAULT_STALE_TIME) { + dehydratedQuery.config.staleTime = query.config.staleTime + } + if (query.config.cacheTime !== DEFAULT_CACHE_TIME) { + dehydratedQuery.config.cacheTime = query.config.cacheTime + } + if (query.state.data !== undefined) { + dehydratedQuery.config.initialData = query.state.data + } + + return dehydratedQuery +} + +const defaultShouldDehydrate: ShouldDehydrateFunction = query => + query.state.status === 'success' + +export function dehydrate( + queryCache: QueryCache, + dehydrateConfig?: DehydrateConfig +): DehydratedState { + const config = dehydrateConfig || {} + const { shouldDehydrate = defaultShouldDehydrate } = config + const dehydratedState: DehydratedState = { + queries: [], + } + for (const query of Object.values(queryCache.queries)) { + if (shouldDehydrate(query)) { + dehydratedState.queries.push(dehydrateQuery(query)) + } + } + + return dehydratedState +} + +export function hydrate( + queryCache: QueryCache, + dehydratedState: unknown +): void { + if (typeof dehydratedState !== 'object' || dehydratedState === null) { + return + } + + const queries = (dehydratedState as DehydratedState).queries || [] + + for (const dehydratedQuery of queries) { + const queryKey = dehydratedQuery.config.queryKey + const queryConfig: QueryConfig = dehydratedQuery.config as QueryConfig< + TResult + > + + const query = queryCache.buildQuery(queryKey, queryConfig) + query.state.updatedAt = dehydratedQuery.updatedAt + query.activateGarbageCollectionTimeout() + } +} diff --git a/src/hydration/index.ts b/src/hydration/index.ts new file mode 100644 index 0000000000..1831247538 --- /dev/null +++ b/src/hydration/index.ts @@ -0,0 +1,12 @@ +export { dehydrate, hydrate } from './hydration' +export { useHydrate, ReactQueryCacheProvider } from './react' + +// Types +export type { + DehydratedQueryConfig, + DehydratedQuery, + DehydratedState, + ShouldDehydrateFunction, + DehydrateConfig, +} from './hydration' +export type { HydrationCacheProviderProps } from './react' diff --git a/src/hydration/react.tsx b/src/hydration/react.tsx new file mode 100644 index 0000000000..f8f60b4496 --- /dev/null +++ b/src/hydration/react.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { + useQueryCache, + ReactQueryCacheProvider as CacheProvider, +} from 'react-query' +import { hydrate } from './hydration' + +import type { ReactQueryCacheProviderProps } from '../react' + +export function useHydrate(queries: unknown) { + const queryCache = useQueryCache() + + // Running hydrate again with the same queries is safe, + // it wont overwrite or initialize existing queries, + // relying on useMemo here is only a performance optimization + React.useMemo(() => { + if (queries) { + hydrate(queryCache, queries) + } + return undefined + }, [queryCache, queries]) +} + +interface HydratorProps { + dehydratedState?: unknown +} + +const Hydrator: React.FC = ({ dehydratedState, children }) => { + useHydrate(dehydratedState) + return children as React.ReactElement +} + +export interface HydrationCacheProviderProps + extends ReactQueryCacheProviderProps { + dehydratedState?: unknown +} + +export const ReactQueryCacheProvider: React.FC = ({ + dehydratedState, + children, + ...rest +}) => { + return ( + + {children} + + ) +} diff --git a/src/hydration/tests/hydration.test.tsx b/src/hydration/tests/hydration.test.tsx new file mode 100644 index 0000000000..2b5d44b2d2 --- /dev/null +++ b/src/hydration/tests/hydration.test.tsx @@ -0,0 +1,287 @@ +import { sleep } from '../../react/tests/utils' +import { makeQueryCache } from '../..' +import { dehydrate, hydrate } from '../hydration' + +import type { QueryCache } from '../..' + +const fetchData: ( + value: TResult, + ms?: number +) => Promise = async (value, ms) => { + await sleep(ms || 0) + return value +} + +// It is up to the queryObserver to schedule staleness, this simulates that +function simulateQueryObserver(queryCache: QueryCache, queryKey: string): void { + queryCache.getQuery(queryKey)?.activateStaleTimeout() +} + +describe('dehydration and rehydration', () => { + test('should work with serializeable values', async () => { + const queryCache = makeQueryCache() + await queryCache.prefetchQuery('string', () => fetchData('string')) + await queryCache.prefetchQuery('number', () => fetchData(1)) + await queryCache.prefetchQuery('boolean', () => fetchData(true)) + await queryCache.prefetchQuery('null', () => fetchData(null)) + await queryCache.prefetchQuery('array', () => fetchData(['string', 0])) + await queryCache.prefetchQuery('nested', () => + fetchData({ key: [{ nestedKey: 1 }] }) + ) + const dehydrated = dehydrate(queryCache) + const stringified = JSON.stringify(dehydrated) + + // --- + + const parsed = JSON.parse(stringified) + const hydrationQueryCache = makeQueryCache() + hydrate(hydrationQueryCache, parsed) + expect(hydrationQueryCache.getQuery('string')?.state.data).toBe('string') + expect(hydrationQueryCache.getQuery('number')?.state.data).toBe(1) + expect(hydrationQueryCache.getQuery('boolean')?.state.data).toBe(true) + expect(hydrationQueryCache.getQuery('null')?.state.data).toBe(null) + expect(hydrationQueryCache.getQuery('array')?.state.data).toEqual([ + 'string', + 0, + ]) + expect(hydrationQueryCache.getQuery('nested')?.state.data).toEqual({ + key: [{ nestedKey: 1 }], + }) + + const fetchDataAfterHydration = jest.fn() + await hydrationQueryCache.prefetchQuery('string', fetchDataAfterHydration) + await hydrationQueryCache.prefetchQuery('number', fetchDataAfterHydration) + await hydrationQueryCache.prefetchQuery('boolean', fetchDataAfterHydration) + await hydrationQueryCache.prefetchQuery('null', fetchDataAfterHydration) + await hydrationQueryCache.prefetchQuery('array', fetchDataAfterHydration) + await hydrationQueryCache.prefetchQuery('nested', fetchDataAfterHydration) + expect(fetchDataAfterHydration).toHaveBeenCalledTimes(0) + + queryCache.clear({ notify: false }) + hydrationQueryCache.clear({ notify: false }) + }) + + test('should not schedule staleness unless observed', async () => { + const queryCache = makeQueryCache() + await queryCache.prefetchQuery('string', () => fetchData('string')) + const dehydrated = dehydrate(queryCache) + const stringified = JSON.stringify(dehydrated) + + // --- + + const parsed = JSON.parse(stringified) + const hydrationQueryCache = makeQueryCache() + hydrate(hydrationQueryCache, parsed) + expect(hydrationQueryCache.getQuery('string')?.state.data).toBe('string') + expect(hydrationQueryCache.getQuery('string')?.state.isStale).toBe(false) + await sleep(10) + expect(hydrationQueryCache.getQuery('string')?.state.isStale).toBe(false) + + queryCache.clear({ notify: false }) + hydrationQueryCache.clear({ notify: false }) + }) + + test('should default to scheduling staleness immediately', async () => { + const queryCache = makeQueryCache() + await queryCache.prefetchQuery('string', () => fetchData('string')) + const dehydrated = dehydrate(queryCache) + const stringified = JSON.stringify(dehydrated) + + // --- + + const parsed = JSON.parse(stringified) + const hydrationQueryCache = makeQueryCache() + hydrate(hydrationQueryCache, parsed) + simulateQueryObserver(hydrationQueryCache, 'string') + expect(hydrationQueryCache.getQuery('string')?.state.data).toBe('string') + expect(hydrationQueryCache.getQuery('string')?.state.isStale).toBe(false) + await sleep(10) + expect(hydrationQueryCache.getQuery('string')?.state.isStale).toBe(true) + + queryCache.clear({ notify: false }) + hydrationQueryCache.clear({ notify: false }) + }) + + test('should respect staleTime, measured from when data was fetched', async () => { + const queryCache = makeQueryCache() + await queryCache.prefetchQuery('string', () => fetchData('string'), { + staleTime: 50, + }) + const dehydrated = dehydrate(queryCache) + const stringified = JSON.stringify(dehydrated) + + await sleep(20) + + // --- + + const parsed = JSON.parse(stringified) + const hydrationQueryCache = makeQueryCache() + hydrate(hydrationQueryCache, parsed) + simulateQueryObserver(hydrationQueryCache, 'string') + expect(hydrationQueryCache.getQuery('string')?.state.data).toBe('string') + expect(hydrationQueryCache.getQuery('string')?.state.isStale).toBe(false) + await sleep(10) + expect(hydrationQueryCache.getQuery('string')?.state.isStale).toBe(false) + await sleep(30) + expect(hydrationQueryCache.getQuery('string')?.state.isStale).toBe(true) + + queryCache.clear({ notify: false }) + hydrationQueryCache.clear({ notify: false }) + }) + + test('should schedule stale immediately if enough time has elapsed between dehydrate and hydrate', async () => { + const queryCache = makeQueryCache() + await queryCache.prefetchQuery('string', () => fetchData('string'), { + staleTime: 20, + }) + const dehydrated = dehydrate(queryCache) + const stringified = JSON.stringify(dehydrated) + + await sleep(30) + + // --- + + const parsed = JSON.parse(stringified) + const hydrationQueryCache = makeQueryCache() + hydrate(hydrationQueryCache, parsed) + simulateQueryObserver(hydrationQueryCache, 'string') + expect(hydrationQueryCache.getQuery('string')?.state.data).toBe('string') + expect(hydrationQueryCache.getQuery('string')?.state.isStale).toBe(false) + await sleep(10) + expect(hydrationQueryCache.getQuery('string')?.state.isStale).toBe(true) + + queryCache.clear({ notify: false }) + hydrationQueryCache.clear({ notify: false }) + }) + + test('should schedule garbage collection, measured from hydration', async () => { + const queryCache = makeQueryCache() + await queryCache.prefetchQuery('string', () => fetchData('string'), { + cacheTime: 50, + }) + const dehydrated = dehydrate(queryCache) + const stringified = JSON.stringify(dehydrated) + + await sleep(20) + + // --- + + const parsed = JSON.parse(stringified) + const hydrationQueryCache = makeQueryCache() + hydrate(hydrationQueryCache, parsed) + expect(hydrationQueryCache.getQuery('string')?.state.data).toBe('string') + await sleep(40) + expect(hydrationQueryCache.getQuery('string')).toBeTruthy() + await sleep(20) + expect(hydrationQueryCache.getQuery('string')).toBeFalsy() + + queryCache.clear({ notify: false }) + hydrationQueryCache.clear({ notify: false }) + }) + + test('should work with complex keys', async () => { + const queryCache = makeQueryCache() + await queryCache.prefetchQuery( + ['string', { key: ['string'], key2: 0 }], + () => fetchData('string') + ) + const dehydrated = dehydrate(queryCache) + const stringified = JSON.stringify(dehydrated) + + // --- + + const parsed = JSON.parse(stringified) + const hydrationQueryCache = makeQueryCache() + hydrate(hydrationQueryCache, parsed) + expect( + hydrationQueryCache.getQuery(['string', { key: ['string'], key2: 0 }]) + ?.state.data + ).toBe('string') + + const fetchDataAfterHydration = jest.fn() + await hydrationQueryCache.prefetchQuery( + ['string', { key: ['string'], key2: 0 }], + fetchDataAfterHydration + ) + expect(fetchDataAfterHydration).toHaveBeenCalledTimes(0) + + queryCache.clear({ notify: false }) + hydrationQueryCache.clear({ notify: false }) + }) + + test('should not include default config in dehydration', async () => { + const queryCache = makeQueryCache() + await queryCache.prefetchQuery('string', () => fetchData('string')) + const dehydrated = dehydrate(queryCache) + + // This is testing implementation details that can change and are not + // part of the public API, but is important for keeping the payload small + // Exact shape is not important here, just that staleTime and cacheTime + // (and any future other config) is not included in it + const dehydratedQuery = dehydrated?.queries.find( + dehydratedQuery => + (dehydratedQuery?.config?.queryKey as Array)[0] === 'string' + ) + expect(dehydratedQuery).toBeTruthy() + expect(dehydratedQuery?.config.staleTime).toBe(undefined) + expect(dehydratedQuery?.config.cacheTime).toBe(undefined) + }) + + test('should only hydrate successful queries by default', async () => { + const consoleMock = jest.spyOn(console, 'error') + consoleMock.mockImplementation(() => undefined) + + const queryCache = makeQueryCache() + await queryCache.prefetchQuery('success', () => fetchData('success')) + queryCache.prefetchQuery('loading', () => fetchData('loading', 10000)) + await queryCache.prefetchQuery('error', () => { + throw new Error() + }) + const dehydrated = dehydrate(queryCache) + const stringified = JSON.stringify(dehydrated) + + // --- + + const parsed = JSON.parse(stringified) + const hydrationQueryCache = makeQueryCache() + hydrate(hydrationQueryCache, parsed) + + expect(hydrationQueryCache.getQuery('success')).toBeTruthy() + expect(hydrationQueryCache.getQuery('loading')).toBeFalsy() + expect(hydrationQueryCache.getQuery('error')).toBeFalsy() + + queryCache.clear({ notify: false }) + hydrationQueryCache.clear({ notify: false }) + consoleMock.mockRestore() + }) + + test('should filter queries via shouldDehydrate', async () => { + const queryCache = makeQueryCache() + await queryCache.prefetchQuery('string', () => fetchData('string')) + await queryCache.prefetchQuery('number', () => fetchData(1)) + const dehydrated = dehydrate(queryCache, { + shouldDehydrate: query => query.queryKey[0] !== 'string', + }) + + // This is testing implementation details that can change and are not + // part of the public API, but is important for keeping the payload small + const dehydratedQuery = dehydrated?.queries.find( + dehydratedQuery => + (dehydratedQuery?.config?.queryKey as Array)[0] === 'string' + ) + expect(dehydratedQuery).toBeUndefined() + + const stringified = JSON.stringify(dehydrated) + + // --- + + const parsed = JSON.parse(stringified) + const hydrationQueryCache = makeQueryCache() + hydrate(hydrationQueryCache, parsed) + expect(hydrationQueryCache.getQuery('string')).toBeUndefined() + expect(hydrationQueryCache.getQuery('number')?.state.data).toBe(1) + + queryCache.clear({ notify: false }) + hydrationQueryCache.clear({ notify: false }) + }) +}) diff --git a/src/hydration/tests/react.test.tsx b/src/hydration/tests/react.test.tsx new file mode 100644 index 0000000000..39b725e271 --- /dev/null +++ b/src/hydration/tests/react.test.tsx @@ -0,0 +1,186 @@ +import React from 'react' +import { render, waitFor } from '@testing-library/react' +import { + ReactQueryCacheProvider as OriginalCacheProvider, + makeQueryCache, + useQuery, +} from '../..' +import { dehydrate, useHydrate, ReactQueryCacheProvider } from '../' + +describe('React hydration', () => { + const fetchData: (value: string) => Promise = value => + new Promise(res => setTimeout(() => res(value), 10)) + const dataQuery: (key: string) => Promise = key => fetchData(key) + let stringifiedState: string + + beforeAll(async () => { + const serverQueryCache = makeQueryCache() + await serverQueryCache.prefetchQuery('string', dataQuery) + const dehydrated = dehydrate(serverQueryCache) + stringifiedState = JSON.stringify(dehydrated) + serverQueryCache.clear({ notify: false }) + }) + + describe('useHydrate', () => { + test('should handle global cache case', async () => { + const dehydratedState = JSON.parse(stringifiedState) + function Page() { + useHydrate(dehydratedState) + const { data } = useQuery('string', dataQuery) + + return ( +
+

{data}

+
+ ) + } + + const rendered = render() + + rendered.getByText('string') + }) + + test('should hydrate queries to the cache on context', async () => { + const dehydratedState = JSON.parse(stringifiedState) + const clientQueryCache = makeQueryCache() + + function Page() { + useHydrate(dehydratedState) + const { data } = useQuery('string', dataQuery) + return ( +
+

{data}

+
+ ) + } + + const rendered = render( + + + + ) + + rendered.getByText('string') + expect(clientQueryCache.getQuery('string')?.state.isStale).toBe(false) + await waitFor(() => + expect(clientQueryCache.getQuery('string')?.state.isStale).toBe(true) + ) + + clientQueryCache.clear({ notify: false }) + }) + }) + + describe('ReactQueryCacheProvider with hydration support', () => { + test('should hydrate new queries if queries change', async () => { + const dehydratedState = JSON.parse(stringifiedState) + const clientQueryCache = makeQueryCache() + + function Page({ queryKey }: { queryKey: string }) { + const { data } = useQuery(queryKey, dataQuery) + return ( +
+

{data}

+
+ ) + } + + const rendered = render( + + + + ) + + rendered.getByText('string') + expect(clientQueryCache.getQuery('string')?.state.isStale).toBe(false) + await waitFor(() => + expect(clientQueryCache.getQuery('string')?.state.isStale).toBe(true) + ) + + const intermediateCache = makeQueryCache() + await intermediateCache.prefetchQuery('string', () => + dataQuery('should not change') + ) + await intermediateCache.prefetchQuery('added string', dataQuery) + const dehydrated = dehydrate(intermediateCache) + intermediateCache.clear({ notify: false }) + + rendered.rerender( + + + + + ) + + // Existing query data should not be overwritten, + // so this should still be the original data + rendered.getByText('string') + // But new query data should be available immediately + rendered.getByText('added string') + expect(clientQueryCache.getQuery('added string')?.state.isStale).toBe( + false + ) + await waitFor(() => + expect(clientQueryCache.getQuery('added string')?.state.isStale).toBe( + true + ) + ) + + clientQueryCache.clear({ notify: false }) + }) + + test('should hydrate queries to new cache if cache changes', async () => { + const dehydratedState = JSON.parse(stringifiedState) + const clientQueryCache = makeQueryCache() + + function Page() { + const { data } = useQuery('string', dataQuery) + return ( +
+

{data}

+
+ ) + } + + const rendered = render( + + + + ) + + rendered.getByText('string') + expect(clientQueryCache.getQuery('string')?.state.isStale).toBe(false) + await waitFor(() => + expect(clientQueryCache.getQuery('string')?.state.isStale).toBe(true) + ) + + const newClientQueryCache = makeQueryCache() + + rendered.rerender( + + + + ) + + rendered.getByText('string') + expect(newClientQueryCache.getQuery('string')?.state.isStale).toBe(false) + await waitFor(() => + expect(newClientQueryCache.getQuery('string')?.state.isStale).toBe(true) + ) + + clientQueryCache.clear({ notify: false }) + newClientQueryCache.clear({ notify: false }) + }) + }) +}) diff --git a/src/hydration/tests/ssr.test.tsx b/src/hydration/tests/ssr.test.tsx new file mode 100644 index 0000000000..e1f5a82f63 --- /dev/null +++ b/src/hydration/tests/ssr.test.tsx @@ -0,0 +1,242 @@ +import * as React from 'react' +import ReactDOM from 'react-dom' +import ReactDOMServer from 'react-dom/server' +import { waitFor } from '@testing-library/react' +import { makeQueryCache, useQuery, setConsole } from '../..' +import { dehydrate, ReactQueryCacheProvider } from '../' +import * as utils from '../../core/utils' +import { sleep } from '../../react/tests/utils' + +jest.useFakeTimers() + +// This monkey-patches the isServer-value from utils, +// so that we can pretend to be in a server environment +function setIsServer(isServer: boolean) { + // @ts-ignore + utils.isServer = isServer +} + +const fetchData: ( + value: TResult, + ms?: number +) => Promise = async (value, ms) => { + await sleep(ms || 1) + return value +} + +function PrintStateComponent({ + componentName, + isFetching, + isError, + data, +}: any): any { + if (isFetching) { + return `Loading ${componentName}` + } + + if (isError) { + return `Error ${componentName}` + } + + return `Success ${componentName} - ${data}` +} + +describe('Server side rendering with de/rehydration', () => { + it('should not mismatch on success', async () => { + const consoleMock = jest.spyOn(console, 'error') + consoleMock.mockImplementation(() => undefined) + const fetchDataSuccess = jest.fn(fetchData) + + // -- Shared part -- + function SuccessComponent() { + const { isFetching, isError, data } = useQuery('success', () => + fetchDataSuccess('success!') + ) + return ( + + ) + } + + // -- Server part -- + setIsServer(true) + + const serverPrefetchCache = makeQueryCache() + const prefetchPromise = serverPrefetchCache.prefetchQuery('success', () => + fetchDataSuccess('success') + ) + jest.runOnlyPendingTimers() + await prefetchPromise + const dehydratedStateServer = dehydrate(serverPrefetchCache) + const markup = ReactDOMServer.renderToString( + + + + ) + const stringifiedState = JSON.stringify(dehydratedStateServer) + setIsServer(false) + + expect(markup).toBe('Success SuccessComponent - success') + + // -- Client part -- + const el = document.createElement('div') + el.innerHTML = markup + const dehydratedStateClient = JSON.parse(stringifiedState) + ReactDOM.hydrate( + + + , + el + ) + + // Check that we have no React hydration mismatches + expect(consoleMock).not.toHaveBeenCalled() + expect(fetchDataSuccess).toHaveBeenCalledTimes(1) + expect(el.innerHTML).toBe('Success SuccessComponent - success') + + ReactDOM.unmountComponentAtNode(el) + consoleMock.mockRestore() + }) + + it('should not mismatch on error', async () => { + setConsole({ + log: console.log, + warn: console.warn, + error: () => undefined, + }) + const consoleMock = jest.spyOn(console, 'error') + consoleMock.mockImplementation(() => undefined) + const fetchDataError = jest.fn(() => { + throw new Error() + }) + + // -- Shared part -- + function ErrorComponent() { + const { isFetching, isError, data } = useQuery('error', () => + fetchDataError() + ) + return ( + + ) + } + + // -- Server part -- + setIsServer(true) + const serverQueryCache = makeQueryCache() + const prefetchPromise = serverQueryCache.prefetchQuery('error', () => + fetchDataError() + ) + jest.runOnlyPendingTimers() + await prefetchPromise + const dehydratedStateServer = dehydrate(serverQueryCache) + const markup = ReactDOMServer.renderToString( + + + + ) + const stringifiedState = JSON.stringify(dehydratedStateServer) + setIsServer(false) + + expect(markup).toBe('Loading ErrorComponent') + + // -- Client part -- + const el = document.createElement('div') + el.innerHTML = markup + const dehydratedStateClient = JSON.parse(stringifiedState) + ReactDOM.hydrate( + + + , + el + ) + + // We expect exactly one console.error here, which is from the + expect(consoleMock).toHaveBeenCalledTimes(0) + expect(fetchDataError).toHaveBeenCalledTimes(1) + expect(el.innerHTML).toBe('Loading ErrorComponent') + + jest.runOnlyPendingTimers() + + expect(fetchDataError).toHaveBeenCalledTimes(2) + await waitFor(() => expect(el.innerHTML).toBe('Error ErrorComponent')) + + ReactDOM.unmountComponentAtNode(el) + consoleMock.mockRestore() + setConsole({ + log: console.log, + warn: console.warn, + error: console.error, + }) + }) + + it('should not mismatch on queries that were not prefetched', async () => { + const consoleMock = jest.spyOn(console, 'error') + consoleMock.mockImplementation(() => undefined) + const fetchDataSuccess = jest.fn(fetchData) + + // -- Shared part -- + function SuccessComponent() { + const { isFetching, isError, data } = useQuery('success', () => + fetchDataSuccess('success!') + ) + return ( + + ) + } + + // -- Server part -- + setIsServer(true) + + const serverPrefetchCache = makeQueryCache() + const dehydratedStateServer = dehydrate(serverPrefetchCache) + const markup = ReactDOMServer.renderToString( + + + + ) + const stringifiedState = JSON.stringify(dehydratedStateServer) + setIsServer(false) + + expect(markup).toBe('Loading SuccessComponent') + + // -- Client part -- + const el = document.createElement('div') + el.innerHTML = markup + const dehydratedStateClient = JSON.parse(stringifiedState) + ReactDOM.hydrate( + + + , + el + ) + + // Check that we have no React hydration mismatches + expect(consoleMock).not.toHaveBeenCalled() + expect(fetchDataSuccess).toHaveBeenCalledTimes(0) + expect(el.innerHTML).toBe('Loading SuccessComponent') + + jest.runOnlyPendingTimers() + + expect(fetchDataSuccess).toHaveBeenCalledTimes(1) + await waitFor(() => + expect(el.innerHTML).toBe('Success SuccessComponent - success!') + ) + + ReactDOM.unmountComponentAtNode(el) + consoleMock.mockRestore() + }) +}) diff --git a/tsconfig.json b/tsconfig.json index a6d78ae24c..5d737de6c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,11 @@ "noUnusedParameters": true, "skipLibCheck": true, "strict": true, - "types": ["jest"] + "types": ["jest"], + "baseUrl": "./", + "paths": { + "react-query": ["src/index.ts"] + } }, "include": ["./src"] } diff --git a/tsconfig.types.json b/tsconfig.types.json index 2abb74449b..27ddd6c130 100644 --- a/tsconfig.types.json +++ b/tsconfig.types.json @@ -6,6 +6,6 @@ "emitDeclarationOnly": true, "noEmit": false }, - "files": ["./src/index.ts"], + "files": ["./src/index.ts", "./src/hydration/index.ts"], "exclude": ["./src/**/*"] } diff --git a/yarn.lock b/yarn.lock index ff246c24e8..11a14154cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1800,6 +1800,21 @@ resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== +"@types/react-dom@^16.9.8": + version "16.9.8" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.8.tgz#fe4c1e11dfc67155733dfa6aa65108b4971cb423" + integrity sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA== + dependencies: + "@types/react" "*" + +"@types/react@*": + version "16.9.46" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.46.tgz#f0326cd7adceda74148baa9bff6e918632f5069e" + integrity sha512-dbHzO3aAq1lB3jRQuNpuZ/mnu+CdD3H0WVaaBQA8LTT3S33xhVBUj232T8M3tAhSWJs/D/UqORYUlJNl/8VQZg== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + "@types/react@^16.9.41": version "16.9.41" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.41.tgz#925137ee4d2ff406a0ecf29e8e9237390844002e" @@ -2634,6 +2649,11 @@ csstype@^2.2.0: resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.10.tgz#e63af50e66d7c266edb6b32909cfd0aabe03928b" integrity sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w== +csstype@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.2.tgz#ee5ff8f208c8cd613b389f7b222c9801ca62b3f7" + integrity sha512-ofovWglpqoqbfLNOTBNZLSbMuGrblAf1efvvArGKOZMBrIoJeu5UsAipQolkijtyQx5MtAzT/J9IHj/CEY1mJw== + damerau-levenshtein@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.5.tgz#780cf7144eb2e8dbd1c3bb83ae31100ccc31a414"