- Wrap the main application layout in an AppErrorBoundary to enhance error handling. - Refactor BOOTSTRAP_REQUIRED_DEPENDENCIES to use a structured format, allowing for optional install specifiers for better dependency management.
Unified · Split
| @@ -5,6 +5,7 @@ import { Toaster } from "@/components/ui/sonner"; · 4 unmodified lines | |||
| 5 | import { AppConvexProvider } from "@/lib/convex-provider"; | 5 | import { AppConvexProvider } from "@/lib/convex-provider"; |
| 6 | import { QueryProvider } from "@/lib/query-provider"; | 6 | import { QueryProvider } from "@/lib/query-provider"; |
| 7 | import { SessionProvider } from "@/lib/session-context"; | 7 | import { SessionProvider } from "@/lib/session-context"; |
| 8 | import { AppErrorBoundary } from "@/components/app-error-boundary"; | ||
| 8 | import "./globals.css"; | 9 | import "./globals.css"; |
| 9 | 10 | ||
| 10 | const inter = Inter({ | 11 | const inter = Inter({ |
| @@ -38,13 +39,15 @@ export default function RootLayout({ · 27 unmodified lines | |||
| 38 | enableSystem | 39 | enableSystem |
| 39 | enableColorScheme | 40 | enableColorScheme |
| 40 | > | 41 | > |
| 41 | <QueryProvider> | 42 | <AppErrorBoundary> |
| 42 | <AppConvexProvider> | 43 | <QueryProvider> |
| 43 | <SessionProvider> | 44 | <AppConvexProvider> |
| 44 | {children} | 45 | <SessionProvider> |
| 45 | </SessionProvider> | 46 | {children} |
| 46 | </AppConvexProvider> | 47 | </SessionProvider> |
| 47 | </QueryProvider> | 48 | </AppConvexProvider> |
| 49 | </QueryProvider> | ||
| 50 | </AppErrorBoundary> | ||
| 48 | <Toaster position="bottom-right" /> | 51 | <Toaster position="bottom-right" /> |
| 49 | </ThemeProvider> | 52 | </ThemeProvider> |
| 50 | </body> | 53 | </body> |
| @@ -0,0 +1,119 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { Component } from "react"; | ||
| 4 | import { AlertTriangle, RotateCcw } from "lucide-react"; | ||
| 5 | import type { ErrorInfo, ReactNode } from "react"; | ||
| 6 | import { Button } from "@/components/ui/button"; | ||
| 7 | import { clearAnonymousAuth } from "@/lib/anonymous-auth"; | ||
| 8 | import { clearSessionStorage } from "@/lib/session-storage"; | ||
| 9 | |||
| 10 | type Props = { | ||
| 11 | children: ReactNode; | ||
| 12 | }; | ||
| 13 | |||
| 14 | type State = { | ||
| 15 | error: Error | null; | ||
| 16 | componentErrorInfo: ErrorInfo | null; | ||
| 17 | }; | ||
| 18 | |||
| 19 | const CLIENT_STORAGE_PREFIX = "executor-"; | ||
| 20 | |||
| 21 | function clearClientStorage() { | ||
| 22 | if (typeof window === "undefined") { | ||
| 23 | return; | ||
| 24 | } | ||
| 25 | |||
| 26 | clearSessionStorage(); | ||
| 27 | clearAnonymousAuth({ clearAccount: true }); | ||
| 28 | |||
| 29 | const clearPrefixedStorage = (storage: Storage) => { | ||
| 30 | const keysToDelete: string[] = []; | ||
| 31 | |||
| 32 | for (let index = storage.length - 1; index >= 0; index--) { | ||
| 33 | const key = storage.key(index); | ||
| 34 | if (key && key.startsWith(CLIENT_STORAGE_PREFIX)) { | ||
| 35 | keysToDelete.push(key); | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | for (const key of keysToDelete) { | ||
| 40 | storage.removeItem(key); | ||
| 41 | } | ||
| 42 | }; | ||
| 43 | |||
| 44 | clearPrefixedStorage(window.localStorage); | ||
| 45 | clearPrefixedStorage(window.sessionStorage); | ||
| 46 | } | ||
| 47 | |||
| 48 | function buildErrorDetails(error: Error | null, errorInfo: ErrorInfo | null) { | ||
| 49 | const lines = [error?.message ?? "Unknown error"]; | ||
| 50 | |||
| 51 | if (error?.stack) { | ||
| 52 | lines.push("", "Stack", error.stack); | ||
| 53 | } | ||
| 54 | |||
| 55 | if (errorInfo?.componentStack) { | ||
| 56 | lines.push("", "Component stack", errorInfo.componentStack); | ||
| 57 | } | ||
| 58 | |||
| 59 | return lines.join("\n"); | ||
| 60 | } | ||
| 61 | |||
| 62 | export class AppErrorBoundary extends Component<Props, State> { | ||
| 63 | state: State = { | ||
| 64 | error: null, | ||
| 65 | componentErrorInfo: null, | ||
| 66 | }; | ||
| 67 | |||
| 68 | static getDerivedStateFromError(error: Error) { | ||
| 69 | return { error, componentErrorInfo: null }; | ||
| 70 | } | ||
| 71 | |||
| 72 | componentDidCatch(error: Error, errorInfo: ErrorInfo) { | ||
| 73 | this.setState({ error, componentErrorInfo: errorInfo }); | ||
| 74 | } | ||
| 75 | |||
| 76 | handleRecover = () => { | ||
| 77 | try { | ||
| 78 | clearClientStorage(); | ||
| 79 | } finally { | ||
| 80 | window.location.assign("/sign-out"); | ||
| 81 | } | ||
| 82 | }; | ||
| 83 | |||
| 84 | render() { | ||
| 85 | const { error, componentErrorInfo } = this.state; | ||
| 86 | |||
| 87 | if (!error) { | ||
| 88 | return this.props.children; | ||
| 89 | } | ||
| 90 | |||
| 91 | const details = buildErrorDetails(error, componentErrorInfo); | ||
| 92 | |||
| 93 | return ( | ||
| 94 | <div className="min-h-screen grid place-items-center bg-background px-4 py-8"> | ||
| 95 | <div className="w-full max-w-2xl border border-border rounded-lg bg-card text-foreground p-6 space-y-4"> | ||
| 96 | <div className="flex items-start gap-3"> | ||
| 97 | <AlertTriangle className="h-5 w-5 text-destructive mt-0.5" /> | ||
| 98 | <div className="space-y-1 min-w-0"> | ||
| 99 | <h2 className="text-lg font-semibold">Something went wrong</h2> | ||
| 100 | <p className="text-sm text-muted-foreground">The app hit an unexpected error. Use refresh to clear cached state and sign out.</p> | ||
| 101 | </div> | ||
| 102 | </div> | ||
| 103 | |||
| 104 | <details className="rounded border border-border/60 bg-background/60 p-3"> | ||
| 105 | <summary className="cursor-pointer text-sm font-medium">Show error details</summary> | ||
| 106 | <pre className="mt-3 max-h-72 overflow-auto rounded bg-black/80 text-[11px] leading-tight text-white/90 p-3 whitespace-pre-wrap break-all"> | ||
| 107 | {details} | ||
| 108 | </pre> | ||
| 109 | </details> | ||
| 110 | |||
| 111 | <Button className="w-full md:w-auto" onClick={this.handleRecover}> | ||
| 112 | <RotateCcw className="h-4 w-4" /> | ||
| 113 | Refresh and recover | ||
| 114 | </Button> | ||
| 115 | </div> | ||
| 116 | </div> | ||
| 117 | ); | ||
| 118 | } | ||
| 119 | } | ||
| @@ -16,20 +16,25 @@ const convexJsonSchema = z.object({ · 15 unmodified lines | |||
| 16 | functions: z.string().optional(), | 16 | functions: z.string().optional(), |
| 17 | }); | 17 | }); |
| 18 | 18 | ||
| 19 | const BOOTSTRAP_REQUIRED_DEPENDENCIES = [ | 19 | type BootstrapDependency = { |
| 20 | "convex", | 20 | packageName: string; |
| 21 | "@convex-dev/migrations", | 21 | installSpecifier?: string; |
| 22 | "@convex-dev/stripe", | 22 | }; |
| 23 | "@convex-dev/workos-authkit", | 23 | |
| 24 | "@apidevtools/swagger-parser", | 24 | const BOOTSTRAP_REQUIRED_DEPENDENCIES: BootstrapDependency[] = [ |
| 25 | "@workos-inc/node", | 25 | { packageName: "convex" }, |
| 26 | "convex-helpers", | 26 | { packageName: "@convex-dev/migrations" }, |
| 27 | "jose", | 27 | { packageName: "@convex-dev/stripe" }, |
| 28 | "openapi-typescript", | 28 | { packageName: "@convex-dev/workos-authkit" }, |
| 29 | "zod", | 29 | { packageName: "@apidevtools/swagger-parser" }, |
| 30 | "graphql", | 30 | { packageName: "@workos-inc/node" }, |
| 31 | "better-result", | 31 | { packageName: "convex-helpers" }, |
| 32 | "@modelcontextprotocol/sdk", | 32 | { packageName: "jose" }, |
| 33 | { packageName: "openapi-typescript" }, | ||
| 34 | { packageName: "zod", installSpecifier: "zod@^3.25.0" }, | ||
| 35 | { packageName: "graphql" }, | ||
| 36 | { packageName: "better-result" }, | ||
| 37 | { packageName: "@modelcontextprotocol/sdk" }, | ||
| 33 | ]; | 38 | ]; |
| 34 | 39 | ||
| 35 | async function generateSelfHostedAdminKey(info: ManagedRuntimeInfo): Promise<string> { | 40 | async function generateSelfHostedAdminKey(info: ManagedRuntimeInfo): Promise<string> { |
| @@ -157,7 +162,7 @@ async function hasBootstrapDependencies(projectDir: string): Promise<boolean> { · 121 unmodified lines | |||
| 157 | } | 162 | } |
| 158 | 163 | ||
| 159 | for (const dependency of BOOTSTRAP_REQUIRED_DEPENDENCIES) { | 164 | for (const dependency of BOOTSTRAP_REQUIRED_DEPENDENCIES) { |
| 160 | const dependencyPath = path.join(nodeModulesDir, ...dependency.split("/")); | 165 | const dependencyPath = path.join(nodeModulesDir, ...dependency.packageName.split("/")); |
| 161 | if (!(await pathExists(dependencyPath))) { | 166 | if (!(await pathExists(dependencyPath))) { |
| 162 | return false; | 167 | return false; |
| 163 | } | 168 | } |
| @@ -198,7 +203,7 @@ async function ensureProjectDependencies(info: ManagedRuntimeInfo, projectDir: s · 34 unmodified lines | |||
| 198 | "--omit", | 203 | "--omit", |
| 199 | "dev", | 204 | "dev", |
| 200 | "--ignore-scripts", | 205 | "--ignore-scripts", |
| 201 | ...BOOTSTRAP_REQUIRED_DEPENDENCIES, | 206 | ...BOOTSTRAP_REQUIRED_DEPENDENCIES.map((dependency) => dependency.installSpecifier ?? dependency.packageName), |
| 202 | ], | 207 | ], |
| 203 | { env, stdout: "pipe", stderr: "pipe" }, | 208 | { env, stdout: "pipe", stderr: "pipe" }, |
| 204 | ); | 209 | ); |