From b843b89ca46cef2aa93bdd3e4ec2a5f9e5e16f3f Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 2 May 2026 03:40:45 -0400 Subject: [PATCH] feat(frontend): add LazyBoundary for chunk-load failure recovery (#875) * feat(frontend): add LazyBoundary for chunk-load failure recovery When a lazy chunk fetch fails, the existing top-level ErrorBoundary shows "Something went wrong" with a "Try again" CTA. "Try again" cannot succeed against a chunk URL that no longer exists on the server (typical post- deploy case where the user's tab was opened against an older bundle). The right remedy is to reload the tab so the browser fetches the new hashed chunks emitted by the current build. Add LazyBoundary, a section-local error boundary that: - Detects chunk-load errors via a substring union covering Chrome / Edge ("Failed to fetch dynamically imported module"), Safari ("Importing a module script failed"), Firefox ("disallowed MIME type" thrown when a deploy serves SPA index.html for a missing chunk URL), older Webpack ("Error loading dynamically imported module"), and Vite ("Loading chunk/CSS chunk N failed"). - For chunk errors, renders a glass-card matching the LockCard aesthetic with an AlertTriangle icon, a "This part of Sencho needs a reload" message, and a Reload CTA that calls window.location.reload(). - For non-chunk runtime errors, falls back to "Something went wrong" + the error message + a Try again CTA. Try again is safe on this path because the lazy import has already resolved before the render error fires. - Logs to console.error in componentDidCatch so the underlying failure is still observable. - Has role="alert" so screen readers announce the failure. Wrap every existing Suspense site (1 in SettingsPage, 7 in EditorLayout including the security-history overlay, 1 in ResourcesView) with LazyBoundary. The top-level ErrorBoundary remains the catch-all for errors that escape the section-local boundary. Includes a unit test enumerating each browser's documented chunk-load message so a regression in any one runtime is caught early. * fix(frontend): move isChunkLoadError to its own file to satisfy react-refresh/only-export-components --- frontend/src/components/EditorLayout.tsx | 91 +++++++++++-------- frontend/src/components/LazyBoundary.tsx | 80 ++++++++++++++++ frontend/src/components/ResourcesView.tsx | 31 ++++--- .../components/__tests__/LazyBoundary.test.ts | 59 ++++++++++++ frontend/src/components/isChunkLoadError.ts | 30 ++++++ .../src/components/settings/SettingsPage.tsx | 18 ++-- 6 files changed, 251 insertions(+), 58 deletions(-) create mode 100644 frontend/src/components/LazyBoundary.tsx create mode 100644 frontend/src/components/__tests__/LazyBoundary.test.ts create mode 100644 frontend/src/components/isChunkLoadError.ts diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 89fedf83..5d461c7f 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -8,6 +8,7 @@ import ErrorBoundary from './ErrorBoundary'; import HomeDashboard from './HomeDashboard'; import type { NotificationItem } from './dashboard/types'; import BashExecModal from './BashExecModal'; +import LazyBoundary from './LazyBoundary'; import { Skeleton } from '@/components/ui/skeleton'; import { AdmiralGate } from './AdmiralGate'; import { CapabilityGate } from './CapabilityGate'; @@ -2523,9 +2524,11 @@ export default function EditorLayout() { ) : activeView === 'host-console' ? ( - }> - setActiveView(selectedFile ? 'editor' : 'dashboard')} /> - + + }> + setActiveView(selectedFile ? 'editor' : 'dashboard')} /> + + ) : !isLoading && selectedFile && activeView === 'editor' ? ( @@ -3064,47 +3067,57 @@ export default function EditorLayout() { ) : activeView === 'global-observability' ? ( - }> - - + + }> + + + ) : activeView === 'fleet' ? ( - }> - { - const node = nodes.find(n => n.id === nodeId); - if (node) { - if (activeNode?.id === nodeId) { - loadFile(stackName); - } else { - pendingStackLoadRef.current = stackName; - setActiveNode(node); + + }> + { + const node = nodes.find(n => n.id === nodeId); + if (node) { + if (activeNode?.id === nodeId) { + loadFile(stackName); + } else { + pendingStackLoadRef.current = stackName; + setActiveNode(node); + } } - } - }} /> - + }} /> + + ) : activeView === 'audit-log' ? ( - }> - - + + }> + + + ) : activeView === 'auto-updates' ? ( - }> - - + + }> + + + ) : activeView === 'scheduled-ops' ? ( - }> - setFilterNodeId(null)} - prefill={schedulePrefill} - onPrefillConsumed={handlePrefillConsumed} - /> - + + }> + setFilterNodeId(null)} + prefill={schedulePrefill} + onPrefillConsumed={handlePrefillConsumed} + /> + + ) : ( - setSecurityHistoryOpen(false)} - /> - + + + setSecurityHistoryOpen(false)} + /> + + ) : null} diff --git a/frontend/src/components/LazyBoundary.tsx b/frontend/src/components/LazyBoundary.tsx new file mode 100644 index 00000000..93e150ba --- /dev/null +++ b/frontend/src/components/LazyBoundary.tsx @@ -0,0 +1,80 @@ +import { Component } from 'react'; +import type { ErrorInfo, ReactNode } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { isChunkLoadError } from './isChunkLoadError'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +/** + * Specialized error boundary for lazy-loaded subtrees. Catches errors + * thrown during chunk fetch and renders a small card inviting the user + * to reload. Reload is the actual remedy because the stale tab is asking + * for chunk URLs that the deployed bundle no longer emits; a "Try again" + * against the same URL would just fail again. + * + * For non-chunk runtime errors (the lazy module loaded but threw during + * render) the boundary falls back to a "Try again" CTA that resets state + * and re-renders the children. This is safe because the lazy import has + * already resolved on this path, so re-rendering does not re-trigger a + * chunk fetch; if the underlying error is non-deterministic (e.g. a stale + * API response) the retry can succeed. If the error is deterministic the + * card just reappears, which is the expected behavior of any boundary. + * + * Sized via min-h-[280px] to look at home in both the workspace area + * and inline-section contexts that wrap lazy components. + */ +class LazyBoundary extends Component { + public state: State = { + hasError: false, + error: null, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('LazyBoundary caught an error:', error, errorInfo); + } + + public render() { + if (!this.state.hasError) return this.props.children; + + const isChunk = isChunkLoadError(this.state.error); + const title = isChunk ? 'This part of Sencho needs a reload' : 'Something went wrong'; + const body = isChunk + ? 'A newer version may have shipped while this tab was open. Reload to fetch the latest.' + : this.state.error?.message || 'Unknown error'; + const ctaLabel = isChunk ? 'Reload' : 'Try again'; + const onCta = isChunk + ? () => window.location.reload() + : () => this.setState({ hasError: false, error: null }); + + return ( +
+
+
+ +
+
+

{title}

+

{body}

+
+ +
+
+ ); + } +} + +export default LazyBoundary; diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index fbddcdf8..d663300b 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -29,6 +29,7 @@ import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { PaidGate } from './PaidGate'; import { CapabilityGate } from './CapabilityGate'; +import LazyBoundary from './LazyBoundary'; import { formatBytes } from '@/lib/utils'; import { cn } from '@/lib/utils'; import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; @@ -1022,20 +1023,22 @@ export default function ResourcesView() {
- - Loading topology... -
- }> - { - window.dispatchEvent(new CustomEvent(SENCHO_OPEN_LOGS_EVENT, { - detail: { containerId: id, containerName: name }, - })); - }} - /> - + + + Loading topology... + + }> + { + window.dispatchEvent(new CustomEvent(SENCHO_OPEN_LOGS_EVENT, { + detail: { containerId: id, containerName: name }, + })); + }} + /> + + diff --git a/frontend/src/components/__tests__/LazyBoundary.test.ts b/frontend/src/components/__tests__/LazyBoundary.test.ts new file mode 100644 index 00000000..522593bc --- /dev/null +++ b/frontend/src/components/__tests__/LazyBoundary.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { isChunkLoadError } from '../isChunkLoadError'; + +/** + * isChunkLoadError is a substring union over the messages browsers emit + * when a dynamically-imported chunk URL is no longer reachable. The heuristic + * is the entire feature: a missed variant routes a stale-tab user to a + * misleading "Try again" CTA instead of "Reload", and "Try again" can never + * succeed against a chunk URL that no longer exists. Keep this fixture in + * sync with the documented browser variants in LazyBoundary.tsx. + */ +describe('isChunkLoadError', () => { + it('returns false for null', () => { + expect(isChunkLoadError(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isChunkLoadError(undefined)).toBe(false); + }); + + it('returns false for unrelated runtime errors', () => { + expect(isChunkLoadError(new Error('Cannot read properties of undefined'))).toBe(false); + expect(isChunkLoadError(new Error('Maximum update depth exceeded'))).toBe(false); + expect(isChunkLoadError(new Error('Network request failed'))).toBe(false); + }); + + it('matches Chrome / Edge "Failed to fetch dynamically imported module"', () => { + const err = new Error('Failed to fetch dynamically imported module: https://app.example/assets/FleetView-abc123.js'); + expect(isChunkLoadError(err)).toBe(true); + }); + + it('matches Safari "Importing a module script failed."', () => { + expect(isChunkLoadError(new Error('Importing a module script failed.'))).toBe(true); + }); + + it('matches Firefox MIME-type variant produced when a deploy serves index.html for a missing chunk', () => { + const err = new Error( + 'Loading module from "https://app.example/assets/FleetView-abc123.js" was blocked because of a disallowed MIME type ("text/html").', + ); + expect(isChunkLoadError(err)).toBe(true); + }); + + it('matches older Webpack "Error loading dynamically imported module"', () => { + expect(isChunkLoadError(new Error('Error loading dynamically imported module'))).toBe(true); + }); + + it('matches Vite "Loading chunk N failed."', () => { + expect(isChunkLoadError(new Error('Loading chunk 42 failed.'))).toBe(true); + }); + + it('matches Vite "Loading CSS chunk N failed"', () => { + expect(isChunkLoadError(new Error('Loading CSS chunk 42 failed'))).toBe(true); + }); + + it('is case-insensitive', () => { + expect(isChunkLoadError(new Error('FAILED TO FETCH DYNAMICALLY IMPORTED MODULE'))).toBe(true); + expect(isChunkLoadError(new Error('Loading Chunk 12 Failed'))).toBe(true); + }); +}); diff --git a/frontend/src/components/isChunkLoadError.ts b/frontend/src/components/isChunkLoadError.ts new file mode 100644 index 00000000..db722eaa --- /dev/null +++ b/frontend/src/components/isChunkLoadError.ts @@ -0,0 +1,30 @@ +/** + * Recognises the error messages browsers throw when a dynamically-imported + * chunk URL is no longer reachable. Each runtime worded the failure + * differently, so the heuristic is a substring union rather than a single + * regex; broadening to a generic "module + fail" match would over-fire on + * legitimate runtime errors and route them to a misleading Reload CTA. + * + * Currently covered: + * - Chrome / Edge: "Failed to fetch dynamically imported module: " + * - Safari: "Importing a module script failed." + * - Firefox: "Loading module from \"\" was blocked because of a disallowed MIME type (\"text/html\")." + * (fired when a deploy serves the SPA index.html for a missing chunk URL) + * - Older Webpack: "Error loading dynamically imported module" + * - Vite: "Loading chunk N failed." / "Loading CSS chunk N failed" + * + * Add new substrings here as new browser variants surface; cover them in + * the matching unit test so a regression in one runtime is caught early. + */ +export function isChunkLoadError(error: Error | null | undefined): boolean { + if (!error) return false; + const msg = error.message.toLowerCase(); + return ( + msg.includes('failed to fetch dynamically imported module') || + msg.includes('importing a module script failed') || + msg.includes('error loading dynamically imported module') || + msg.includes('disallowed mime type') || + msg.includes('loading chunk') || + msg.includes('loading css chunk') + ); +} diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index e64f3873..43fc4f42 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -33,6 +33,7 @@ import { isItemLocked, } from './index'; import type { SectionId, SettingsItemMeta, VisibilityContext } from './index'; +import LazyBoundary from '../LazyBoundary'; import { SectionGate } from './SectionGate'; import { SettingsSidebar } from './SettingsSidebar'; import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext'; @@ -254,12 +255,17 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp ) : null} {/* Suspense outside SectionGate so the locked-tier path (which never mounts the lazy children) - does not see a fallback flash. */} - }> - - {sectionElement} - - + does not see a fallback flash. LazyBoundary + outside Suspense catches chunk-fetch failures + so a stale tab spans-deploy mismatch shows a + Reload card instead of crashing the workspace. */} + + }> + + {sectionElement} + + +