| 1 | import { Component, type ErrorInfo, type ReactNode, useEffect, useRef, useState } from "react"; |
| 2 | import { TriangleAlert } from "lucide-react"; |
| 3 | import { useLocation, useRevalidator } from "react-router"; |
| 4 | |
| 5 | import { Card } from "./ui/card"; |
| 6 | |
| 7 | /** |
| 8 | * Says a section could not be loaded, without taking the page down. |
| 9 | * `compact`: one quiet line, for a sidebar's card or a count's place. |
| 10 | */ |
| 11 | export function Unavailable({ what, compact = false }: { what: string; compact?: boolean }) { |
| 12 | if (compact) { |
| 13 | return ( |
| 14 | <p className="flex items-center gap-1.5 text-xs text-muted"> |
| 15 | <TriangleAlert size={12} className="shrink-0 text-warn" /> |
| 16 | {what} could not be loaded just now. |
| 17 | </p> |
| 18 | ); |
| 19 | } |
| 20 | return ( |
| 21 | <Card asChild tone="plain" className="flex items-center gap-2 border-dashed px-4 py-5 text-sm text-muted"> |
| 22 | <p> |
| 23 | <TriangleAlert size={14} className="text-warn" /> |
| 24 | {what} could not be loaded just now. It will be back on the next refresh. |
| 25 | </p> |
| 26 | </Card> |
| 27 | ); |
| 28 | } |
| 29 | |
| 30 | /** |
| 31 | * Keeps a part of a page that fails to draw to itself: in its place it says |
| 32 | * that `what` could not be loaded, and the rest of the page stays. It tries |
| 33 | * again on the next refresh or navigation (whatever reloads the page's |
| 34 | * data). Data a part could not fetch is the loader's to say |
| 35 | * (lib/partial.ts `part`); this catches what breaks while drawing. |
| 36 | */ |
| 37 | export function Boundary({ what, children, fallback }: { what: string; children: ReactNode; fallback?: ReactNode }) { |
| 38 | const { key } = useLocation(); |
| 39 | const loads = useLoads(); |
| 40 | return ( |
| 41 | <Catch what={what} fallback={fallback} reset={`${key}:${loads}`}> |
| 42 | {children} |
| 43 | </Catch> |
| 44 | ); |
| 45 | } |
| 46 | |
| 47 | /** How many times the page's data has loaded again in place (a refresh, a form). */ |
| 48 | function useLoads(): number { |
| 49 | const { state } = useRevalidator(); |
| 50 | const [loads, setLoads] = useState(0); |
| 51 | const was = useRef(state); |
| 52 | useEffect(() => { |
| 53 | if (was.current !== "idle" && state === "idle") setLoads((n) => n + 1); |
| 54 | was.current = state; |
| 55 | }, [state]); |
| 56 | return loads; |
| 57 | } |
| 58 | |
| 59 | type CatchProps = { what: string; fallback?: ReactNode; reset: string; children: ReactNode }; |
| 60 | |
| 61 | class Catch extends Component<CatchProps, { failed: boolean }> { |
| 62 | state = { failed: false }; |
| 63 | |
| 64 | static getDerivedStateFromError() { |
| 65 | return { failed: true }; |
| 66 | } |
| 67 | |
| 68 | componentDidCatch(error: unknown, info: ErrorInfo) { |
| 69 | console.error(`g1t: ${this.props.what} failed to draw`, error, info.componentStack); |
| 70 | } |
| 71 | |
| 72 | componentDidUpdate(previous: CatchProps) { |
| 73 | // Fresh data, or another page: draw it again. |
| 74 | if (this.state.failed && previous.reset !== this.props.reset) this.setState({ failed: false }); |
| 75 | } |
| 76 | |
| 77 | render() { |
| 78 | if (this.state.failed) return this.props.fallback ?? <Unavailable what={this.props.what} />; |
| 79 | return this.props.children; |
| 80 | } |
| 81 | } |