From 15b2cd124d9571600c11058f254dd3ed416c5247 Mon Sep 17 00:00:00 2001 From: FaberVitale Date: Sat, 16 Apr 2022 12:23:37 +0200 Subject: [PATCH 1/7] feat: add useSuspendAll hook & react/suspense example --- .codesandbox/ci.json | 3 +- examples/query/react/suspense/.env | 1 + examples/query/react/suspense/package.json | 43 +++++ .../query/react/suspense/public/index.html | 42 ++++ .../query/react/suspense/public/manifest.json | 8 + examples/query/react/suspense/src/App.tsx | 45 +++++ examples/query/react/suspense/src/Pokemon.tsx | 64 +++++++ .../suspense/src/PokemonParallelQueries.tsx | 93 +++++++++ .../react/suspense/src/PokemonPlaceholder.tsx | 55 ++++++ .../suspense/src/PokemonSingleQueries.tsx | 69 +++++++ .../suspense/src/PokemonWithEvolution.tsx | 48 +++++ examples/query/react/suspense/src/index.tsx | 15 ++ .../query/react/suspense/src/pokemon.data.ts | 180 ++++++++++++++++++ .../react/suspense/src/react-app-env.d.ts | 7 + .../react/suspense/src/services/pokemon.ts | 31 +++ examples/query/react/suspense/src/store.ts | 11 ++ examples/query/react/suspense/src/styles.css | 96 ++++++++++ examples/query/react/suspense/tsconfig.json | 21 ++ .../toolkit/src/query/react/buildHooks.ts | 136 ++++++++++++- .../toolkit/src/query/react/exceptions.ts | 29 +++ packages/toolkit/src/query/react/index.ts | 13 +- .../toolkit/src/query/react/suspense-utils.ts | 66 +++++++ .../src/query/tests/buildHooks.test.tsx | 169 +++++++++++++++- .../src/query/tests/unionTypes.test.ts | 7 +- yarn.lock | 47 +++++ 25 files changed, 1282 insertions(+), 17 deletions(-) create mode 100644 examples/query/react/suspense/.env create mode 100644 examples/query/react/suspense/package.json create mode 100644 examples/query/react/suspense/public/index.html create mode 100644 examples/query/react/suspense/public/manifest.json create mode 100644 examples/query/react/suspense/src/App.tsx create mode 100644 examples/query/react/suspense/src/Pokemon.tsx create mode 100644 examples/query/react/suspense/src/PokemonParallelQueries.tsx create mode 100644 examples/query/react/suspense/src/PokemonPlaceholder.tsx create mode 100644 examples/query/react/suspense/src/PokemonSingleQueries.tsx create mode 100644 examples/query/react/suspense/src/PokemonWithEvolution.tsx create mode 100644 examples/query/react/suspense/src/index.tsx create mode 100644 examples/query/react/suspense/src/pokemon.data.ts create mode 100644 examples/query/react/suspense/src/react-app-env.d.ts create mode 100644 examples/query/react/suspense/src/services/pokemon.ts create mode 100644 examples/query/react/suspense/src/store.ts create mode 100644 examples/query/react/suspense/src/styles.css create mode 100644 examples/query/react/suspense/tsconfig.json create mode 100644 packages/toolkit/src/query/react/exceptions.ts create mode 100644 packages/toolkit/src/query/react/suspense-utils.ts diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index 69146d63ba..b937870674 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -5,7 +5,8 @@ "github/reduxjs/rtk-github-issues-example", "/examples/query/react/basic", "/examples/query/react/advanced", - "/examples/action-listener/counter" + "/examples/action-listener/counter", + "/examples/query/react/suspense" ], "node": "14", "buildCommand": "build:packages", diff --git a/examples/query/react/suspense/.env b/examples/query/react/suspense/.env new file mode 100644 index 0000000000..7d910f1484 --- /dev/null +++ b/examples/query/react/suspense/.env @@ -0,0 +1 @@ +SKIP_PREFLIGHT_CHECK=true \ No newline at end of file diff --git a/examples/query/react/suspense/package.json b/examples/query/react/suspense/package.json new file mode 100644 index 0000000000..3ace260cfa --- /dev/null +++ b/examples/query/react/suspense/package.json @@ -0,0 +1,43 @@ +{ + "name": "@examples-query-react/suspense", + "private": true, + "version": "1.0.0", + "description": "", + "keywords": [], + "main": "src/index.tsx", + "dependencies": { + "@reduxjs/toolkit": "^1.8.0", + "clsx": "^1.1.1", + "react": "17.0.0", + "react-dom": "17.0.0", + "react-error-boundary": "3.1.4", + "react-redux": "7.2.2", + "react-scripts": "4.0.2", + "use-sync-external-store": "^1.0.0" + }, + "devDependencies": { + "@types/react": "17.0.0", + "@types/react-dom": "17.0.0", + "@types/react-redux": "7.1.9", + "@types/use-sync-external-store": "^0.0.3", + "typescript": "~4.2.4" + }, + "eslintConfig": { + "extends": [ + "react-app" + ], + "rules": { + "react/react-in-jsx-scope": "off" + } + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build" + }, + "browserslist": [ + ">0.2%", + "not dead", + "not ie <= 11", + "not op_mini all" + ] +} diff --git a/examples/query/react/suspense/public/index.html b/examples/query/react/suspense/public/index.html new file mode 100644 index 0000000000..475209a96c --- /dev/null +++ b/examples/query/react/suspense/public/index.html @@ -0,0 +1,42 @@ + + + + + + + + + + + React App + + + + +
+ + + diff --git a/examples/query/react/suspense/public/manifest.json b/examples/query/react/suspense/public/manifest.json new file mode 100644 index 0000000000..6269787a85 --- /dev/null +++ b/examples/query/react/suspense/public/manifest.json @@ -0,0 +1,8 @@ +{ + "short_name": "RTK Query Polling Example", + "name": "Polling Example", + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/examples/query/react/suspense/src/App.tsx b/examples/query/react/suspense/src/App.tsx new file mode 100644 index 0000000000..e788dbdb39 --- /dev/null +++ b/examples/query/react/suspense/src/App.tsx @@ -0,0 +1,45 @@ +import * as React from 'react' +import { POKEMON_NAMES } from './pokemon.data' +import './styles.css' +import { PokemonSingleQueries } from './PokemonSingleQueries' +import { PokemonParallelQueries } from './PokemonParallelQueries' + +const getRandomPokemonName = () => + POKEMON_NAMES[Math.floor(Math.random() * POKEMON_NAMES.length)] + +export default function App() { + const [errorRate, setErrorRate] = React.useState( + window.fetchFnErrorRate + ) + + React.useEffect(() => { + window.fetchFnErrorRate = errorRate + }, [errorRate]) + + return ( +
+
+
+ +
+
+ +
+ +
+ ) +} diff --git a/examples/query/react/suspense/src/Pokemon.tsx b/examples/query/react/suspense/src/Pokemon.tsx new file mode 100644 index 0000000000..6b5d43fec7 --- /dev/null +++ b/examples/query/react/suspense/src/Pokemon.tsx @@ -0,0 +1,64 @@ +import * as React from 'react' +import { useSuspendAll } from '@reduxjs/toolkit/query/react' +import { useGetPokemonByNameQuery } from './services/pokemon' +import type { PokemonName } from './pokemon.data' + +const intervalOptions = [ + { label: 'Off', value: 0 }, + { label: '20s', value: 10000 }, + { label: '1m', value: 60000 }, +] + +const getRandomIntervalValue = () => + intervalOptions[Math.floor(Math.random() * intervalOptions.length)].value + +export interface PokemonProps { + name: PokemonName +} + +export function Pokemon({ name }: PokemonProps) { + const [pollingInterval, setPollingInterval] = React.useState( + getRandomIntervalValue() + ) + + const [{ data, isFetching, refetch }] = useSuspendAll( + useGetPokemonByNameQuery(name) + ) + + return ( +
+

{data.species.name}

+ {data.species.name} +
+ + +
+
+ +
+
+ ) +} diff --git a/examples/query/react/suspense/src/PokemonParallelQueries.tsx b/examples/query/react/suspense/src/PokemonParallelQueries.tsx new file mode 100644 index 0000000000..fc0c675464 --- /dev/null +++ b/examples/query/react/suspense/src/PokemonParallelQueries.tsx @@ -0,0 +1,93 @@ +import * as React from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { pokemonEvolutions } from './pokemon.data' +import { PokemonPlaceholder } from './PokemonPlaceholder' +import { PokemonWithEvolution } from './PokemonWithEvolution' + +const evolutionsKeys = Object.keys( + pokemonEvolutions +) as (keyof typeof pokemonEvolutions)[] + +export const PokemonParallelQueries = React.memo( + function PokemonParallelQueries() { + const [evolutions, setEvolutions] = React.useState([ + 'bulbasaur' as keyof typeof pokemonEvolutions, + ]) + + return ( +
+

Suspense: indipendent parallel queries

+
{ + evt.preventDefault() + + const formValues = new FormData(evt.currentTarget) + + const next = Boolean(formValues.get('addBulbasaur')) + ? 'bulbasaur' + : evolutionsKeys[ + Math.floor(Math.random() * evolutionsKeys.length) + ] + + setEvolutions((curr) => curr.concat(next)) + }} + > + + +
+
+ {evolutions.map((name, idx) => ( + ( + <> + { + (error as any)?.retryQuery?.(); + resetErrorBoundary() + }} + /> + { + (error as any)?.retryQuery?.() + resetErrorBoundary() + }} + /> + + )} + > + + + + + } + > + + + + ))} +
+
+ ) + } +) diff --git a/examples/query/react/suspense/src/PokemonPlaceholder.tsx b/examples/query/react/suspense/src/PokemonPlaceholder.tsx new file mode 100644 index 0000000000..f217697bda --- /dev/null +++ b/examples/query/react/suspense/src/PokemonPlaceholder.tsx @@ -0,0 +1,55 @@ +import * as React from 'react' +import clsx from 'clsx' +import { PokemonName } from './pokemon.data' + +export interface PokemonPlaceholderProps + extends React.HTMLAttributes { + name: PokemonName + error?: Error | undefined + onRetry?(): void +} + +export function PokemonPlaceholder({ + name, + children, + className, + error, + onRetry, + ...otherProps +}: PokemonPlaceholderProps) { + const isError = !!error + + let content: React.ReactNode = isError ? ( + <> +

An error has occurred while loading {name}

+
{error?.message}
+ {onRetry && ( + + )} + {children} + + ) : ( + <> +

Loading pokemon {name}

+
+ (Suspense fallback) + {children} + + ) + + return ( +
+ {content} +
+ ) +} diff --git a/examples/query/react/suspense/src/PokemonSingleQueries.tsx b/examples/query/react/suspense/src/PokemonSingleQueries.tsx new file mode 100644 index 0000000000..e0bad250f0 --- /dev/null +++ b/examples/query/react/suspense/src/PokemonSingleQueries.tsx @@ -0,0 +1,69 @@ +import { SuspenseQueryError } from '@reduxjs/toolkit/dist/query/react' +import * as React from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { Pokemon, PokemonProps } from './Pokemon' +import { POKEMON_NAMES } from './pokemon.data' +import { PokemonPlaceholder } from './PokemonPlaceholder' + +const getRandomPokemonName = () => + POKEMON_NAMES[Math.floor(Math.random() * POKEMON_NAMES.length)] + +export const PokemonSingleQueries = React.memo(function PokemonSingleQueries() { + const [pokemonConf, setPokemonConf] = React.useState([ + { name: 'bulbasaur' }, + ]) + + return ( +
+

Suspense: single query

+
{ + evt.preventDefault() + + const formValues = new FormData(evt.currentTarget) + + setPokemonConf((prev) => [ + ...prev, + { + name: Boolean(formValues.get('addBulbasaur')) + ? 'bulbasaur' + : getRandomPokemonName(), + }, + ]) + }} + > + + +
+
+ {pokemonConf.map((pokemonProps, idx) => ( + ( + { + (error as any)?.retryQuery?.() + resetErrorBoundary() + }} + /> + )} + > + } + > + + + + ))} +
+
+ ) +}) diff --git a/examples/query/react/suspense/src/PokemonWithEvolution.tsx b/examples/query/react/suspense/src/PokemonWithEvolution.tsx new file mode 100644 index 0000000000..50f432402a --- /dev/null +++ b/examples/query/react/suspense/src/PokemonWithEvolution.tsx @@ -0,0 +1,48 @@ +import * as React from 'react' +import { PokemonName } from './pokemon.data' +import { useGetPokemonByNameQuery } from './services/pokemon' +import { useSuspendAll } from '@reduxjs/toolkit/query/react' + +export interface PokemonWithEvolutionProps { + base: PokemonName + evolution: PokemonName +} + +export function PokemonWithEvolution({ + base, + evolution, +}: PokemonWithEvolutionProps) { + const [baseDataQuery, evolutionQuery] = useSuspendAll( + useGetPokemonByNameQuery(base), + useGetPokemonByNameQuery(evolution) + ) + + return ( + <> + {[baseDataQuery, evolutionQuery].map( + ({ data, isFetching, refetch }, idx) => ( +
+

{data.species.name}

+ {data.species.name} +
{idx === 0 ? 'base' : 'evolution'}
+
+ +
+
+ ) + )} + + ) +} diff --git a/examples/query/react/suspense/src/index.tsx b/examples/query/react/suspense/src/index.tsx new file mode 100644 index 0000000000..a1d9197a57 --- /dev/null +++ b/examples/query/react/suspense/src/index.tsx @@ -0,0 +1,15 @@ +import { render } from 'react-dom' +import { Provider } from 'react-redux' + +import App from './App' +import { store } from './store' + +window.fetchFnErrorRate = 0 + +const rootElement = document.getElementById('root') +render( + + + , + rootElement +) diff --git a/examples/query/react/suspense/src/pokemon.data.ts b/examples/query/react/suspense/src/pokemon.data.ts new file mode 100644 index 0000000000..f1db3c0e11 --- /dev/null +++ b/examples/query/react/suspense/src/pokemon.data.ts @@ -0,0 +1,180 @@ +export const POKEMON_NAMES = [ + 'bulbasaur', + 'ivysaur', + 'venusaur', + 'charmander', + 'charmeleon', + 'charizard', + 'squirtle', + 'wartortle', + 'blastoise', + 'caterpie', + 'metapod', + 'butterfree', + 'weedle', + 'kakuna', + 'beedrill', + 'pidgey', + 'pidgeotto', + 'pidgeot', + 'rattata', + 'raticate', + 'spearow', + 'fearow', + 'ekans', + 'arbok', + 'pikachu', + 'raichu', + 'sandshrew', + 'sandslash', + 'nidoran', + 'nidorina', + 'nidoqueen', + 'nidoran', + 'nidorino', + 'nidoking', + 'clefairy', + 'clefable', + 'vulpix', + 'ninetales', + 'jigglypuff', + 'wigglytuff', + 'zubat', + 'golbat', + 'oddish', + 'gloom', + 'vileplume', + 'paras', + 'parasect', + 'venonat', + 'venomoth', + 'diglett', + 'dugtrio', + 'meowth', + 'persian', + 'psyduck', + 'golduck', + 'mankey', + 'primeape', + 'growlithe', + 'arcanine', + 'poliwag', + 'poliwhirl', + 'poliwrath', + 'abra', + 'kadabra', + 'alakazam', + 'machop', + 'machoke', + 'machamp', + 'bellsprout', + 'weepinbell', + 'victreebel', + 'tentacool', + 'tentacruel', + 'geodude', + 'graveler', + 'golem', + 'ponyta', + 'rapidash', + 'slowpoke', + 'slowbro', + 'magnemite', + 'magneton', + "farfetch'd", + 'doduo', + 'dodrio', + 'seel', + 'dewgong', + 'grimer', + 'muk', + 'shellder', + 'cloyster', + 'gastly', + 'haunter', + 'gengar', + 'onix', + 'drowzee', + 'hypno', + 'krabby', + 'kingler', + 'voltorb', + 'electrode', + 'exeggcute', + 'exeggutor', + 'cubone', + 'marowak', + 'hitmonlee', + 'hitmonchan', + 'lickitung', + 'koffing', + 'weezing', + 'rhyhorn', + 'rhydon', + 'chansey', + 'tangela', + 'kangaskhan', + 'horsea', + 'seadra', + 'goldeen', + 'seaking', + 'staryu', + 'starmie', + 'mr. mime', + 'scyther', + 'jynx', + 'electabuzz', + 'magmar', + 'pinsir', + 'tauros', + 'magikarp', + 'gyarados', + 'lapras', + 'ditto', + 'eevee', + 'vaporeon', + 'jolteon', + 'flareon', + 'porygon', + 'omanyte', + 'omastar', + 'kabuto', + 'kabutops', + 'aerodactyl', + 'snorlax', + 'articuno', + 'zapdos', + 'moltres', + 'dratini', + 'dragonair', + 'dragonite', + 'mewtwo', + 'mew', +] as const + +export type PokemonName = typeof POKEMON_NAMES[number] + +export interface Pokemon { + name: string +} + +export const pokemonEvolutions = { + bulbasaur: 'venusaur', + ivysaur: 'venusaur', + charmander: 'charizard', + pidgey: 'pidgeot', + rattata: 'raticate', + tentacool: 'tentacruel', + magikarp: 'gyarados', + weepinbell: 'victreebel', + staryu: 'starmie', + koffing: 'weezing', + exeggcute: 'exeggutor', + goldeen: 'seaking', + gastly: 'haunter', + wartortle: 'blastoise', + squirtle: 'blastoise', + metapod: 'butterfree', + pikachu: 'raichu', + sandshrew: 'sandslash' +} as const diff --git a/examples/query/react/suspense/src/react-app-env.d.ts b/examples/query/react/suspense/src/react-app-env.d.ts new file mode 100644 index 0000000000..649313d630 --- /dev/null +++ b/examples/query/react/suspense/src/react-app-env.d.ts @@ -0,0 +1,7 @@ +/// + +export declare global { + interface Window { + fetchFnErrorRate: number + } +} diff --git a/examples/query/react/suspense/src/services/pokemon.ts b/examples/query/react/suspense/src/services/pokemon.ts new file mode 100644 index 0000000000..6d7f5f49cf --- /dev/null +++ b/examples/query/react/suspense/src/services/pokemon.ts @@ -0,0 +1,31 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import type { PokemonName } from '../pokemon.data' + +export const pokemonApi = createApi({ + reducerPath: 'pokemonApi', + baseQuery: fetchBaseQuery({ + baseUrl: 'https://pokeapi.co/api/v2/', + fetchFn( + ...args: Parameters + ): ReturnType { + const errorRate = Math.max(Number(window.fetchFnErrorRate ?? 0), 0) + + if (Number.isFinite(errorRate) && Math.random() <= errorRate) { + return Promise.reject( + new Error(`fetch errorRate ${window.fetchFnErrorRate}`) + ) + } + + return fetch(...args) + }, + }), + endpoints: (builder) => ({ + getPokemonByName: builder.query, PokemonName>({ + query: (name: PokemonName) => `pokemon/${name}`, + }), + }), +}) + +const { useGetPokemonByNameQuery } = pokemonApi + +export { useGetPokemonByNameQuery } diff --git a/examples/query/react/suspense/src/store.ts b/examples/query/react/suspense/src/store.ts new file mode 100644 index 0000000000..8168bb91bd --- /dev/null +++ b/examples/query/react/suspense/src/store.ts @@ -0,0 +1,11 @@ +import { configureStore } from '@reduxjs/toolkit' +import { pokemonApi } from './services/pokemon' + +export const store = configureStore({ + reducer: { + [pokemonApi.reducerPath]: pokemonApi.reducer, + }, + // adding the api middleware enables caching, invalidation, polling and other features of `rtk-query` + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(pokemonApi.middleware), +}) diff --git a/examples/query/react/suspense/src/styles.css b/examples/query/react/suspense/src/styles.css new file mode 100644 index 0000000000..618327aac8 --- /dev/null +++ b/examples/query/react/suspense/src/styles.css @@ -0,0 +1,96 @@ +.App { + font-family: sans-serif; + text-align: center; +} + +.pokemon-article { + display: flex; + align-items: center; + width: 100%; + flex-flow: column nowrap; +} + +article { + padding: 8px; +} + +h3 { + margin: 0.2rem 0; +} + +.pokemon-list { + display: grid; + grid-template-columns: repeat(auto-fill, 180px); + grid-gap: 20px; + width: 100%; +} + +.select-pokemon-form { + padding: 8px 0; +} + +.global-controls { + padding: 8px 0; +} + +.pokemon-card { + display: flex; + flex-flow: column nowrap; + align-items: center; + justify-content: space-evenly; + min-height: 280px; + color: #202020; + border: 2px solid currentColor; + font-size: 1rem; + border-radius: 8px; + padding: 0 4px; +} + +.pokemon-card h3 { + justify-self: flex-start; +} + +.pokemon-card__pic { + width: 80px; + height: 80px; +} + +.pokemon-card--placeholder { + background: #e0e0e0; + min-height: 280px; +} + +.alert--danger { + color: #842029; + border-color: #f5c2c7; + background: #f8d7da; +} + +.alert--info { + color: #084298; + background-color: #cfe2ff; + border-color: #b6d4fe; +} + +.alert--success { + color: #0f5132; + background-color: #d1e7dd; + border-color: #badbcc; +} + +.btn { + color: inherit; + background: transparent; + border: 2px solid currentColor; + padding: 0 8px; + min-width: 44px; + min-height: 2rem; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn:active:not(:disabled) { + transform: translateY(2px); +} diff --git a/examples/query/react/suspense/tsconfig.json b/examples/query/react/suspense/tsconfig.json new file mode 100644 index 0000000000..9c55775e40 --- /dev/null +++ b/examples/query/react/suspense/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": ["./src/**/*"], + "compilerOptions": { + "strict": true, + "alwaysStrict": true, + "esModuleInterop": true, + "lib": ["dom", "es2015"], + "jsx": "react-jsx", + "target": "es5", + "allowJs": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + } +} diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 4602b35aa4..9b6b619f35 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -52,6 +52,8 @@ import { useStableQueryArgs } from './useSerializedStableValue' import type { UninitializedValue } from './constants' import { UNINITIALIZED_VALUE } from './constants' import { useShallowStableValue } from './useShallowStableValue' +import { SuspenseQueryError } from './exceptions' +import type { Suspendable } from './suspense-utils' // Copy-pasted from React-Redux export const useIsomorphicLayoutEffect = @@ -77,6 +79,10 @@ export interface MutationHooks< useMutation: UseMutation } +type IdleState = Arg extends SkipToken + ? { isSkipped: true } + : { isSkipped: boolean } + /** * A React hook that automatically triggers fetches of data from an endpoint, 'subscribes' the component to the cached data, and reads the request status and cached data from the Redux store. The component will re-render as the loading status changes and the data becomes available. * @@ -93,11 +99,15 @@ export interface MutationHooks< * - Re-renders as the request status changes and data becomes available */ export type UseQuery> = < - R extends Record = UseQueryStateDefaultResult + R extends Record = UseQueryStateDefaultResult, + Arg extends QueryArgFrom | SkipToken = QueryArgFrom | SkipToken >( arg: QueryArgFrom | SkipToken, options?: UseQuerySubscriptionOptions & UseQueryStateOptions -) => UseQueryStateResult & ReturnType> +) => UseQueryStateResult & + ReturnType> & + Suspendable & + IdleState interface UseQuerySubscriptionOptions extends SubscriptionOptions { /** @@ -502,6 +512,87 @@ type GenericPrefetchThunk = ( options: PrefetchOptions ) => ThunkAction +/** + * @internal + */ +type CreateSuspendableQueryOptions< + Definitions extends EndpointDefinitions, + Key extends keyof Definitions +> = { + name: Key + isSkipped: boolean + args: any + api: Api + prefetch: (arg: any, options?: PrefetchOptions | undefined) => void + queryStateResults: UseQueryStateResult< + QueryDefinitionOf, + UseQueryStateDefaultResult> + > +} + +type QueryDefinitionOf< + Key extends keyof Definitions, + Definitions extends EndpointDefinitions +> = Definitions[Key] extends QueryDefinition + ? QueryDefinition + : never + +const createSuspendablePromise = < + Definitions extends EndpointDefinitions, + Key extends QueryKeys +>({ + isSkipped, + args, + prefetch, + api, + name, + queryStateResults, +}: CreateSuspendableQueryOptions< + Definitions, + Key +>): Suspendable['getSuspendablePromise'] => { + const retry = () => { + prefetch(args, { + force: true, + }) + } + + return (): Promise | undefined => { + // We do not suspend if a query is skipped: + // @see https://github.com/vercel/swr/pull/357#issuecomment-627089889 + if (!isSkipped && typeof queryStateResults.data === 'undefined') { + if (queryStateResults.isLoading) { + let pendingPromise = api.util.getRunningOperationPromise(name, args) + + if (!pendingPromise) { + prefetch(args, { + force: true, + }) + + pendingPromise = api.util.getRunningOperationPromise( + name as any, + args + ) + + if (!pendingPromise) { + throw new Error( + `[rtk-query][react]: invalid state error, expected getRunningOperationPromise(${name}, ${queryStateResults.requestId}) to be defined` + ) + } + } + return pendingPromise + } else if (queryStateResults.isError && !queryStateResults.isFetching) { + throw new SuspenseQueryError( + queryStateResults.error, + queryStateResults.endpointName + '', + retry + ) + } + } + return undefined + } +} + /** * * @param opts.api - An API with defined endpoints to create hooks for @@ -848,17 +939,46 @@ export function buildHooks({ ) }, useQuery(arg, options) { + const isSkipped: boolean = arg === skipToken || !!options?.skip const querySubscriptionResults = useQuerySubscription(arg, options) const queryStateResults = useQueryState(arg, { - selectFromResult: - arg === skipToken || options?.skip - ? undefined - : noPendingQueryStateSelector, + selectFromResult: isSkipped ? undefined : noPendingQueryStateSelector, ...options, }) + + const stableArg = useStableQueryArgs( + options?.skip ? skipToken : arg, + serializeQueryArgs, + context.endpointDefinitions[name], + name + ) + + const prefetch = usePrefetch(name as any) + return useMemo( - () => ({ ...queryStateResults, ...querySubscriptionResults }), - [queryStateResults, querySubscriptionResults] + () => ({ + ...queryStateResults, + ...querySubscriptionResults, + isSkipped, + /** + * This implementation will also trigger a fetch using prefetch, if possible, during the initial render. + */ + getSuspendablePromise: createSuspendablePromise({ + args: stableArg, + isSkipped, + prefetch, + api: api, + queryStateResults, + name: name as unknown as QueryKeys, + }), + }), + [ + queryStateResults, + querySubscriptionResults, + prefetch, + isSkipped, + stableArg, + ] ) }, } diff --git a/packages/toolkit/src/query/react/exceptions.ts b/packages/toolkit/src/query/react/exceptions.ts new file mode 100644 index 0000000000..6bb1ceab18 --- /dev/null +++ b/packages/toolkit/src/query/react/exceptions.ts @@ -0,0 +1,29 @@ +const computeErrorMessage = (reason: any, queryKey: string) => { + let message = `rtk-query suspense error ${queryKey}: ` + + if (reason instanceof Error) { + message += reason + } else if (typeof reason === 'object' && reason !== null) { + ;[reason?.status, reason?.code, reason?.error].forEach((value) => { + if (value) { + message += ` ${value}` + } + }) + } else { + message += reason + } + + return message +} + +export class SuspenseQueryError extends Error { + constructor( + public reason: unknown, + public endpointName: string, + public retryQuery: () => void + ) { + super(computeErrorMessage(reason, endpointName)) + this.reason = reason + this.name = 'SuspenseQueryError' + } +} diff --git a/packages/toolkit/src/query/react/index.ts b/packages/toolkit/src/query/react/index.ts index aaaf881c63..8002d82f7b 100644 --- a/packages/toolkit/src/query/react/index.ts +++ b/packages/toolkit/src/query/react/index.ts @@ -12,8 +12,19 @@ import type { BaseQueryFn } from '@reduxjs/toolkit/dist/query/baseQueryTypes' import type { QueryKeys } from '@reduxjs/toolkit/dist/query/core/apiState' import type { PrefetchOptions } from '@reduxjs/toolkit/dist/query/core/module' - +export { SuspenseQueryError } from './exceptions' export * from '@reduxjs/toolkit/query' +export { useSuspendAll } from './suspense-utils' +export type { + Suspendable, + Resource, + SuspendableResource, + UseSuspendAllOutput, + ResolvedSuspendableResource, + IdleResource, + IdleSuspendableResource, +} from './suspense-utils' + export { ApiProvider } from './ApiProvider' const createApi = /* @__PURE__ */ buildCreateApi( diff --git a/packages/toolkit/src/query/react/suspense-utils.ts b/packages/toolkit/src/query/react/suspense-utils.ts new file mode 100644 index 0000000000..935792e0a9 --- /dev/null +++ b/packages/toolkit/src/query/react/suspense-utils.ts @@ -0,0 +1,66 @@ +export interface Resource { + data?: Data | undefined + isLoading?: boolean +} + +export interface IdleResource extends Resource { + isSkipped: true +} + +export interface Suspendable { + getSuspendablePromise(): Promise | undefined +} + +export type SuspendableResource = Resource & Suspendable + +export type IdleSuspendableResource = IdleResource & Suspendable + +export type ResolvedSuspendableResource = T extends SuspendableResource< + infer Data +> + ? Omit & { + data: Exclude + isLoading: false + } + : never + +export type UseSuspendAllOutput = { + [K in keyof Sus]: Sus[K] extends IdleSuspendableResource + ? Sus[K] + : Sus[K] extends SuspendableResource + ? ResolvedSuspendableResource + : Sus[K] extends Suspendable + ? Sus[K] + : never +} + +function isPromiseLike(val: unknown): val is PromiseLike { + return ( + !!val && typeof val === 'object' && typeof (val as any).then === 'function' + ) +} + +function getSuspendable(suspendable: Suspendable) { + return suspendable.getSuspendablePromise() +} + +export function useSuspendAll< + G extends SuspendableResource, + T extends SuspendableResource[] +>( + ...suspendables: readonly [G, ...T] +): UseSuspendAllOutput { + if (!suspendables.length) { + throw new TypeError('useSuspendAll: requires one or more arguments') + } + + let promises = suspendables + .map(getSuspendable) + .filter(isPromiseLike) as Promise[] + + if (promises.length) { + throw Promise.all(promises) + } + + return suspendables as UseSuspendAllOutput +} diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index 0adf60eb10..a3a3b49c0a 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -4,6 +4,7 @@ import { fetchBaseQuery, QueryStatus, skipToken, + useSuspendAll, } from '@reduxjs/toolkit/query/react' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -22,6 +23,7 @@ import type { AnyAction } from 'redux' import type { SubscriptionOptions } from '@reduxjs/toolkit/dist/query/core/apiState' import type { SerializedError } from '@reduxjs/toolkit' import { renderHook } from '@testing-library/react-hooks' +import { Suspense } from 'react' // Just setup a temporary in-memory counter for tests that `getIncrementedAmount`. // This can be used to test how many renders happen due to data changes or @@ -78,6 +80,39 @@ const api = createApi({ }), }) +class ErrorBoundary extends React.Component< + { + fallback?: + | React.ReactNode + | ((error: Error, reset: () => void) => React.ReactNode) + children: React.ReactNode + }, + { error: Error | null } +> { + state = { error: null } as { error: Error | null } + + componentDidCatch(error: Error) { + this.setState({ error }) + } + + reset = () => { + this.setState({ error: null }) + } + + render(): React.ReactNode { + const { error } = this.state + const { fallback, children } = this.props + + if (error) { + return typeof fallback === 'function' + ? fallback(error, this.reset) + : fallback + } + + return children + } +} + const storeRef = setupApiStore(api, { actions(state: AnyAction[] = [], action: AnyAction) { return [...state, action] @@ -1904,7 +1939,9 @@ describe('hooks with createApi defaults set', () => { */ test('useQuery with selectFromResult with all flags destructured rerenders like the default useQuery behavior', async () => { function Posts() { - const { data: posts } = api.endpoints.getPosts.useQuery() + const { data: posts } = api.endpoints.getPosts.useQuery(undefined, { + skip: true, + }) const [addPost] = api.endpoints.addPost.useMutation() getRenderCount = useRenderCounter() return ( @@ -1965,7 +2002,7 @@ describe('hooks with createApi defaults set', () => { await waitFor(() => expect(getRenderCount()).toBe(5)) fireEvent.click(addBtn) fireEvent.click(addBtn) - await waitFor(() => expect(getRenderCount()).toBe(9)) + await waitFor(() => expect(getRenderCount()).toBe(7)) }) test('useQuery with selectFromResult option serves a deeply memoized value and does not rerender unnecessarily', async () => { @@ -2302,6 +2339,7 @@ describe('hooks with createApi defaults set', () => { describe('skip behaviour', () => { const uninitialized = { + getSuspendablePromise: expect.any(Function), status: QueryStatus.uninitialized, refetch: expect.any(Function), data: undefined, @@ -2312,6 +2350,11 @@ describe('skip behaviour', () => { isUninitialized: true, } + const skipped = { + ...uninitialized, + isSkipped: true, + } + function subscriptionCount(key: string) { return Object.keys(storeRef.store.getState().api.subscriptions[key] || {}) .length @@ -2327,7 +2370,7 @@ describe('skip behaviour', () => { } ) - expect(result.current).toEqual(uninitialized) + expect(result.current).toEqual(skipped) expect(subscriptionCount('getUser(1)')).toBe(0) rerender([1]) @@ -2335,7 +2378,7 @@ describe('skip behaviour', () => { expect(subscriptionCount('getUser(1)')).toBe(1) rerender([1, { skip: true }]) - expect(result.current).toEqual(uninitialized) + expect(result.current).toEqual(skipped) expect(subscriptionCount('getUser(1)')).toBe(0) }) @@ -2349,7 +2392,7 @@ describe('skip behaviour', () => { } ) - expect(result.current).toEqual(uninitialized) + expect(result.current).toEqual(skipped) expect(subscriptionCount('getUser(1)')).toBe(0) // also no subscription on `getUser(skipToken)` or similar: expect(storeRef.store.getState().api.subscriptions).toEqual({}) @@ -2360,7 +2403,121 @@ describe('skip behaviour', () => { expect(storeRef.store.getState().api.subscriptions).not.toEqual({}) rerender([skipToken]) - expect(result.current).toEqual(uninitialized) + expect(result.current).toEqual(skipped) expect(subscriptionCount('getUser(1)')).toBe(0) }) }) + +describe('suspense', () => { + type UserProps = { + userId: number + skipFetch?: boolean + } + + function User({ userId, skipFetch = false }: UserProps) { + const [{ data, isFetching, refetch, isLoading }] = useSuspendAll( + api.useGetUserQuery(skipFetch ? skipToken : userId) + ) + + return ( +
+
{String(isLoading)}
+
{String(isFetching)}
+
{String(data?.name)}
+ +
+ ) + } + + describe('useSuspendAll', () => { + const consoleErrorSpy = jest.spyOn(console, 'error') + + function ThrowsBecauseNoArgs() { + const tuple = [ + { + getSuspendablePromise() { + return undefined + }, + }, + ] as const + + ;(tuple as unknown as any[]).splice(0, tuple.length) + + useSuspendAll(...tuple) + return
+ } + + test('throws error if no arg is provided', () => { + consoleErrorSpy.mockImplementationOnce(() => {}) + const { container } = render( + ( +
{String(error)}
+ )} + > + +
+ ) + + expect( + container.querySelector('[data-testid="error-fallback"]') + ).toBeDefined() + }) + + test('suspends queries only if isLoading is true', async () => { + render( + fallback}> + + , + { wrapper: storeRef.wrapper } + ) + + expect(screen.getByTestId('fallback').textContent).toBe('fallback') + + await waitFor(() => + expect(screen.getByTestId('isFetching').textContent).toBe('false') + ) + + expect(screen.getByTestId('name').textContent).toBe('Timmy') + + fireEvent.click(screen.getByTestId('refetch')) + + await waitFor(() => { + expect(screen.getByTestId('isFetching').textContent).toBe('true') + }) + + await waitFor(() => { + expect(screen.getByTestId('isFetching').textContent).toBe('false') + }) + + expect(screen.getByTestId('name').textContent).toBe('Timmy') + }) + + test('does not suspend while a query is skipped', async () => { + let { rerender } = render( + fallback}> + + , + { wrapper: storeRef.wrapper } + ) + + expect(screen.getByTestId('isFetching').textContent).toBe('false') + + rerender( + fallback}> + + + ) + + expect(screen.getByTestId('fallback')?.textContent).toBe('fallback') + + await waitFor(() => + expect(screen.getByTestId('isFetching').textContent).toBe('false') + ) + + expect(screen.getByTestId('name').textContent).toBe('Timmy') + }) + }) +}) diff --git a/packages/toolkit/src/query/tests/unionTypes.test.ts b/packages/toolkit/src/query/tests/unionTypes.test.ts index ee3d852235..efe49e61d4 100644 --- a/packages/toolkit/src/query/tests/unionTypes.test.ts +++ b/packages/toolkit/src/query/tests/unionTypes.test.ts @@ -353,7 +353,11 @@ describe.skip('TS only tests', () => { } ) - const { refetch: _omit, ...useQueryResultWithoutMethods } = useQueryResult + const { + refetch: _omit, + getSuspendablePromise, + ...useQueryResultWithoutMethods + } = useQueryResult expectExactType(useQueryStateResult)(useQueryResultWithoutMethods) expectExactType(useQueryStateWithSelectFromResult)( // @ts-expect-error @@ -412,6 +416,7 @@ describe.skip('TS only tests', () => { }, }) expectExactType({ + getSuspendablePromise: expect.any(Function), data: '' as string | number, isUninitialized: false, isLoading: true, diff --git a/yarn.lock b/yarn.lock index de5997b9cd..fcb909d94c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4072,6 +4072,26 @@ __metadata: languageName: unknown linkType: soft +"@examples-query-react/suspense@workspace:examples/query/react/suspense": + version: 0.0.0-use.local + resolution: "@examples-query-react/suspense@workspace:examples/query/react/suspense" + dependencies: + "@reduxjs/toolkit": ^1.8.0 + "@types/react": 17.0.0 + "@types/react-dom": 17.0.0 + "@types/react-redux": 7.1.9 + "@types/use-sync-external-store": ^0.0.3 + clsx: ^1.1.1 + react: 17.0.0 + react-dom: 17.0.0 + react-error-boundary: 3.1.4 + react-redux: 7.2.2 + react-scripts: 4.0.2 + typescript: ~4.2.4 + use-sync-external-store: ^1.0.0 + languageName: unknown + linkType: soft + "@examples-query-react/with-apiprovider@workspace:examples/query/react/with-apiprovider": version: 0.0.0-use.local resolution: "@examples-query-react/with-apiprovider@workspace:examples/query/react/with-apiprovider" @@ -7044,6 +7064,13 @@ __metadata: languageName: node linkType: hard +"@types/use-sync-external-store@npm:^0.0.3": + version: 0.0.3 + resolution: "@types/use-sync-external-store@npm:0.0.3" + checksum: 161ddb8eec5dbe7279ac971531217e9af6b99f7783213566d2b502e2e2378ea19cf5e5ea4595039d730aa79d3d35c6567d48599f69773a02ffcff1776ec2a44e + languageName: node + linkType: hard + "@types/uuid@npm:^8.3.0": version: 8.3.0 resolution: "@types/uuid@npm:8.3.0" @@ -22197,6 +22224,17 @@ fsevents@^1.2.7: languageName: node linkType: hard +"react-error-boundary@npm:3.1.4": + version: 3.1.4 + resolution: "react-error-boundary@npm:3.1.4" + dependencies: + "@babel/runtime": ^7.12.5 + peerDependencies: + react: ">=16.13.1" + checksum: f36270a5d775a25c8920f854c0d91649ceea417b15b5bc51e270a959b0476647bb79abb4da3be7dd9a4597b029214e8fe43ea914a7f16fa7543c91f784977f1b + languageName: node + linkType: hard + "react-error-boundary@npm:^3.1.0": version: 3.1.3 resolution: "react-error-boundary@npm:3.1.3" @@ -26876,6 +26914,15 @@ resolve@~1.19.0: languageName: node linkType: hard +"use-sync-external-store@npm:^1.0.0": + version: 1.0.0 + resolution: "use-sync-external-store@npm:1.0.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0-rc + checksum: f3df10af62625169d6a8cd8d4f38942dc6817aa27398fa7f474421fd574b0c4c83679b9d15f983f482c396ee1183416eb146814c81f44241e1480acd701ef018 + languageName: node + linkType: hard + "use@npm:^3.1.0": version: 3.1.1 resolution: "use@npm:3.1.1" From 8d957862d1986287c82fef601d1d9b98c8beeab3 Mon Sep 17 00:00:00 2001 From: FaberVitale Date: Mon, 18 Apr 2022 21:21:29 +0200 Subject: [PATCH 2/7] fix: apply CR requested changes #2245 Feedback: - https://github.com/reduxjs/redux-toolkit/pull/2245#discussion_r851667127 - https://github.com/reduxjs/redux-toolkit/pull/2245#discussion_r851667688 - https://github.com/reduxjs/redux-toolkit/pull/2245#discussion_r851668256 - https://github.com/reduxjs/redux-toolkit/pull/2245#discussion_r851668497 - https://github.com/reduxjs/redux-toolkit/pull/2245#discussion_r851667424 --- .../toolkit/src/query/react/buildHooks.ts | 70 ++++++++++++------- .../toolkit/src/query/react/exceptions.ts | 13 ++-- .../toolkit/src/query/react/suspense-utils.ts | 23 ++---- .../src/query/tests/buildHooks.test.tsx | 16 ++--- .../src/query/tests/unionTypes.test.ts | 8 ++- .../toolkit/src/query/utils/isPromiseLike.ts | 9 +++ 6 files changed, 83 insertions(+), 56 deletions(-) create mode 100644 packages/toolkit/src/query/utils/isPromiseLike.ts diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 9b6b619f35..7f05f2efb8 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -79,9 +79,7 @@ export interface MutationHooks< useMutation: UseMutation } -type IdleState = Arg extends SkipToken - ? { isSkipped: true } - : { isSkipped: boolean } +type SkippedState = { isSkipped: Skipped } /** * A React hook that automatically triggers fetches of data from an endpoint, 'subscribes' the component to the cached data, and reads the request status and cached data from the Redux store. The component will re-render as the loading status changes and the data becomes available. @@ -98,16 +96,43 @@ type IdleState = Arg extends SkipToken * - Returns the latest request status and cached data from the Redux store * - Re-renders as the request status changes and data becomes available */ -export type UseQuery> = < - R extends Record = UseQueryStateDefaultResult, - Arg extends QueryArgFrom | SkipToken = QueryArgFrom | SkipToken ->( - arg: QueryArgFrom | SkipToken, - options?: UseQuerySubscriptionOptions & UseQueryStateOptions -) => UseQueryStateResult & - ReturnType> & - Suspendable & - IdleState +export interface UseQuery> { + // arg provided + = UseQueryStateDefaultResult>( + arg: QueryArgFrom, + options?: UseQuerySubscriptionOptions & UseQueryStateOptions + ): UseQueryStateResult & + ReturnType> & + Suspendable & + SkippedState + // skipped query + = UseQueryStateDefaultResult>( + arg: SkipToken, + options?: UseQuerySubscriptionOptions & UseQueryStateOptions + ): UseQueryStateResult & + ReturnType> & + Suspendable & + SkippedState + = UseQueryStateDefaultResult>( + arg: QueryArgFrom | SkipToken, + options?: UseQuerySubscriptionOptions & UseQueryStateOptions + ): UseQueryStateResult & + ReturnType> & + Suspendable & + SkippedState +} + +/** + * @internal + */ +type UseQueryParams> = Parameters< + UseQuery +> + +/** + * @internal + */ +type AnyQueryDefinition = QueryDefinition interface UseQuerySubscriptionOptions extends SubscriptionOptions { /** @@ -551,7 +576,7 @@ const createSuspendablePromise = < Definitions, Key >): Suspendable['getSuspendablePromise'] => { - const retry = () => { + const fetchOnce = () => { prefetch(args, { force: true, }) @@ -565,27 +590,19 @@ const createSuspendablePromise = < let pendingPromise = api.util.getRunningOperationPromise(name, args) if (!pendingPromise) { - prefetch(args, { - force: true, - }) + fetchOnce() pendingPromise = api.util.getRunningOperationPromise( name as any, args ) - - if (!pendingPromise) { - throw new Error( - `[rtk-query][react]: invalid state error, expected getRunningOperationPromise(${name}, ${queryStateResults.requestId}) to be defined` - ) - } } return pendingPromise } else if (queryStateResults.isError && !queryStateResults.isFetching) { throw new SuspenseQueryError( queryStateResults.error, queryStateResults.endpointName + '', - retry + fetchOnce ) } } @@ -938,7 +955,10 @@ export function buildHooks({ [trigger, queryStateResults, info] ) }, - useQuery(arg, options) { + useQuery( + arg: UseQueryParams['0'], + options: UseQueryParams['1'] + ) { const isSkipped: boolean = arg === skipToken || !!options?.skip const querySubscriptionResults = useQuerySubscription(arg, options) const queryStateResults = useQueryState(arg, { diff --git a/packages/toolkit/src/query/react/exceptions.ts b/packages/toolkit/src/query/react/exceptions.ts index 6bb1ceab18..f3320f449d 100644 --- a/packages/toolkit/src/query/react/exceptions.ts +++ b/packages/toolkit/src/query/react/exceptions.ts @@ -4,11 +4,13 @@ const computeErrorMessage = (reason: any, queryKey: string) => { if (reason instanceof Error) { message += reason } else if (typeof reason === 'object' && reason !== null) { - ;[reason?.status, reason?.code, reason?.error].forEach((value) => { - if (value) { - message += ` ${value}` + const relevantProperties = [reason?.status, reason?.code, reason?.error] + + for (const property of relevantProperties) { + if (property) { + message += ` ${property}` } - }) + } } else { message += reason } @@ -25,5 +27,8 @@ export class SuspenseQueryError extends Error { super(computeErrorMessage(reason, endpointName)) this.reason = reason this.name = 'SuspenseQueryError' + + // https://www.typescriptlang.org/docs/handbook/2/classes.html#inheriting-built-in-types + Object.setPrototypeOf(this, SuspenseQueryError.prototype) } } diff --git a/packages/toolkit/src/query/react/suspense-utils.ts b/packages/toolkit/src/query/react/suspense-utils.ts index 935792e0a9..f43296857a 100644 --- a/packages/toolkit/src/query/react/suspense-utils.ts +++ b/packages/toolkit/src/query/react/suspense-utils.ts @@ -1,3 +1,5 @@ +import { isPromiseLike } from '../utils/isPromiseLike' + export interface Resource { data?: Data | undefined isLoading?: boolean @@ -34,26 +36,13 @@ export type UseSuspendAllOutput = { : never } -function isPromiseLike(val: unknown): val is PromiseLike { - return ( - !!val && typeof val === 'object' && typeof (val as any).then === 'function' - ) -} - -function getSuspendable(suspendable: Suspendable) { +const getSuspendable = (suspendable: Suspendable) => { return suspendable.getSuspendablePromise() } export function useSuspendAll< - G extends SuspendableResource, - T extends SuspendableResource[] ->( - ...suspendables: readonly [G, ...T] -): UseSuspendAllOutput { - if (!suspendables.length) { - throw new TypeError('useSuspendAll: requires one or more arguments') - } - + T extends ReadonlyArray> +>(...suspendables: T): UseSuspendAllOutput { let promises = suspendables .map(getSuspendable) .filter(isPromiseLike) as Promise[] @@ -62,5 +51,5 @@ export function useSuspendAll< throw Promise.all(promises) } - return suspendables as UseSuspendAllOutput + return suspendables as UseSuspendAllOutput } diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index a3a3b49c0a..bc1c9059ce 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -1542,7 +1542,7 @@ describe('hooks tests', () => { baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), tagTypes: ['User'], endpoints: (build) => ({ - checkSession: build.query({ + checkSession: build.query({ query: () => '/me', providesTags: ['User'], }), @@ -1837,7 +1837,7 @@ describe('hooks with createApi defaults set', () => { baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com/' }), tagTypes: ['Posts'], endpoints: (build) => ({ - getPosts: build.query({ + getPosts: build.query({ query: () => ({ url: 'posts' }), providesTags: (result) => result ? result.map(({ id }) => ({ type: 'Posts', id })) : [], @@ -2134,9 +2134,9 @@ describe('hooks with createApi defaults set', () => { test('useQuery with selectFromResult option has a type error if the result is not an object', async () => { function SelectedPost() { + // @ts-expect-error const _res1 = api.endpoints.getPosts.useQuery(undefined, { // selectFromResult must always return an object - // @ts-expect-error selectFromResult: ({ data }) => data?.length ?? 0, }) @@ -2434,18 +2434,16 @@ describe('suspense', () => { describe('useSuspendAll', () => { const consoleErrorSpy = jest.spyOn(console, 'error') - function ThrowsBecauseNoArgs() { + function ExceptionCausedByAnInvalidArg() { const tuple = [ { - getSuspendablePromise() { + invalid() { return undefined }, }, ] as const - ;(tuple as unknown as any[]).splice(0, tuple.length) - - useSuspendAll(...tuple) + useSuspendAll(...(tuple as any)) return
} @@ -2457,7 +2455,7 @@ describe('suspense', () => {
{String(error)}
)} > - + ) diff --git a/packages/toolkit/src/query/tests/unionTypes.test.ts b/packages/toolkit/src/query/tests/unionTypes.test.ts index efe49e61d4..748ed3a8c7 100644 --- a/packages/toolkit/src/query/tests/unionTypes.test.ts +++ b/packages/toolkit/src/query/tests/unionTypes.test.ts @@ -358,7 +358,10 @@ describe.skip('TS only tests', () => { getSuspendablePromise, ...useQueryResultWithoutMethods } = useQueryResult - expectExactType(useQueryStateResult)(useQueryResultWithoutMethods) + expectExactType(useQueryStateResult)( + // @ts-expect-error + useQueryResultWithoutMethods + ) expectExactType(useQueryStateWithSelectFromResult)( // @ts-expect-error useQueryResultWithoutMethods @@ -411,10 +414,12 @@ describe.skip('TS only tests', () => { isFetching, isError, isSuccess, + isSkipped: false, isUninitialized, } }, }) + expectExactType({ getSuspendablePromise: expect.any(Function), data: '' as string | number, @@ -423,6 +428,7 @@ describe.skip('TS only tests', () => { isFetching: true, isSuccess: false, isError: false, + isSkipped: false, refetch: () => {}, })(result) }) diff --git a/packages/toolkit/src/query/utils/isPromiseLike.ts b/packages/toolkit/src/query/utils/isPromiseLike.ts new file mode 100644 index 0000000000..f09f0877c9 --- /dev/null +++ b/packages/toolkit/src/query/utils/isPromiseLike.ts @@ -0,0 +1,9 @@ +/** + * Thenable type guard. + * @internal + */ +export const isPromiseLike = (val: unknown): val is PromiseLike => { + return ( + !!val && typeof val === 'object' && typeof (val as any).then === 'function' + ) +} From 1a890160fffa4ac284e2760e8f8155034be249c3 Mon Sep 17 00:00:00 2001 From: FaberVitale Date: Mon, 18 Apr 2022 22:33:02 +0200 Subject: [PATCH 3/7] chore: improve suspense demo UI --- examples/query/react/suspense/src/App.tsx | 3 +- .../suspense/src/PokemonParallelQueries.tsx | 6 +- .../suspense/src/PokemonSingleQueries.tsx | 5 +- .../suspense/src/PokemonWithEvolution.tsx | 3 +- examples/query/react/suspense/src/styles.css | 99 ++++++++++++++++--- 5 files changed, 92 insertions(+), 24 deletions(-) diff --git a/examples/query/react/suspense/src/App.tsx b/examples/query/react/suspense/src/App.tsx index e788dbdb39..0f76951756 100644 --- a/examples/query/react/suspense/src/App.tsx +++ b/examples/query/react/suspense/src/App.tsx @@ -18,7 +18,8 @@ export default function App() { return (
-
+
+

Suspense