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} + + +