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 (
+
+ );
+ }
+}
+
+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}
+
+
+