Skip to content

Commit

Permalink
feat: function & hook for resetting failed queries
Browse files Browse the repository at this point in the history
  • Loading branch information
tien committed Jun 12, 2024
1 parent 8700cc7 commit e10f37b
Show file tree
Hide file tree
Showing 12 changed files with 209 additions and 23 deletions.
2 changes: 1 addition & 1 deletion apps/docs/docs/getting-started/mutation.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ There are multiple way to select the account used for signing.
import { ReDotSignerProvider } from "@reactive-dot/react";

const App = () => (
<ReDotSignerProvider signer={someSigner}>{/** ... */}</ReDotSignerProvider>
<ReDotSignerProvider signer={someSigner}>{/* ... */}</ReDotSignerProvider>
);
```

Expand Down
34 changes: 34 additions & 0 deletions apps/docs/docs/getting-started/query.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<article>
<header>Oops, something went wrong!</header>
<button onClick={() => props.resetErrorBoundary(props.error)}>Retry</button>
</article>
);

const AppErrorBoundary = () => {
const resetQueryError = useResetQueryError();

return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={(details) => {
if (details.reason === "imperative-api") {
const [error] = details.args;
resetQueryError(error);
}
}}
>
{/* ... */}
</ErrorBoundary>
);
};
```
4 changes: 2 additions & 2 deletions apps/docs/docs/getting-started/setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ import { Suspense } from "react";
const App = () => (
<ReDotProvider config={config}>
<ReDotChainProvider chainId="polkadot">
{/** Make sure there is at least one Suspense boundary wrapping the app */}
<Suspense>{/** ... */}</Suspense>
{/* Make sure there is at least one Suspense boundary wrapping the app */}
<Suspense>{/* ... */}</Suspense>
</ReDotChainProvider>
</ReDotProvider>
);
Expand Down
3 changes: 2 additions & 1 deletion apps/example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
51 changes: 38 additions & 13 deletions apps/example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { IDLE, MutationError, PENDING } from "@reactive-dot/core";
import {
ReDotChainProvider,
ReDotProvider,
useResetQueryError,
useAccounts,
useBlock,
useConnectedWallets,
Expand All @@ -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();
Expand Down Expand Up @@ -236,34 +238,57 @@ const Mutation = () => {
);
};

const Example = () => (
<div>
<Query />
<Mutation />
</div>
const ErrorFallback = (props: FallbackProps) => (
<article>
<header>
<strong>Oops, something went wrong!</strong>
</header>
<button onClick={() => props.resetErrorBoundary(props.error)}>Retry</button>
</article>
);

type ExampleProps = { chainName: string };

const Example = (props: ExampleProps) => {
const resetQueryError = useResetQueryError();

return (
<div>
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={(details) => {
if (details.reason === "imperative-api") {
const [error] = details.args;
resetQueryError(error);
}
}}
>
<Suspense fallback={<h2>Loading {props.chainName}...</h2>}>
<h2>{props.chainName}</h2>
<Query />
<Mutation />
</Suspense>
</ErrorBoundary>
</div>
);
};

const App = () => (
<ReDotProvider config={config}>
<Suspense fallback="Loading wallet connection...">
<WalletConnection />
</Suspense>
<ReDotChainProvider chainId="polkadot">
<Suspense fallback={<h2>Loading Polkadot...</h2>}>
<h2>Polkadot</h2>
<Example />
</Suspense>
<Example chainName="Polkadot" />
</ReDotChainProvider>
<ReDotChainProvider chainId="kusama">
<Suspense fallback={<h2>Loading Kusama...</h2>}>
<h2>Kusama</h2>
<Example />
<Example chainName="Kusama" />
</Suspense>
</ReDotChainProvider>
<ReDotChainProvider chainId="westend">
<Suspense fallback={<h2>Loading Westend...</h2>}>
<h2>Westend</h2>
<Example />
<Example chainName="Westend" />
</Suspense>
</ReDotChainProvider>
</ReDotProvider>
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/hooks/useQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/react/src/hooks/useResetQueryError.ts
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
23 changes: 18 additions & 5 deletions packages/react/src/stores/query.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -24,13 +25,21 @@ const instructionPayloadAtomFamily = atomFamily(
}): Atom<unknown> | WritableAtom<Promise<unknown>, [], 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<unknown>,
Expand Down Expand Up @@ -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<unknown> =>
withAtomFamilyErrorCatcher(
queryPayloadAtomFamily,
param,
atom,
)((get) => {
const atoms = getQueryInstructionPayloadAtoms(param.chainId, param.query);

return Promise.all(
Expand Down
92 changes: 92 additions & 0 deletions packages/react/src/utils/jotai.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, unknown>,
readonly param: unknown,
message: string | undefined,
options?: ErrorOptions,
) {
super(message, options);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
static fromAtomFamilyError<TError, TAtomFamily extends AtomFamily<any, any>>(
error: TError,
atomFamily: TAtomFamily,
param: TAtomFamily extends AtomFamily<infer Param, infer _>
? 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<unknown>,
>(
atomFamily: AtomFamily<TParam, unknown>,
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);
}
};
File renamed without changes.
12 changes: 12 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit e10f37b

Please sign in to comment.