From 00251fb5e7f75faa9b87b9d361064183f8330c88 Mon Sep 17 00:00:00 2001 From: FaberVitale Date: Sun, 20 Mar 2022 19:05:03 +0100 Subject: [PATCH 1/4] feat(rtk-query/react): add useUnstable_SuspenseQuery & suspense example -- Description Adds useUnstable_SuspenseQuery, an alternative to `useQuery` that can be used to render-as-you-fetch. -- References - https://reactjs.org/docs/concurrent-mode-suspense.html - https://github.com/facebook/react/blob/12adaffef7105e2714f82651ea51936c563fe15c/packages/react/src/ReactLazy.js -- Alternatives - swr: https://swr.vercel.app/docs/suspense - react-query: https://react-query.tanstack.com/guides/suspense --- examples/query/react/suspense/.env | 1 + examples/query/react/suspense/package.json | 40 +++++ .../query/react/suspense/public/index.html | 43 +++++ .../query/react/suspense/public/manifest.json | 8 + examples/query/react/suspense/src/App.tsx | 68 ++++++++ examples/query/react/suspense/src/Pokemon.tsx | 73 +++++++++ .../react/suspense/src/SuspendedPokemon.tsx | 59 +++++++ examples/query/react/suspense/src/index.tsx | 13 ++ .../query/react/suspense/src/pokemon.data.ts | 155 ++++++++++++++++++ .../react/suspense/src/react-app-env.d.ts | 1 + .../react/suspense/src/services/pokemon.ts | 12 ++ examples/query/react/suspense/src/store.ts | 11 ++ examples/query/react/suspense/src/styles.css | 10 ++ examples/query/react/suspense/tsconfig.json | 25 +++ .../toolkit/src/query/react/buildHooks.ts | 69 ++++++++ packages/toolkit/src/query/react/module.ts | 2 + .../src/query/tests/buildHooks.test.tsx | 110 +++++++++++++ yarn.lock | 28 ++++ 18 files changed, 728 insertions(+) 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/SuspendedPokemon.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 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..e8c3be65f1 --- /dev/null +++ b/examples/query/react/suspense/package.json @@ -0,0 +1,40 @@ +{ + "name": "@examples-query-react/suspense", + "private": true, + "version": "1.0.0", + "description": "", + "keywords": [], + "main": "src/index.tsx", + "dependencies": { + "@reduxjs/toolkit": "^1.6.0-rc.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" + }, + "devDependencies": { + "@types/react": "17.0.0", + "@types/react-dom": "17.0.0", + "@types/react-redux": "7.1.9", + "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..42ae2d2dcb --- /dev/null +++ b/examples/query/react/suspense/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + React App + + + + +
+ + + + \ No newline at end of file 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..c42892918d --- /dev/null +++ b/examples/query/react/suspense/src/App.tsx @@ -0,0 +1,68 @@ +import * as React from 'react' +import { POKEMON_NAMES } from './pokemon.data' +import './styles.css' +import { SuspendedPokemon, SuspendedPokemonProps } from './SuspendedPokemon' + +const getRandomPokemonName = () => + POKEMON_NAMES[Math.floor(Math.random() * POKEMON_NAMES.length)] + +export default function App() { + const [pokemonConf, setPokemonConf] = React.useState( + [{ name: 'bulbasaur', suspendOnRefetch: false, throwOnIntialRender: false }] + ) + + return ( +
+
+
{ + evt.preventDefault() + + const formValues = new FormData(evt.currentTarget) + + setPokemonConf((prev) => [ + ...prev, + { + name: Boolean(formValues.get('addBulbasaur')) + ? 'bulbasaur' + : getRandomPokemonName(), + suspendOnRefetch: Boolean(formValues.get('suspendOnRefetch')), + throwOnIntialRender: Boolean( + formValues.get('throwOnIntialRender') + ), + }, + ]) + }} + > + + + + +
+
+
+ {pokemonConf.map((suspendedPokemonProps, index) => ( + + ))} +
+
+ ) +} diff --git a/examples/query/react/suspense/src/Pokemon.tsx b/examples/query/react/suspense/src/Pokemon.tsx new file mode 100644 index 0000000000..ca467dfbd7 --- /dev/null +++ b/examples/query/react/suspense/src/Pokemon.tsx @@ -0,0 +1,73 @@ +import * as React from 'react' +import { pokemonApi } 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 const Pokemon = ({ + name, + suspendOnRefetch = false, +}: { + name: PokemonName + suspendOnRefetch?: boolean +}) => { + const [pollingInterval, setPollingInterval] = React.useState( + getRandomIntervalValue() + ) + + const { data, isFetching, refetch } = + pokemonApi.endpoints.getPokemonByName.useUnstable_SuspenseQuery(name, { + pollingInterval, + suspendOnRefetch, + }) + + if (!data) { + return ( +
+

{name}

+

No data!

+
+ ) + } + + return ( +
+

{data.species.name}

+
+ {data.species.name} +
+
+ + +
+
+

suspendOnRefetch: {String(suspendOnRefetch)}

+ +
+
+ ) +} diff --git a/examples/query/react/suspense/src/SuspendedPokemon.tsx b/examples/query/react/suspense/src/SuspendedPokemon.tsx new file mode 100644 index 0000000000..1deb52c074 --- /dev/null +++ b/examples/query/react/suspense/src/SuspendedPokemon.tsx @@ -0,0 +1,59 @@ +import { Suspense, useState } from 'react' +import { Pokemon } from './Pokemon' +import { PokemonName } from './pokemon.data' +import { ErrorBoundary } from 'react-error-boundary' + +export interface SuspendedPokemonProps { + name: PokemonName + suspendOnRefetch: boolean + throwOnIntialRender: boolean +} + +function BuggyComponent({ errorCount, name }:Pick & { errorCount: number }) { + if(!errorCount) { + throw new Error('error while rendering:' + name) + } + + return <> +} + +export function SuspendedPokemon({ + name, + suspendOnRefetch, + throwOnIntialRender, +}: SuspendedPokemonProps) { + const [errorCount, setErrorCount] = useState(0) + + return ( +
+ setErrorCount((n) => n + 1)} + fallbackRender={({ resetErrorBoundary, error }) => { + return ( +
+

render {name} error

+

{String(error)}

+
+ +
+
+ ) + }} + > + {throwOnIntialRender && } + + suspense fallback
+ loading pokemon {name} +
+ } + > + + + + + ) +} diff --git a/examples/query/react/suspense/src/index.tsx b/examples/query/react/suspense/src/index.tsx new file mode 100644 index 0000000000..ccb5944c13 --- /dev/null +++ b/examples/query/react/suspense/src/index.tsx @@ -0,0 +1,13 @@ +import { render } from 'react-dom' +import { Provider } from 'react-redux' + +import App from './App' +import { store } from './store' + +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..1617ce9e50 --- /dev/null +++ b/examples/query/react/suspense/src/pokemon.data.ts @@ -0,0 +1,155 @@ +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] 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..6431bc5fc6 --- /dev/null +++ b/examples/query/react/suspense/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// 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..d933f34457 --- /dev/null +++ b/examples/query/react/suspense/src/services/pokemon.ts @@ -0,0 +1,12 @@ +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/' }), + endpoints: (builder) => ({ + getPokemonByName: builder.query({ + query: (name: PokemonName) => `pokemon/${name}`, + }), + }), +}) 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..47c4992334 --- /dev/null +++ b/examples/query/react/suspense/src/styles.css @@ -0,0 +1,10 @@ +.App { + font-family: sans-serif; + text-align: center; +} + +.pokemon-list { + display: grid; + grid-template-columns: repeat(auto-fill, 180px); + grid-gap: 20px; +} diff --git a/examples/query/react/suspense/tsconfig.json b/examples/query/react/suspense/tsconfig.json new file mode 100644 index 0000000000..d4eea2ea4b --- /dev/null +++ b/examples/query/react/suspense/tsconfig.json @@ -0,0 +1,25 @@ +{ + "include": [ + "./src/**/*" + ], + "compilerOptions": { + "strict": 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 + } +} \ No newline at end of file diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 15e2977937..6b14e96035 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -69,6 +69,7 @@ export interface QueryHooks< useQuerySubscription: UseQuerySubscription useLazyQuerySubscription: UseLazyQuerySubscription useQueryState: UseQueryState + useUnstable_SuspenseQuery: UseSuspenseQuery } export interface MutationHooks< @@ -99,6 +100,23 @@ export type UseQuery> = < options?: UseQuerySubscriptionOptions & UseQueryStateOptions ) => UseQueryStateResult & ReturnType> +export interface UseSuspenseOptions { + /** + * If set to `true` it will suspend the query on subsequent fetches, + * hence when `data !== undefined && isFetching` is truthy. + */ + suspendOnRefetch?: boolean +} + +export type UseSuspenseQuery> = < + R extends Record = UseQueryStateDefaultResult +>( + arg: QueryArgFrom | SkipToken, + options?: UseQuerySubscriptionOptions & + UseQueryStateOptions & + UseSuspenseOptions +) => Omit, 'isLoading' | 'status'> & ReturnType> + interface UseQuerySubscriptionOptions extends SubscriptionOptions { /** * Prevents a query from automatically running. @@ -502,6 +520,13 @@ type GenericPrefetchThunk = ( options: PrefetchOptions ) => ThunkAction +const isSkippedQuery = ( + arg: unknown, + opt?: Opt | undefined +): boolean => { + return arg === skipToken || !!opt?.skip +} + /** * * @param opts.api - An API with defined endpoints to create hooks for @@ -861,6 +886,50 @@ export function buildHooks({ [queryStateResults, querySubscriptionResults] ) }, + useUnstable_SuspenseQuery(arg, options) { + const querySubscriptionResults = useQuerySubscription(arg, options) + const queryStateResults = useQueryState(arg, { + selectFromResult: isSkippedQuery(arg, options) + ? undefined + : noPendingQueryStateSelector, + ...options, + }) + + // We do not suspend if a query is skipped: + // @see https://github.com/vercel/swr/pull/357#issuecomment-627089889 + if (!isSkippedQuery(arg, options)) { + if ( + queryStateResults.isLoading || + (queryStateResults.isFetching && options?.suspendOnRefetch) + ) { + const pendingPromise = api.util.getRunningOperationPromise( + name, + arg as unknown as string + ) + + if (pendingPromise) { + throw pendingPromise + } + } else if ( + queryStateResults.isError && + !queryStateResults.isFetching + ) { + throw queryStateResults.error + } + } + + return useMemo( + () => { + const output = { ...queryStateResults, ...querySubscriptionResults }; + + delete output.isLoading; + delete output.status; + + return output + }, + [queryStateResults, querySubscriptionResults] + ) + }, } } diff --git a/packages/toolkit/src/query/react/module.ts b/packages/toolkit/src/query/react/module.ts index 538ecdbd94..dcf272ea56 100644 --- a/packages/toolkit/src/query/react/module.ts +++ b/packages/toolkit/src/query/react/module.ts @@ -161,6 +161,7 @@ export const reactHooksModule = ({ useLazyQuerySubscription, useQueryState, useQuerySubscription, + useUnstable_SuspenseQuery, } = buildQueryHooks(endpointName) safeAssign(anyApi.endpoints[endpointName], { useQuery, @@ -168,6 +169,7 @@ export const reactHooksModule = ({ useLazyQuerySubscription, useQueryState, useQuerySubscription, + useUnstable_SuspenseQuery, }) ;(api as any)[`use${capitalize(endpointName)}Query`] = useQuery ;(api as any)[`useLazy${capitalize(endpointName)}Query`] = diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index 0adf60eb10..a6075235e6 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -22,6 +22,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 @@ -605,6 +606,115 @@ describe('hooks tests', () => { }) }) + describe('useSuspenseQuery', () => { + type UserProps = { + userId: number + skipFetch?: boolean + suspendOnRefetch?: boolean + } + + function User({ + userId, + skipFetch = false, + suspendOnRefetch = false, + }: UserProps) { + const { data, isFetching, refetch } = + api.endpoints.getUser.useUnstable_SuspenseQuery( + skipFetch ? skipToken : userId, + { suspendOnRefetch } + ) + + return ( +
+
{String(isFetching)}
+
{String(data?.name)}
+ +
+ ) + } + + 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') + }) + + test('suspends on refetches if options.suspendOnRefetch 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('fallback').textContent).toBe('fallback') + }) + + expect(screen.getByTestId('name').textContent).toBe('Timmy') + }) + }) + describe('useLazyQuery', () => { let data: any diff --git a/yarn.lock b/yarn.lock index a5d048b1cd..bd6c7ba0b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4049,6 +4049,23 @@ __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.6.0-rc.1 + "@types/react": 17.0.0 + "@types/react-dom": 17.0.0 + "@types/react-redux": 7.1.9 + 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 + 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" @@ -21964,6 +21981,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" From f0818a91fc8fa23256bd34a1339a18493ced0a6e Mon Sep 17 00:00:00 2001 From: FaberVitale Date: Sun, 20 Mar 2022 22:05:52 +0100 Subject: [PATCH 2/4] chore: add examples/query/react/suspense to sandbox ci --- .codesandbox/ci.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index 69146d63ba..8daac0366c 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -5,6 +5,7 @@ "github/reduxjs/rtk-github-issues-example", "/examples/query/react/basic", "/examples/query/react/advanced", + "/examples/query/react/suspense", "/examples/action-listener/counter" ], "node": "14", From a6b7f364291f0d1841abcc97716e13dd82712fe8 Mon Sep 17 00:00:00 2001 From: FaberVitale Date: Sat, 26 Mar 2022 10:30:00 +0100 Subject: [PATCH 3/4] fix: skip unnecessary render if there's no pending promise see: https://github.com/reduxjs/redux-toolkit/pull/2149#discussion_r830684178 --- .../toolkit/src/query/react/buildHooks.ts | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 6b14e96035..602e363cc6 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -115,7 +115,8 @@ export type UseSuspenseQuery> = < options?: UseQuerySubscriptionOptions & UseQueryStateOptions & UseSuspenseOptions -) => Omit, 'isLoading' | 'status'> & ReturnType> +) => Omit, 'isLoading' | 'status'> & + ReturnType> interface UseQuerySubscriptionOptions extends SubscriptionOptions { /** @@ -887,6 +888,7 @@ export function buildHooks({ ) }, useUnstable_SuspenseQuery(arg, options) { + const dispatch = useDispatch() const querySubscriptionResults = useQuerySubscription(arg, options) const queryStateResults = useQueryState(arg, { selectFromResult: isSkippedQuery(arg, options) @@ -902,14 +904,29 @@ export function buildHooks({ queryStateResults.isLoading || (queryStateResults.isFetching && options?.suspendOnRefetch) ) { - const pendingPromise = api.util.getRunningOperationPromise( + let pendingPromise = api.util.getRunningOperationPromise( name, arg as unknown as string ) - if (pendingPromise) { - throw pendingPromise + if (!pendingPromise) { + dispatch( + api.util.prefetch(name as any, arg as any, { force: true }) + ) + + pendingPromise = api.util.getRunningOperationPromise( + name, + arg as unknown as string + ) + + if (!pendingPromise) { + throw new Error( + `[rtk-query][react]: invalid state error, expected getRunningOperationPromise(${name}, ${arg}) to be defined` + ) + } } + + throw pendingPromise } else if ( queryStateResults.isError && !queryStateResults.isFetching @@ -918,17 +935,14 @@ export function buildHooks({ } } - return useMemo( - () => { - const output = { ...queryStateResults, ...querySubscriptionResults }; + return useMemo(() => { + const output = { ...queryStateResults, ...querySubscriptionResults } - delete output.isLoading; - delete output.status; + delete output.isLoading + delete output.status - return output - }, - [queryStateResults, querySubscriptionResults] - ) + return output + }, [queryStateResults, querySubscriptionResults]) }, } } From f2d0ec62b5d9511e68ab5927aae8cbed85a1195d Mon Sep 17 00:00:00 2001 From: FaberVitale Date: Sat, 26 Mar 2022 10:32:14 +0100 Subject: [PATCH 4/4] chore: improve slightly fallback suspense UI in suspense example --- .../query/react/suspense/src/SuspendedPokemon.tsx | 13 +++++++------ examples/query/react/suspense/src/styles.css | 10 ++++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/examples/query/react/suspense/src/SuspendedPokemon.tsx b/examples/query/react/suspense/src/SuspendedPokemon.tsx index 1deb52c074..0ab8557f18 100644 --- a/examples/query/react/suspense/src/SuspendedPokemon.tsx +++ b/examples/query/react/suspense/src/SuspendedPokemon.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import { Suspense, useState } from 'react' import { Pokemon } from './Pokemon' import { PokemonName } from './pokemon.data' @@ -11,13 +12,13 @@ export interface SuspendedPokemonProps { function BuggyComponent({ errorCount, name }:Pick & { errorCount: number }) { if(!errorCount) { - throw new Error('error while rendering:' + name) + throw new Error(`error while rendering: ${name}, errorCount ${errorCount}`); } return <> } -export function SuspendedPokemon({ +export const SuspendedPokemon = memo(function SuspendedPokemon({ name, suspendOnRefetch, throwOnIntialRender, @@ -45,9 +46,9 @@ export function SuspendedPokemon({ {throwOnIntialRender && } - suspense fallback
- loading pokemon {name} +
+ Suspense fallback UI.
+ Loading pokemon {name}
} > @@ -56,4 +57,4 @@ export function SuspendedPokemon({ ) -} +}); diff --git a/examples/query/react/suspense/src/styles.css b/examples/query/react/suspense/src/styles.css index 47c4992334..ed4fe2d2c6 100644 --- a/examples/query/react/suspense/src/styles.css +++ b/examples/query/react/suspense/src/styles.css @@ -8,3 +8,13 @@ grid-template-columns: repeat(auto-fill, 180px); grid-gap: 20px; } + +.suspense-fallback-wrapper { + display: flex; + place-items: center; + background: #e0e0e0; + color: #202020; + border-radius: 8px; + font-size: 1.1rem; + height: 250px; +}