effect-atom-boundary
Shared boundaries
createErrorBoundary makes a boundary for failures that belong to the app, and a hook that types the reads under it without the errors it claims.
Some failures belong to the app rather than to the component that read them. Every Effect RPC procedure fails with RpcClientError when the request doesn't reach the server or the answer doesn't decode: it's the connection failing, not one of the contract's errors, and every component handling it would repeat the same message. createErrorBoundary makes one boundary for it, returned as a component and a hook you name by destructuring:
// Every procedure fails with RpcClientError when the request doesn't reach
// the server, so the app catches it once.
export const [const AppBoundary: ErrorBoundaryComponent<"RpcClientError">AppBoundary, const useAppBoundary: UseErrorBoundary<"RpcClientError">useAppBoundary] = createErrorBoundary<typeof IssuesClient, "RpcClientError", never>(options: ErrorBoundaryOptions<typeof IssuesClient, "RpcClientError", never>): ErrorBoundary<"RpcClientError">createErrorBoundary({
ErrorBoundaryOptions<Source, Tags extends TagsOf<ErrorsOf<Source>>, ParentTags extends string>.name: stringname: "App",
ErrorBoundaryOptions<typeof IssuesClient, "RpcClientError", never>.errors: typeof IssuesClienterrors: class IssuesClientIssuesClient,
ErrorBoundaryOptions<typeof IssuesClient, "RpcClientError", never>.catchTags: CatchTags<RpcClientError | IssueNotFound | EmptyTitle | EmptyBody | EmptyComment | Unauthorized, "RpcClientError">catchTags: {
RpcClientError?: ((props: CatchTagProps<RpcClientError>) => React.ReactNode) | undefinedRpcClientError: ({ reset: () => voidreset }) => (
<React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>
Can't reach the server. <React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button React.DOMAttributes<HTMLButtonElement>.onClick?: React.MouseEventHandler<HTMLButtonElement> | undefinedonClick={reset: () => voidreset}>Try again</React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button>
</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>
),
},
});
errors names what the errors come from, for the types only: an AtomRpc client (the errors of all its procedures), an AtomHttpApi client (the errors of all its endpoints and their middleware), an atom, or an atom family. An AtomHttpApi query's transport failures aren't among them: see AtomHttpApi transport failures. name names the boundary in messages and React DevTools: "App" is <AppBoundary>. reset refreshes every atom that failed with a tag the boundary handles, so Try again loads the page again once the server is back.
Reads under the boundary
A component wraps its read in the boundary's hook. useAppBoundary(result) takes the AsyncResult a read returned, throws the failures the boundary claims to the boundary, and returns the result typed without them. Around raf-effect-atom's useAtomResult, which returns typed failures and throws defects, that's useAppBoundary(useAtomResult(issue, props.issue)):
function function IssueTitle(props: {
issue: Handle<typeof issue>;
}): React.JSX.Element
IssueTitle(props: {
issue: Handle<typeof issue>;
}
props: { issue: Handle<(id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issue: interface Handle<S>Handle<typeof const issue: (id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>issue> }) {
const const result: AsyncResult.Success<Issue, IssueNotFound | Unauthorized> | AsyncResult.Failure<Issue, IssueNotFound | Unauthorized>result = useAppBoundary<SettledResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>(result: SettledResult<Issue, RpcClientError | IssueNotFound | Unauthorized>): AsyncResult.Success<Issue, IssueNotFound | Unauthorized> | AsyncResult.Failure<Issue, IssueNotFound | Unauthorized>useAppBoundary(useAtomResult<(id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>(source: (id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>, handle: NoInfer<Handle<(id: number & Brand<"IssueId">) => Atom<AsyncResult.AsyncResult<Issue, unknown>>>>): SettledResult<...>useAtomResult(const issue: (id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>issue, props: {
issue: Handle<typeof issue>;
}
props.issue: Handle<(id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issue));
if (import AsyncResultAsyncResult.const isSuccess: <Issue, IssueNotFound | Unauthorized>(result: AsyncResult.AsyncResult<Issue, IssueNotFound | Unauthorized>) => result is AsyncResult.Success<Issue, IssueNotFound | Unauthorized>isSuccess(const result: AsyncResult.Success<Issue, IssueNotFound | Unauthorized> | AsyncResult.Failure<Issue, IssueNotFound | Unauthorized>result)) {
return <React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>{const result: AsyncResult.Success<Issue, IssueNotFound | Unauthorized>result.Success<Issue, IssueNotFound | Unauthorized>.value: Issuevalue.title: stringtitle}</React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>;
}
// useAtomResult throws defects, so a failure it returns has a typed error.
const error = import OptionOption.const getOrThrow: <IssueNotFound | Unauthorized>(self: Option.Option<IssueNotFound | Unauthorized>) => IssueNotFound | UnauthorizedgetOrThrow(import AsyncResultAsyncResult.const error: <Issue, IssueNotFound | Unauthorized>(self: AsyncResult.AsyncResult<Issue, IssueNotFound | Unauthorized>) => Option.Option<IssueNotFound | Unauthorized>error(const result: AsyncResult.Failure<Issue, IssueNotFound | Unauthorized>result)); return import MatchMatch.const valueTags: <IssueNotFound | Unauthorized, {
IssueNotFound: (notFound: IssueNotFound) => React.JSX.Element;
Unauthorized: () => React.JSX.Element;
}>(input: IssueNotFound | Unauthorized, fields: {
IssueNotFound: (notFound: IssueNotFound) => React.JSX.Element;
Unauthorized: () => React.JSX.Element;
}) => React.JSX.Element (+1 overload)
valueTags(const error: IssueNotFound | Unauthorizederror, {
type IssueNotFound: (notFound: IssueNotFound) => React.JSX.ElementIssueNotFound: (notFound: IssueNotFoundnotFound) => <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>There's no issue #{notFound: IssueNotFoundnotFound.id: number & Brand<"IssueId">id}.</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>,
type Unauthorized: () => React.JSX.ElementUnauthorized: () => <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>Sign in to see this issue.</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>,
});
}
It's a hook, called with the read it narrows, so it goes wherever the read goes. It throws when its boundary isn't mounted above the component:
function function App(): React.JSX.ElementApp() {
return (
<const AppBoundary: ErrorBoundaryComponent<"RpcClientError">AppBoundary>
<function IssueTitle(props: {
issue: Handle<typeof issue>;
}): React.JSX.Element
IssueTitle issue: Handle<(id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issue={const issueHandle: Handle<(id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issueHandle} />
</const AppBoundary: ErrorBoundaryComponent<"RpcClientError">AppBoundary>
);
}A tag that none of errors' procedures can fail with is a type error:
createErrorBoundary<typeof IssuesClient, "IssueNotFound" | "EmptyTitle" | "EmptyBody" | "EmptyComment" | "Unauthorized" | "RpcClientError", never>(options: ErrorBoundaryOptions<typeof IssuesClient, "IssueNotFound" | "EmptyTitle" | "EmptyBody" | "EmptyComment" | "Unauthorized" | "RpcClientError", never>): ErrorBoundary<"IssueNotFound" | "EmptyTitle" | "EmptyBody" | "EmptyComment" | "Unauthorized" | "RpcClientError">createErrorBoundary({
ErrorBoundaryOptions<Source, Tags extends TagsOf<ErrorsOf<Source>>, ParentTags extends string>.name: stringname: "App",
ErrorBoundaryOptions<typeof IssuesClient, "IssueNotFound" | "EmptyTitle" | "EmptyBody" | "EmptyComment" | "Unauthorized" | "RpcClientError", never>.errors: typeof IssuesClienterrors: class IssuesClientIssuesClient,
ErrorBoundaryOptions<typeof IssuesClient, "IssueNotFound" | "EmptyTitle" | "EmptyBody" | "EmptyComment" | "Unauthorized" | "RpcClientError", never>.catchTags: CatchTags<RpcClientError | IssueNotFound | EmptyTitle | EmptyBody | EmptyComment | Unauthorized, "IssueNotFound" | "EmptyTitle" | "EmptyBody" | "EmptyComment" | "Unauthorized" | "RpcClientError">catchTags: {
RpcClientEror: () => <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>Can't reach the server.</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>, },
});
Chained boundaries
parent puts a boundary under another one. A result passed to the child's hook throws the tags any boundary in the chain claims, each to the nearest boundary that claims it, and the claimed tags add up along the chain. Being signed out belongs to the app too, so <AuthBoundary> claims Unauthorized under <AppBoundary>, and <IssueBoundary> claims IssueNotFound under <AuthBoundary>:
// Under the App boundary: every procedure can fail with Unauthorized too.
export const [const AuthBoundary: ErrorBoundaryComponent<"Unauthorized" | "RpcClientError">AuthBoundary, const useAuthBoundary: UseErrorBoundary<"Unauthorized" | "RpcClientError">useAuthBoundary] = createErrorBoundary<typeof IssuesClient, "Unauthorized", "RpcClientError">(options: ErrorBoundaryOptions<typeof IssuesClient, "Unauthorized", "RpcClientError">): ErrorBoundary<"Unauthorized" | "RpcClientError">createErrorBoundary({
ErrorBoundaryOptions<Source, Tags extends TagsOf<ErrorsOf<Source>>, ParentTags extends string>.name: stringname: "Auth",
ErrorBoundaryOptions<typeof IssuesClient, "Unauthorized", "RpcClientError">.errors: typeof IssuesClienterrors: class IssuesClientIssuesClient,
ErrorBoundaryOptions<typeof IssuesClient, "Unauthorized", "RpcClientError">.parent?: ErrorBoundaryComponent<"RpcClientError"> | undefinedparent: const AppBoundary: ErrorBoundaryComponent<"RpcClientError">AppBoundary,
ErrorBoundaryOptions<typeof IssuesClient, "Unauthorized", "RpcClientError">.catchTags: CatchTags<RpcClientError | IssueNotFound | EmptyTitle | EmptyBody | EmptyComment | Unauthorized, "Unauthorized">catchTags: {
Unauthorized?: ((props: CatchTagProps<Unauthorized>) => React.ReactNode) | undefinedUnauthorized: ({ reset: () => voidreset }) => (
<React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>
You're signed out. <React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button React.DOMAttributes<HTMLButtonElement>.onClick?: React.MouseEventHandler<HTMLButtonElement> | undefinedonClick={reset: () => voidreset}>Try again</React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button>
</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>
),
},
});
// Under the Auth boundary: it claims IssueNotFound, and Unauthorized and
// RpcClientError still go up the chain.
export const [const IssueBoundary: ErrorBoundaryComponent<"IssueNotFound" | "Unauthorized" | "RpcClientError">IssueBoundary, const useIssueBoundary: UseErrorBoundary<"IssueNotFound" | "Unauthorized" | "RpcClientError">useIssueBoundary] = createErrorBoundary<typeof IssuesClient, "IssueNotFound", "Unauthorized" | "RpcClientError">(options: ErrorBoundaryOptions<typeof IssuesClient, "IssueNotFound", "Unauthorized" | "RpcClientError">): ErrorBoundary<"IssueNotFound" | "Unauthorized" | "RpcClientError">createErrorBoundary({
ErrorBoundaryOptions<Source, Tags extends TagsOf<ErrorsOf<Source>>, ParentTags extends string>.name: stringname: "Issue",
ErrorBoundaryOptions<typeof IssuesClient, "IssueNotFound", "Unauthorized" | "RpcClientError">.errors: typeof IssuesClienterrors: class IssuesClientIssuesClient,
ErrorBoundaryOptions<typeof IssuesClient, "IssueNotFound", "Unauthorized" | "RpcClientError">.parent?: ErrorBoundaryComponent<"Unauthorized" | "RpcClientError"> | undefinedparent: const AuthBoundary: ErrorBoundaryComponent<"Unauthorized" | "RpcClientError">AuthBoundary,
ErrorBoundaryOptions<typeof IssuesClient, "IssueNotFound", "Unauthorized" | "RpcClientError">.catchTags: CatchTags<RpcClientError | IssueNotFound | EmptyTitle | EmptyBody | EmptyComment | Unauthorized, "IssueNotFound">catchTags: {
IssueNotFound?: ((props: CatchTagProps<IssueNotFound>) => React.ReactNode) | undefinedIssueNotFound: ({ error: IssueNotFounderror }) => <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>There's no issue #{error: IssueNotFounderror.id: number & Brand<"IssueId">id}.</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>,
},
});useIssueBoundary(useAtomResult(issue, props.issue)) throws IssueNotFound to <IssueBoundary>, Unauthorized to <AuthBoundary> and RpcClientError to <AppBoundary>. No failure is left, so the hook types the result as a success. <IssueBoundary> throws when <AuthBoundary> isn't mounted above it.
Matching is by tag
A shared boundary catches a claimed tag from any atom read under it, not only from the results passed to its hook. reset refreshes every atom in the registry whose failure has a tag the boundary handles, not only the one whose read threw: one outage fails every atom on the page, and one reset loads them all again.
AtomHttpApi transport failures
The two clients report a failed connection differently. An AtomRpc query fails with RpcClientError, a typed failure, so <AppBoundary> claims it by tag and its reset retries it. An AtomHttpApi query turns HttpClientError (the request didn't reach the server, or the response had a status or body the client couldn't read) and a SchemaError (the answer didn't decode) into defects. Its error type lists only its endpoints' and middleware's errors, so a boundary's catchTags can't name these failures.
A defect skips the boundaries createErrorBoundary makes:
- A read through
useAtomResultthrowsCause.squash(cause)to the nearest other React error boundary, such as the route'serrorComponentor one the app mounts for defects. - A loader's
ensureorfetchrejects with it, and the router renders the route'serrorComponent. - To retry, refresh the atom before rendering it again, with
atoms.fetch(source, ...args)orregistry.refresh. The atom keeps the defect, andensurereturns it without a new request.
Keep these failures in that boundary. On the server, createErrorBoundary boundaries check typed failures only, and a defect reaches the browser without its _tag. A boundary that matched HttpClientError would render a fallback in the browser that the server didn't.
Reads the hook narrows
The hook takes the AsyncResult of a read that returns typed failures and suspends while a failed atom refreshes: raf-effect-atom's useAtomResult, or useAtomSuspense(atom, { includeFailure: true, suspendOnWaiting: true }). It throws a defect it's given too, to the nearest other React error boundary.
- A read that returns the old failure while
resetrefreshes the atom, such as Effect Atom'suseAtomValue, hands the hook the claimed failure again. The hook throws it, and the boundary keeps its handler after the refresh succeeds. useAtomSuspensewithoutincludeFailurethrows every failure itself, including the ones the boundaries leave, before the hook sees the result.