From e10f37bb8eddd85b27cfd353ceadcb80180a4c0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n=20Kh=E1=BA=AFc?= Date: Wed, 12 Jun 2024 21:27:23 +1200 Subject: [PATCH] feat: function & hook for resetting failed queries --- apps/docs/docs/getting-started/mutation.md | 2 +- apps/docs/docs/getting-started/query.md | 34 +++++++ apps/docs/docs/getting-started/setup.mdx | 4 +- apps/example/package.json | 3 +- apps/example/src/App.tsx | 51 +++++++--- packages/react/src/hooks/useQuery.ts | 2 +- .../react/src/hooks/useResetQueryError.ts | 8 ++ packages/react/src/index.ts | 1 + packages/react/src/stores/query.ts | 23 ++++- packages/react/src/utils/jotai.ts | 92 +++++++++++++++++++ .../react/src/{utils.ts => utils/vanilla.ts} | 0 yarn.lock | 12 +++ 12 files changed, 209 insertions(+), 23 deletions(-) create mode 100644 packages/react/src/hooks/useResetQueryError.ts create mode 100644 packages/react/src/utils/jotai.ts rename packages/react/src/{utils.ts => utils/vanilla.ts} (100%) diff --git a/apps/docs/docs/getting-started/mutation.md b/apps/docs/docs/getting-started/mutation.md index 09caa0ff..33be0907 100644 --- a/apps/docs/docs/getting-started/mutation.md +++ b/apps/docs/docs/getting-started/mutation.md @@ -20,7 +20,7 @@ There are multiple way to select the account used for signing. import { ReDotSignerProvider } from "@reactive-dot/react"; const App = () => ( - {/** ... */} + {/* ... */} ); ``` diff --git a/apps/docs/docs/getting-started/query.md b/apps/docs/docs/getting-started/query.md index a646fe28..0447c3cb 100644 --- a/apps/docs/docs/getting-started/query.md +++ b/apps/docs/docs/getting-started/query.md @@ -116,3 +116,37 @@ const QueryWithRefresh = () => { ); }; ``` + +## Retry failed query + +Error from queries can be caught and reset using `ErrorBoundary` & [`useResetQueryError`](/api/react/function/useResetQueryError) hook. + +```tsx +import { useResetQueryError } from "@reactive-dot/react"; +import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; + +const ErrorFallback = (props: FallbackProps) => ( +
+
Oops, something went wrong!
+ +
+); + +const AppErrorBoundary = () => { + const resetQueryError = useResetQueryError(); + + return ( + { + if (details.reason === "imperative-api") { + const [error] = details.args; + resetQueryError(error); + } + }} + > + {/* ... */} + + ); +}; +``` diff --git a/apps/docs/docs/getting-started/setup.mdx b/apps/docs/docs/getting-started/setup.mdx index af4e9d87..b9e16afb 100644 --- a/apps/docs/docs/getting-started/setup.mdx +++ b/apps/docs/docs/getting-started/setup.mdx @@ -101,8 +101,8 @@ import { Suspense } from "react"; const App = () => ( - {/** Make sure there is at least one Suspense boundary wrapping the app */} - {/** ... */} + {/* Make sure there is at least one Suspense boundary wrapping the app */} + {/* ... */} ); diff --git a/apps/example/package.json b/apps/example/package.json index f30976e8..d3e7d138 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -15,7 +15,8 @@ "date-fns": "^3.6.0", "polkadot-api": "^0.8.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.13" }, "devDependencies": { "@reactive-dot/eslint-config": "workspace:^", diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index df831df8..b73b8c97 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -3,6 +3,7 @@ import { IDLE, MutationError, PENDING } from "@reactive-dot/core"; import { ReDotChainProvider, ReDotProvider, + useResetQueryError, useAccounts, useBlock, useConnectedWallets, @@ -14,6 +15,7 @@ import { import { formatDistance } from "date-fns"; import { Binary } from "polkadot-api"; import { Suspense, useState, useTransition } from "react"; +import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; const PendingPoolRewards = () => { const accounts = useAccounts(); @@ -236,34 +238,57 @@ const Mutation = () => { ); }; -const Example = () => ( -
- - -
+const ErrorFallback = (props: FallbackProps) => ( +
+
+ Oops, something went wrong! +
+ +
); +type ExampleProps = { chainName: string }; + +const Example = (props: ExampleProps) => { + const resetQueryError = useResetQueryError(); + + return ( +
+ { + if (details.reason === "imperative-api") { + const [error] = details.args; + resetQueryError(error); + } + }} + > + Loading {props.chainName}...}> +

{props.chainName}

+ + +
+
+
+ ); +}; + const App = () => ( - Loading Polkadot...}> -

Polkadot

- -
+
Loading Kusama...}> -

Kusama

- +
Loading Westend...}> -

Westend

- +
diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts index d3ee560e..b11ca179 100644 --- a/packages/react/src/hooks/useQuery.ts +++ b/packages/react/src/hooks/useQuery.ts @@ -4,7 +4,7 @@ import { queryPayloadAtomFamily, } from "../stores/query.js"; import type { Falsy, FalsyGuard, FlatHead } from "../types.js"; -import { flatHead, stringify } from "../utils.js"; +import { flatHead, stringify } from "../utils/vanilla.js"; import type { ChainHookOptions } from "./types.js"; import { Query, diff --git a/packages/react/src/hooks/useResetQueryError.ts b/packages/react/src/hooks/useResetQueryError.ts new file mode 100644 index 00000000..80256231 --- /dev/null +++ b/packages/react/src/hooks/useResetQueryError.ts @@ -0,0 +1,8 @@ +import { resetQueryError } from "../utils/jotai.js"; + +/** + * Hook for getting function to reset query error caught by error boundary + * + * @returns Function to reset caught query error + */ +export const useResetQueryError = () => resetQueryError; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 791296fa..5b4019b8 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -14,3 +14,4 @@ export { useMutation } from "./hooks/useMutation.js"; export { useQuery, useQueryWithRefresh } from "./hooks/useQuery.js"; export { useTypedApi } from "./hooks/useTypedApi.js"; export { useConnectedWallets, useWallets } from "./hooks/useWallets.js"; +export { useResetQueryError } from "./hooks/useResetQueryError.js"; diff --git a/packages/react/src/stores/query.ts b/packages/react/src/stores/query.ts index 8271dcb8..c2b86a6d 100644 --- a/packages/react/src/stores/query.ts +++ b/packages/react/src/stores/query.ts @@ -1,4 +1,5 @@ -import { stringify } from "../utils.js"; +import { withAtomFamilyErrorCatcher } from "../utils/jotai.js"; +import { stringify } from "../utils/vanilla.js"; import { typedApiAtomFamily } from "./client.js"; import { QueryInstruction, @@ -24,13 +25,21 @@ const instructionPayloadAtomFamily = atomFamily( }): Atom | WritableAtom, [], void> => { switch (preflight(param.instruction)) { case "promise": - return atomWithRefresh(async (get, { signal }) => { + return withAtomFamilyErrorCatcher( + instructionPayloadAtomFamily, + param, + atomWithRefresh, + )(async (get, { signal }) => { const api = await get(typedApiAtomFamily(param.chainId)); return query(api, param.instruction, { signal }); }); case "observable": - return atomWithObservable((get) => + return withAtomFamilyErrorCatcher( + instructionPayloadAtomFamily, + param, + atomWithObservable, + )((get) => from(get(typedApiAtomFamily(param.chainId))).pipe( switchMap( (api) => query(api, param.instruction) as Observable, @@ -67,8 +76,12 @@ export const getQueryInstructionPayloadAtoms = ( // TODO: should be memoized within render function instead // https://github.com/pmndrs/jotai/discussions/1553 export const queryPayloadAtomFamily = atomFamily( - (param: { chainId: ChainId; query: Query }) => - atom((get) => { + (param: { chainId: ChainId; query: Query }): Atom => + withAtomFamilyErrorCatcher( + queryPayloadAtomFamily, + param, + atom, + )((get) => { const atoms = getQueryInstructionPayloadAtoms(param.chainId, param.query); return Promise.all( diff --git a/packages/react/src/utils/jotai.ts b/packages/react/src/utils/jotai.ts new file mode 100644 index 00000000..4dfa12aa --- /dev/null +++ b/packages/react/src/utils/jotai.ts @@ -0,0 +1,92 @@ +import { QueryError } from "@reactive-dot/core"; +import type { Atom, Getter } from "jotai"; +import type { AtomFamily } from "jotai/vanilla/utils/atomFamily"; +import { Observable } from "rxjs"; +import { catchError } from "rxjs/operators"; + +export class AtomFamilyError extends QueryError { + constructor( + readonly atomFamily: AtomFamily, + readonly param: unknown, + message: string | undefined, + options?: ErrorOptions, + ) { + super(message, options); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static fromAtomFamilyError>( + error: TError, + atomFamily: TAtomFamily, + param: TAtomFamily extends AtomFamily + ? Param + : unknown, + message?: string, + ): AtomFamilyError { + return new AtomFamilyError(atomFamily, param, message, { + cause: error, + }); + } +} + +export const withAtomFamilyErrorCatcher = < + TParam, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TRead extends (get: Getter, ...args: unknown[]) => any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TAtomCreator extends (read: TRead, ...args: any[]) => Atom, +>( + atomFamily: AtomFamily, + param: TParam, + atomCreator: TAtomCreator, +): TAtomCreator => { + // @ts-expect-error complex sub-type + const atomCatching: TAtomCreator = (read, ...args) => { + // @ts-expect-error complex sub-type + const readCatching: TRead = (...readArgs) => { + try { + const value = read(...readArgs); + + if (value instanceof Promise) { + return value.catch((error) => { + throw AtomFamilyError.fromAtomFamilyError(error, atomFamily, param); + }); + } + + if (value instanceof Observable) { + return value.pipe( + catchError((error) => { + throw AtomFamilyError.fromAtomFamilyError( + error, + atomFamily, + param, + ); + }), + ); + } + + return value; + } catch (error) { + throw AtomFamilyError.fromAtomFamilyError(error, atomFamily, param); + } + }; + + return atomCreator(readCatching, ...args); + }; + + return atomCatching; +}; + +export const resetQueryError = (error: unknown) => { + if (!(error instanceof Error)) { + return; + } + + if (error instanceof AtomFamilyError) { + error.atomFamily.remove(error.param); + } + + if (error.cause instanceof Error) { + resetQueryError(error.cause); + } +}; diff --git a/packages/react/src/utils.ts b/packages/react/src/utils/vanilla.ts similarity index 100% rename from packages/react/src/utils.ts rename to packages/react/src/utils/vanilla.ts diff --git a/yarn.lock b/yarn.lock index b3671d51..9ab10f1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3411,6 +3411,7 @@ __metadata: polkadot-api: "npm:^0.8.0" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" + react-error-boundary: "npm:^4.0.13" typescript: "npm:^5.4.5" vite: "npm:^5.2.13" languageName: unknown @@ -12791,6 +12792,17 @@ __metadata: languageName: node linkType: hard +"react-error-boundary@npm:^4.0.13": + version: 4.0.13 + resolution: "react-error-boundary@npm:4.0.13" + dependencies: + "@babel/runtime": "npm:^7.12.5" + peerDependencies: + react: ">=16.13.1" + checksum: 10c0/6f3e0e4d7669f680ccf49c08c9571519c6e31f04dcfc30a765a7136c7e6fbbbe93423dd5a9fce12107f8166e54133e9dd5c2079a00c7a38201ac811f7a28b8e7 + languageName: node + linkType: hard + "react-error-overlay@npm:^6.0.11": version: 6.0.11 resolution: "react-error-overlay@npm:6.0.11"