From ae580b6e274ef0fe618e3b8b43fc4103e760d249 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 16:07:52 +0000 Subject: [PATCH 1/4] perf(web): implement route-level code splitting via lazy routes (LUM-1900) Apply React Router v7's object-based lazy property to all non-critical route groups. Vite creates separate chunks per dynamic import(), so each lazy route loads only when first navigated to. Main bundle reduced from 4,131 KB to 2,090 KB (49% smaller). Eager (critical path): RootLayout, ChatLayout, ChatPage, DocumentViewerPage, ConversationRedirect, ActiveAssistantGate, NotFound. Lazy (deferred): settings (18 pages), logs (4 pages), account/auth (8 pages), onboarding (3 screens), intelligence pages, library, inspector, home, connect. Add RouterProvider.onError handler in main.tsx to catch chunk load failures (stale deploys, network errors) and trigger a page reload. Update CONVENTIONS.md and AGENTS.md with code splitting conventions. Co-Authored-By: ashlee@vellum.ai --- apps/web/AGENTS.md | 1 + apps/web/docs/CONVENTIONS.md | 33 +++++++ apps/web/src/main.tsx | 19 +++- apps/web/src/routes.tsx | 179 +++++++++++++++-------------------- 4 files changed, 128 insertions(+), 104 deletions(-) diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 99e8e9e6ec2..ef8e2a04df9 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -40,6 +40,7 @@ pattern (`assistant/docs/`, `docs/` at the repo root). - **Route protection**: uses React Router v7 [middleware](https://reactrouter.com/how-to/middleware) (`v8_middleware` future flag), not layout gate components or `useEffect` redirects. Auth is always required — the middleware redirects unauthenticated users to `/account/login`. See [`docs/CONVENTIONS.md` — Route protection via middleware](./docs/CONVENTIONS.md#route-protection-via-middleware). - **Assistant lifecycle**: owned by `RootLayout` and passed down via [outlet context](https://reactrouter.com/start/framework/outlet). `ChatLayout` reads it via `useRootOutletContext()` and re-publishes the chat-scoped slice as `AssistantContextValue` for its own children. Routes under `ChatLayout` keep consuming the resolved `assistantId` via `useAssistantContext()` — never hardcode or independently resolve it. - **Active-assistant gating**: routes that require a working assistant (queries against `/v1/assistants/{id}/...`, anything that reads or writes per-assistant state) are mounted under `` in `src/routes.tsx`. The gate defers child rendering until `assistantId` is non-null AND `assistantState.kind === "active"`, then re-provides a narrowed outlet context. Inside the gate, call `useActiveAssistantContext()` instead of `useAssistantContext()` — the returned `assistantId` is typed `string` (non-null). **Do not add `if (!assistantId) return null;` guards in gated routes** — the gate makes them unreachable. Routes that intentionally render across non-active states (today: `ChatPage`, `DocumentViewerPage`) live outside the gate and keep using `useAssistantContext()`. +- **Code splitting**: routes use `Component` (not `element`) with the object-based [`lazy` property](https://reactrouter.com/start/data/route-object#lazy) for route-level code splitting. New routes should default to `lazy` unless they're on the primary landing path (chat). See [`docs/CONVENTIONS.md` — Route-level code splitting](./docs/CONVENTIONS.md#route-level-code-splitting). ## Commands diff --git a/apps/web/docs/CONVENTIONS.md b/apps/web/docs/CONVENTIONS.md index 4872bcb6cbd..6bc8d400ebc 100644 --- a/apps/web/docs/CONVENTIONS.md +++ b/apps/web/docs/CONVENTIONS.md @@ -65,6 +65,39 @@ References: - [React — Thinking in React](https://react.dev/learn/thinking-in-react) - [React Router — Layout Routes](https://reactrouter.com/start/framework/routing#layout-routes) +### Route-level code splitting + +Routes use `Component` (not `element`) and the object-based `lazy` +property for code splitting. Vite creates a separate chunk per dynamic +`import()`, so each lazy route loads only when navigated to. + +**Eager routes** (critical path — always in the initial bundle): +`RootLayout`, `ChatLayout`, `ChatPage`, `DocumentViewerPage`, +`ConversationRedirect`, `ActiveAssistantGate`, `NotFound`. + +**Lazy routes** (everything else): settings, logs, account/auth, +onboarding, intelligence pages, library, inspector, home, connect. + +```ts +// Lazy route — object syntax (preferred) +{ path: "settings", lazy: { Component: () => import("./settings-layout.js").then((m) => m.SettingsLayout) } } + +// Eager route — direct Component reference +{ path: "conversations/:conversationId", Component: ChatPage } +``` + +When adding a new route, default to `lazy` unless it's on the primary +landing path. Use `Component`, not `element` — they are mutually +exclusive and `lazy` returns `Component`. + +The `RouterProvider.onError` handler in `main.tsx` catches chunk load +failures (stale deploys, network errors) and triggers a page reload. + +References: +- [React Router — Route Object (`Component`)](https://reactrouter.com/start/data/route-object#component) +- [React Router — Lazy Loading (Data Mode)](https://reactrouter.com/start/data/custom#3-lazy-loading) +- [React Router — `lazy` property](https://reactrouter.com/start/data/route-object#lazy) + --- ## Code organization diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 76b7b0d6d59..53188a9e188 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -13,6 +13,14 @@ import "./index.css"; import { initSafeAreaBridge } from "@/runtime/native-safe-area.js"; +function isChunkLoadError(error: unknown): boolean { + if (error instanceof TypeError && /dynamically imported module|importing a module script/i.test(error.message)) { + return true; + } + const name = (error as { name?: string }).name; + return name === "ChunkLoadError" || name === "DynamicImportError"; +} + async function boot() { await initSafeAreaBridge(); @@ -26,7 +34,16 @@ async function boot() { createRoot(rootEl).render( - + { + if (isChunkLoadError(error)) { + window.location.reload(); + return; + } + console.error("[RouterProvider]", error); + }} + /> , ); diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 0a51b600430..9fddf08205e 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -6,86 +6,44 @@ import { ChatLayout } from "@/domains/chat/chat-layout.js"; import { ChatPage } from "@/domains/chat/chat-page.js"; import { ConversationRedirect } from "@/domains/chat/conversation-redirect.js"; import { DocumentViewerPage } from "@/domains/chat/document-viewer-page.js"; -import { HomePageRoute } from "@/home-page-route.js"; -import { LibraryPage } from "@/domains/library/library-page.js"; -import { LibraryDetailPage } from "@/domains/library/library-detail-page.js"; -import { IdentityPage } from "@/domains/intelligence/identity-page.js"; -import { IntelligenceLayout } from "@/domains/intelligence/intelligence-layout.js"; -import { PluginsPage } from "@/domains/intelligence/plugins-page.js"; -import { SkillsPage } from "@/domains/intelligence/skills-page.js"; -import { ConnectPage } from "@/domains/contacts/connect-page.js"; -import { ContactsPage } from "@/domains/contacts/contacts-page.js"; -import { WorkspacePage } from "@/domains/workspace/workspace-page.js"; -import { InspectPage } from "@/domains/chat/inspector/inspect-page.js"; -import { MemoryRouterPlaygroundPage } from "@/domains/chat/inspector/memory-router-playground-page.js"; import { NotFound } from "@/components/not-found.js"; -import { SettingsLayout } from "@/domains/settings/settings-layout.js"; -import { GeneralPage } from "@/domains/settings/pages/general-page.js"; -import { AiPage } from "@/domains/settings/ai/ai-page.js"; -import { IntegrationsPage } from "@/domains/settings/pages/integrations-page.js"; -import { SchedulesPage } from "@/domains/settings/pages/schedules-page.js"; -import { NotificationsPage } from "@/domains/settings/pages/notifications-page.js"; -import { SoundsPage } from "@/domains/settings/pages/sounds-page.js"; -import { VoicePage } from "@/domains/settings/pages/voice-page.js"; -import { DevicesPage } from "@/domains/settings/pages/devices-page.js"; -import { PrivacyPage } from "@/domains/settings/pages/privacy-page.js"; -import { ArchivePage } from "@/domains/settings/pages/archive-page.js"; -import { CommunityPage } from "@/domains/settings/pages/community-page.js"; -import { DebugPage } from "@/domains/settings/pages/debug-page.js"; -import { DeveloperPage } from "@/domains/settings/pages/developer-page.js"; -import { AdvancedPage } from "@/domains/settings/pages/advanced-page.js"; -import { BillingPage } from "@/domains/settings/billing/billing-page.js"; -import { UpgradeCancelPage } from "@/domains/settings/billing/upgrade-cancel-page.js"; -import { UpgradeSuccessPage } from "@/domains/settings/billing/upgrade-success-page.js"; -import { DangerZoneRedirectPage } from "@/domains/settings/pages/danger-zone-redirect-page.js"; -import { SystemEventsRedirectPage } from "@/domains/settings/pages/system-events-redirect-page.js"; -import { AccountPage } from "@/domains/account/pages/account-page.js"; -import { LoginPage } from "@/domains/account/pages/login-page.js"; -import { SignupPage } from "@/domains/account/pages/signup-page.js"; -import { ProviderCallbackPage } from "@/domains/account/pages/provider-callback-page.js"; -import { ProviderSignupPage } from "@/domains/account/pages/provider-signup-page.js"; -import { DesktopOAuthCompletePage } from "@/domains/account/pages/desktop-oauth-complete-page.js"; -import { LogoutPage } from "@/domains/account/pages/logout-page.js"; -import { OAuthPopupCompletePage } from "@/domains/account/pages/oauth-popup-complete-page.js"; -import { PasswordResetPage } from "@/domains/account/pages/password-reset-page.js"; import { ActiveAssistantGate } from "@/components/layout/active-assistant-gate.js"; -import { HatchingScreen } from "@/domains/onboarding/pages/hatching-screen.js"; -import { PreChatFlow } from "@/domains/onboarding/pages/pre-chat-flow.js"; -import { PrivacyScreen } from "@/domains/onboarding/pages/privacy-screen.js"; -import { LogsLayout } from "@/domains/logs/logs-layout.js"; -import { TracePage } from "@/domains/logs/pages/trace-page.js"; -import { UsagePage } from "@/domains/logs/pages/usage-page.js"; -import { SystemEventsPage } from "@/domains/logs/pages/system-events-page.js"; -import { EmailsPage } from "@/domains/logs/pages/emails-page.js"; // Route tree — no basename, routes are absolute browser paths. // To view the full hierarchy at a glance: // grep -n 'path:' apps/web/src/routes.tsx // +// Non-critical route groups use the object-based `lazy` property so Vite +// splits them into separate chunks that are fetched on first navigation. +// The router resolves each lazy property before transitioning, so the +// previous route stays visible while the new chunk downloads — no flash. +// // References: // - React Router data mode routing: https://reactrouter.com/start/data/routing // - React Router route object: https://reactrouter.com/start/data/route-object +// - React Router lazy (data mode): https://reactrouter.com/start/data/custom#3-lazy-loading // - React Router middleware: https://reactrouter.com/how-to/middleware export const router = createBrowserRouter( [ - // Account routes — standalone auth pages, no app chrome + // Account routes — standalone auth pages, no app chrome. + // Lazy-loaded: only needed for unauthenticated flows. { path: "/account", children: [ - { index: true, Component: AccountPage }, - { path: "login", Component: LoginPage }, - { path: "signup", Component: SignupPage }, - { path: "provider/callback", Component: ProviderCallbackPage }, - { path: "provider/signup", Component: ProviderSignupPage }, - { path: "oauth/popup-complete", Component: OAuthPopupCompletePage }, - { path: "oauth/desktop-complete", Component: DesktopOAuthCompletePage }, - { path: "password/reset", Component: PasswordResetPage }, - { path: "password/reset/key/:key", Component: PasswordResetPage }, + { index: true, lazy: { Component: () => import("@/domains/account/pages/account-page.js").then((m) => m.AccountPage) } }, + { path: "login", lazy: { Component: () => import("@/domains/account/pages/login-page.js").then((m) => m.LoginPage) } }, + { path: "signup", lazy: { Component: () => import("@/domains/account/pages/signup-page.js").then((m) => m.SignupPage) } }, + { path: "provider/callback", lazy: { Component: () => import("@/domains/account/pages/provider-callback-page.js").then((m) => m.ProviderCallbackPage) } }, + { path: "provider/signup", lazy: { Component: () => import("@/domains/account/pages/provider-signup-page.js").then((m) => m.ProviderSignupPage) } }, + { path: "oauth/popup-complete", lazy: { Component: () => import("@/domains/account/pages/oauth-popup-complete-page.js").then((m) => m.OAuthPopupCompletePage) } }, + { path: "oauth/desktop-complete", lazy: { Component: () => import("@/domains/account/pages/desktop-oauth-complete-page.js").then((m) => m.DesktopOAuthCompletePage) } }, + { path: "password/reset", lazy: { Component: () => import("@/domains/account/pages/password-reset-page.js").then((m) => m.PasswordResetPage) } }, + { path: "password/reset/key/:key", lazy: { Component: () => import("@/domains/account/pages/password-reset-page.js").then((m) => m.PasswordResetPage) } }, ], }, // Logout — standalone page, no app chrome - { path: "/logout", Component: LogoutPage }, + { path: "/logout", lazy: { Component: () => import("@/domains/account/pages/logout-page.js").then((m) => m.LogoutPage) } }, // Assistant routes — auth-protected app with layout { @@ -93,52 +51,64 @@ export const router = createBrowserRouter( middleware: [authMiddleware], Component: RootLayout, children: [ - // Onboarding routes — full-screen (no ChatLayout sidebar) - { path: "onboarding/privacy", Component: PrivacyScreen }, - { path: "onboarding/prechat", Component: PreChatFlow }, - { path: "onboarding/hatching", Component: HatchingScreen }, + // Onboarding routes — full-screen (no ChatLayout sidebar). + // Lazy-loaded: one-time flow, not revisited. + { + path: "onboarding/privacy", + lazy: { Component: () => import("@/domains/onboarding/pages/privacy-screen.js").then((m) => m.PrivacyScreen) }, + }, + { + path: "onboarding/prechat", + lazy: { Component: () => import("@/domains/onboarding/pages/pre-chat-flow.js").then((m) => m.PreChatFlow) }, + }, + { + path: "onboarding/hatching", + lazy: { Component: () => import("@/domains/onboarding/pages/hatching-screen.js").then((m) => m.HatchingScreen) }, + }, // Settings routes — full-screen overlay panel (no ChatLayout sidebar). // SettingsShell provides its own layout with back-arrow, sidebar nav, // and content area — the main app sidebar is intentionally hidden. + // Lazy-loaded: visited occasionally, heavy deps (Stripe, schedules, voice). { path: "settings", - Component: SettingsLayout, + lazy: { Component: () => import("@/domains/settings/settings-layout.js").then((m) => m.SettingsLayout) }, children: [ - { index: true, Component: GeneralPage }, - { path: "general", Component: GeneralPage }, - { path: "ai", Component: AiPage }, - { path: "integrations", Component: IntegrationsPage }, - { path: "schedules", Component: SchedulesPage }, - { path: "notifications", Component: NotificationsPage }, - { path: "sounds", Component: SoundsPage }, - { path: "voice", Component: VoicePage }, - { path: "devices", Component: DevicesPage }, - { path: "privacy", Component: PrivacyPage }, - { path: "archive", Component: ArchivePage }, - { path: "billing", Component: BillingPage }, - { path: "billing/upgrade/cancel", Component: UpgradeCancelPage }, - { path: "billing/upgrade/success", Component: UpgradeSuccessPage }, - { path: "community", Component: CommunityPage }, - { path: "debug", Component: DebugPage }, - { path: "developer", Component: DeveloperPage }, - { path: "advanced", Component: AdvancedPage }, - { path: "danger-zone", Component: DangerZoneRedirectPage }, - { path: "system-events", Component: SystemEventsRedirectPage }, + { index: true, lazy: { Component: () => import("@/domains/settings/pages/general-page.js").then((m) => m.GeneralPage) } }, + { path: "general", lazy: { Component: () => import("@/domains/settings/pages/general-page.js").then((m) => m.GeneralPage) } }, + { path: "ai", lazy: { Component: () => import("@/domains/settings/ai/ai-page.js").then((m) => m.AiPage) } }, + { path: "integrations", lazy: { Component: () => import("@/domains/settings/pages/integrations-page.js").then((m) => m.IntegrationsPage) } }, + { path: "schedules", lazy: { Component: () => import("@/domains/settings/pages/schedules-page.js").then((m) => m.SchedulesPage) } }, + { path: "notifications", lazy: { Component: () => import("@/domains/settings/pages/notifications-page.js").then((m) => m.NotificationsPage) } }, + { path: "sounds", lazy: { Component: () => import("@/domains/settings/pages/sounds-page.js").then((m) => m.SoundsPage) } }, + { path: "voice", lazy: { Component: () => import("@/domains/settings/pages/voice-page.js").then((m) => m.VoicePage) } }, + { path: "devices", lazy: { Component: () => import("@/domains/settings/pages/devices-page.js").then((m) => m.DevicesPage) } }, + { path: "privacy", lazy: { Component: () => import("@/domains/settings/pages/privacy-page.js").then((m) => m.PrivacyPage) } }, + { path: "archive", lazy: { Component: () => import("@/domains/settings/pages/archive-page.js").then((m) => m.ArchivePage) } }, + { path: "billing", lazy: { Component: () => import("@/domains/settings/billing/billing-page.js").then((m) => m.BillingPage) } }, + { path: "billing/upgrade/cancel", lazy: { Component: () => import("@/domains/settings/billing/upgrade-cancel-page.js").then((m) => m.UpgradeCancelPage) } }, + { path: "billing/upgrade/success", lazy: { Component: () => import("@/domains/settings/billing/upgrade-success-page.js").then((m) => m.UpgradeSuccessPage) } }, + { path: "community", lazy: { Component: () => import("@/domains/settings/pages/community-page.js").then((m) => m.CommunityPage) } }, + { path: "debug", lazy: { Component: () => import("@/domains/settings/pages/debug-page.js").then((m) => m.DebugPage) } }, + { path: "developer", lazy: { Component: () => import("@/domains/settings/pages/developer-page.js").then((m) => m.DeveloperPage) } }, + { path: "advanced", lazy: { Component: () => import("@/domains/settings/pages/advanced-page.js").then((m) => m.AdvancedPage) } }, + { path: "danger-zone", lazy: { Component: () => import("@/domains/settings/pages/danger-zone-redirect-page.js").then((m) => m.DangerZoneRedirectPage) } }, + { path: "system-events", lazy: { Component: () => import("@/domains/settings/pages/system-events-redirect-page.js").then((m) => m.SystemEventsRedirectPage) } }, ], }, // Logs routes — full-screen overlay panel (like SettingsLayout). // LogsLayout reuses SettingsShell for visual consistency. + // Lazy-loaded: analytics-only, pulls in recharts. { path: "logs", - Component: LogsLayout, + lazy: { Component: () => import("@/domains/logs/logs-layout.js").then((m) => m.LogsLayout) }, children: [ - { index: true, Component: UsagePage }, - { path: "trace", Component: TracePage }, - { path: "usage", Component: UsagePage }, - { path: "system-events", Component: SystemEventsPage }, - { path: "emails", Component: EmailsPage }, + { index: true, lazy: { Component: () => import("@/domains/logs/pages/usage-page.js").then((m) => m.UsagePage) } }, + { path: "trace", lazy: { Component: () => import("@/domains/logs/pages/trace-page.js").then((m) => m.TracePage) } }, + { path: "usage", lazy: { Component: () => import("@/domains/logs/pages/usage-page.js").then((m) => m.UsagePage) } }, + { path: "system-events", lazy: { Component: () => import("@/domains/logs/pages/system-events-page.js").then((m) => m.SystemEventsPage) } }, + { path: "emails", lazy: { Component: () => import("@/domains/logs/pages/emails-page.js").then((m) => m.EmailsPage) } }, ], }, @@ -159,27 +129,30 @@ export const router = createBrowserRouter( { Component: ActiveAssistantGate, children: [ - { path: "home", Component: HomePageRoute }, { - Component: IntelligenceLayout, + path: "home", + lazy: { Component: () => import("@/home-page-route.js").then((m) => m.HomePageRoute) }, + }, + { + lazy: { Component: () => import("@/domains/intelligence/intelligence-layout.js").then((m) => m.IntelligenceLayout) }, children: [ - { path: "identity", Component: IdentityPage }, - { path: "plugins", Component: PluginsPage }, - { path: "skills", Component: SkillsPage }, - { path: "workspace", Component: WorkspacePage }, - { path: "contacts", Component: ContactsPage }, + { path: "identity", lazy: { Component: () => import("@/domains/intelligence/identity-page.js").then((m) => m.IdentityPage) } }, + { path: "plugins", lazy: { Component: () => import("@/domains/intelligence/plugins-page.js").then((m) => m.PluginsPage) } }, + { path: "skills", lazy: { Component: () => import("@/domains/intelligence/skills-page.js").then((m) => m.SkillsPage) } }, + { path: "workspace", lazy: { Component: () => import("@/domains/workspace/workspace-page.js").then((m) => m.WorkspacePage) } }, + { path: "contacts", lazy: { Component: () => import("@/domains/contacts/contacts-page.js").then((m) => m.ContactsPage) } }, ], }, - { path: "library", Component: LibraryPage }, - { path: "library/:appId", Component: LibraryDetailPage }, - { path: "connect", Component: ConnectPage }, + { path: "library", lazy: { Component: () => import("@/domains/library/library-page.js").then((m) => m.LibraryPage) } }, + { path: "library/:appId", lazy: { Component: () => import("@/domains/library/library-detail-page.js").then((m) => m.LibraryDetailPage) } }, + { path: "connect", lazy: { Component: () => import("@/domains/contacts/connect-page.js").then((m) => m.ConnectPage) } }, { path: "conversations/:conversationId/inspect", - Component: InspectPage, + lazy: { Component: () => import("@/domains/chat/inspector/inspect-page.js").then((m) => m.InspectPage) }, }, { path: "memory-router-playground", - Component: MemoryRouterPlaygroundPage, + lazy: { Component: () => import("@/domains/chat/inspector/memory-router-playground-page.js").then((m) => m.MemoryRouterPlaygroundPage) }, }, ], }, From dc8c94453f41961e8490512086249665dec1ad39 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 16:15:08 +0000 Subject: [PATCH 2/4] fix(web): guard chunk load error handler against infinite reload loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add sessionStorage counter capping reload attempts at 2. After exhausting retries, fall through to console.error so the app remains usable during persistent chunk failures (network outage, CDN issue). Also guard isChunkLoadError against non-object thrown values — onError receives unknown, so null/undefined must not reach the property access. Co-Authored-By: ashlee@vellum.ai --- apps/web/src/main.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 53188a9e188..6d1d9d337a7 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -17,6 +17,7 @@ function isChunkLoadError(error: unknown): boolean { if (error instanceof TypeError && /dynamically imported module|importing a module script/i.test(error.message)) { return true; } + if (typeof error !== "object" || error == null) return false; const name = (error as { name?: string }).name; return name === "ChunkLoadError" || name === "DynamicImportError"; } @@ -38,8 +39,14 @@ async function boot() { router={router} onError={(error) => { if (isChunkLoadError(error)) { - window.location.reload(); - return; + const key = "vellum:chunk-reload-count"; + const count = Number(sessionStorage.getItem(key) || 0); + if (count < 2) { + sessionStorage.setItem(key, String(count + 1)); + window.location.reload(); + return; + } + sessionStorage.removeItem(key); } console.error("[RouterProvider]", error); }} From edb584267e30b209777656b4c5aaa80131040724 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 19:13:47 +0000 Subject: [PATCH 3/4] refactor(web): replace hacky chunk error handler with route-level ErrorBoundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove isChunkLoadError heuristic, sessionStorage reload counter, and control-flow logic from RouterProvider.onError. These are replaced by React Router v7's first-class ErrorBoundary route property — the documented, recommended way to handle route errors in data mode. Changes: - Add RootErrorBoundary component using useRouteError() / isRouteErrorResponse() - Add ErrorBoundary: RootErrorBoundary to all root route objects (/account, /assistant, /logout, top-level catch-all) - Simplify onError to logging-only (its intended purpose per the docs) References: - https://reactrouter.com/how-to/error-boundary - https://reactrouter.com/api/data-routers/RouterProvider Co-Authored-By: ashlee@vellum.ai --- .../src/components/root-error-boundary.tsx | 41 +++++++++++++++++++ apps/web/src/main.tsx | 19 --------- apps/web/src/routes.tsx | 8 +++- 3 files changed, 47 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/root-error-boundary.tsx diff --git a/apps/web/src/components/root-error-boundary.tsx b/apps/web/src/components/root-error-boundary.tsx new file mode 100644 index 00000000000..62672ef1c6d --- /dev/null +++ b/apps/web/src/components/root-error-boundary.tsx @@ -0,0 +1,41 @@ +import { useRouteError, isRouteErrorResponse } from "react-router"; + +import { Button } from "@vellum/design-library/components/button"; + +/** + * Root error boundary rendered by React Router when any unhandled error + * occurs during route resolution (including lazy chunk load failures), + * loader execution, or component rendering. + * + * Uses `useRouteError()` — the React Router v7 data-mode API for + * accessing the caught error inside an `ErrorBoundary` route property. + * + * References: + * - https://reactrouter.com/how-to/error-boundary + * - https://reactrouter.com/start/data/route-object + */ +export function RootErrorBoundary() { + const error = useRouteError(); + + const status = isRouteErrorResponse(error) ? error.status : undefined; + const heading = status === 404 ? "Page not found" : "Something went wrong"; + const message = + status === 404 + ? "The page you requested doesn't exist." + : "An unexpected error occurred. Try reloading the page."; + + return ( +
+

+ {heading} +

+

{message}

+ +
+ ); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 6d1d9d337a7..d63cd3491d3 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -13,15 +13,6 @@ import "./index.css"; import { initSafeAreaBridge } from "@/runtime/native-safe-area.js"; -function isChunkLoadError(error: unknown): boolean { - if (error instanceof TypeError && /dynamically imported module|importing a module script/i.test(error.message)) { - return true; - } - if (typeof error !== "object" || error == null) return false; - const name = (error as { name?: string }).name; - return name === "ChunkLoadError" || name === "DynamicImportError"; -} - async function boot() { await initSafeAreaBridge(); @@ -38,16 +29,6 @@ async function boot() { { - if (isChunkLoadError(error)) { - const key = "vellum:chunk-reload-count"; - const count = Number(sessionStorage.getItem(key) || 0); - if (count < 2) { - sessionStorage.setItem(key, String(count + 1)); - window.location.reload(); - return; - } - sessionStorage.removeItem(key); - } console.error("[RouterProvider]", error); }} /> diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 9fddf08205e..6800983f3d1 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -7,6 +7,7 @@ import { ChatPage } from "@/domains/chat/chat-page.js"; import { ConversationRedirect } from "@/domains/chat/conversation-redirect.js"; import { DocumentViewerPage } from "@/domains/chat/document-viewer-page.js"; import { NotFound } from "@/components/not-found.js"; +import { RootErrorBoundary } from "@/components/root-error-boundary.js"; import { ActiveAssistantGate } from "@/components/layout/active-assistant-gate.js"; // Route tree — no basename, routes are absolute browser paths. @@ -22,6 +23,7 @@ import { ActiveAssistantGate } from "@/components/layout/active-assistant-gate.j // - React Router data mode routing: https://reactrouter.com/start/data/routing // - React Router route object: https://reactrouter.com/start/data/route-object // - React Router lazy (data mode): https://reactrouter.com/start/data/custom#3-lazy-loading +// - React Router error boundaries: https://reactrouter.com/how-to/error-boundary // - React Router middleware: https://reactrouter.com/how-to/middleware export const router = createBrowserRouter( [ @@ -29,6 +31,7 @@ export const router = createBrowserRouter( // Lazy-loaded: only needed for unauthenticated flows. { path: "/account", + ErrorBoundary: RootErrorBoundary, children: [ { index: true, lazy: { Component: () => import("@/domains/account/pages/account-page.js").then((m) => m.AccountPage) } }, { path: "login", lazy: { Component: () => import("@/domains/account/pages/login-page.js").then((m) => m.LoginPage) } }, @@ -43,12 +46,13 @@ export const router = createBrowserRouter( }, // Logout — standalone page, no app chrome - { path: "/logout", lazy: { Component: () => import("@/domains/account/pages/logout-page.js").then((m) => m.LogoutPage) } }, + { path: "/logout", ErrorBoundary: RootErrorBoundary, lazy: { Component: () => import("@/domains/account/pages/logout-page.js").then((m) => m.LogoutPage) } }, // Assistant routes — auth-protected app with layout { path: "/assistant", middleware: [authMiddleware], + ErrorBoundary: RootErrorBoundary, Component: RootLayout, children: [ // Onboarding routes — full-screen (no ChatLayout sidebar). @@ -165,7 +169,7 @@ export const router = createBrowserRouter( }, // Top-level catch-all - { path: "*", Component: NotFound }, + { path: "*", ErrorBoundary: RootErrorBoundary, Component: NotFound }, ], { future: { v8_middleware: true }, From 638a852706824905051730fbc80db643ca241722 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 19:45:17 +0000 Subject: [PATCH 4/4] refactor(web): use Sentry.captureException in RouterProvider onError --- apps/web/src/main.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index d63cd3491d3..25019e5e41e 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { RouterProvider } from "react-router"; +import * as Sentry from "@sentry/react"; import { useAuthStore, setupAuthListeners } from "@/stores/auth-store.js"; import { setupOrganizationStore } from "@/stores/organization-store.js"; @@ -29,7 +30,9 @@ async function boot() { { - console.error("[RouterProvider]", error); + Sentry.captureException(error, { + tags: { context: "RouterProvider" }, + }); }} />