From c4dd55ace0cc51b6d2f9e203cf0a874602f10551 Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Mon, 20 Apr 2026 21:46:46 +0800 Subject: [PATCH 01/15] feat: add connection state store with health polling --- apps/web/src/stores/connection-store.ts | 86 ++++++++++++++++++++ tests/unit/web/connection-store.test.ts | 101 ++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 apps/web/src/stores/connection-store.ts create mode 100644 tests/unit/web/connection-store.test.ts diff --git a/apps/web/src/stores/connection-store.ts b/apps/web/src/stores/connection-store.ts new file mode 100644 index 00000000..cb9159e9 --- /dev/null +++ b/apps/web/src/stores/connection-store.ts @@ -0,0 +1,86 @@ +import { create } from "zustand"; + +type ConnectionStatus = "connected" | "disconnected" | "reconnected" | "offline"; + +interface ConnectionState { + status: ConnectionStatus; + failedSince: number | null; + lastHealthCheck: number | null; + + setDisconnected: () => void; + setOffline: () => void; + setOnline: () => void; + checkHealth: () => Promise; + startPolling: () => void; + stopPolling: () => void; + refreshStaleData: () => Promise; +} + +let pollingInterval: ReturnType | null = null; + +export const useConnectionStore = create((set, get) => ({ + status: "connected", + failedSince: null, + lastHealthCheck: null, + + setDisconnected: () => { + const current = get(); + if (current.status === "disconnected") return; + set({ + status: "disconnected", + failedSince: current.failedSince ?? Date.now(), + }); + }, + + setOffline: () => { + set({ status: "offline", failedSince: get().failedSince ?? Date.now() }); + }, + + setOnline: () => { + if (get().status !== "offline") return; + set({ status: "disconnected" }); + }, + + checkHealth: async () => { + try { + const res = await fetch("/api/v1/health"); + if (res.ok) { + const current = get().status; + if (current === "disconnected" || current === "offline") { + set({ status: "reconnected", lastHealthCheck: Date.now(), failedSince: null }); + } else { + set({ lastHealthCheck: Date.now() }); + } + } + } catch { + if (get().status === "connected") { + get().setDisconnected(); + } + } + }, + + startPolling: () => { + if (pollingInterval) return; + pollingInterval = setInterval(() => { + get().checkHealth(); + }, 3000); + }, + + stopPolling: () => { + if (pollingInterval) { + clearInterval(pollingInterval); + pollingInterval = null; + } + }, + + refreshStaleData: async () => { + const { useSettingsStore } = await import("@/stores/settings-store"); + const { useFeaturesStore } = await import("@/stores/features-store"); + + useSettingsStore.setState({ loaded: false }); + await Promise.allSettled([ + useSettingsStore.getState().fetch(), + useFeaturesStore.getState().refresh(), + ]); + }, +})); diff --git a/tests/unit/web/connection-store.test.ts b/tests/unit/web/connection-store.test.ts new file mode 100644 index 00000000..601d435a --- /dev/null +++ b/tests/unit/web/connection-store.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +import { useConnectionStore } from "@/stores/connection-store"; + +function okHealth() { + return Promise.resolve(new Response(JSON.stringify({ status: "healthy" }), { status: 200 })); +} + +function failHealth() { + return Promise.reject(new TypeError("Failed to fetch")); +} + +describe("connection-store", () => { + beforeEach(() => { + vi.useFakeTimers(); + useConnectionStore.setState({ + status: "connected", + failedSince: null, + lastHealthCheck: null, + }); + fetchMock.mockReset(); + }); + + afterEach(() => { + useConnectionStore.getState().stopPolling(); + vi.useRealTimers(); + }); + + it("starts in connected state", () => { + expect(useConnectionStore.getState().status).toBe("connected"); + }); + + it("transitions to disconnected on setDisconnected", () => { + useConnectionStore.getState().setDisconnected(); + const state = useConnectionStore.getState(); + expect(state.status).toBe("disconnected"); + expect(state.failedSince).toBeTypeOf("number"); + }); + + it("does not overwrite failedSince on repeated setDisconnected calls", () => { + useConnectionStore.getState().setDisconnected(); + const first = useConnectionStore.getState().failedSince; + useConnectionStore.getState().setDisconnected(); + expect(useConnectionStore.getState().failedSince).toBe(first); + }); + + it("transitions to offline on setOffline", () => { + useConnectionStore.getState().setOffline(); + expect(useConnectionStore.getState().status).toBe("offline"); + }); + + it("transitions from offline to disconnected on setOnline", () => { + useConnectionStore.getState().setOffline(); + useConnectionStore.getState().setOnline(); + expect(useConnectionStore.getState().status).toBe("disconnected"); + }); + + it("checkHealth transitions disconnected → reconnected on success", async () => { + fetchMock.mockImplementation(okHealth); + useConnectionStore.getState().setDisconnected(); + await useConnectionStore.getState().checkHealth(); + expect(useConnectionStore.getState().status).toBe("reconnected"); + expect(useConnectionStore.getState().lastHealthCheck).toBeTypeOf("number"); + }); + + it("checkHealth stays disconnected on failure", async () => { + fetchMock.mockImplementation(failHealth); + useConnectionStore.getState().setDisconnected(); + await useConnectionStore.getState().checkHealth(); + expect(useConnectionStore.getState().status).toBe("disconnected"); + }); + + it("checkHealth is a no-op when already connected", async () => { + fetchMock.mockImplementation(okHealth); + await useConnectionStore.getState().checkHealth(); + expect(useConnectionStore.getState().status).toBe("connected"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("startPolling is idempotent", () => { + useConnectionStore.getState().setDisconnected(); + useConnectionStore.getState().startPolling(); + useConnectionStore.getState().startPolling(); + fetchMock.mockImplementation(failHealth); + vi.advanceTimersByTime(3000); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("stopPolling clears the interval", () => { + useConnectionStore.getState().setDisconnected(); + fetchMock.mockImplementation(failHealth); + useConnectionStore.getState().startPolling(); + useConnectionStore.getState().stopPolling(); + vi.advanceTimersByTime(6000); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); From 2ba7520dd443c90224ecf826122e516c489b595c Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Mon, 20 Apr 2026 21:49:22 +0800 Subject: [PATCH 02/15] fix: handle non-ok HTTP responses in connection store checkHealth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkHealth previously ignored non-ok responses (e.g. 503), silently doing nothing when the server reported unhealthy. Add else branch to transition connected → disconnected on non-ok status. Add tests for connected → disconnected on fetch failure and offline → reconnected on health check success. --- apps/web/src/stores/connection-store.ts | 4 ++++ tests/unit/web/connection-store.test.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/apps/web/src/stores/connection-store.ts b/apps/web/src/stores/connection-store.ts index cb9159e9..3dff6e46 100644 --- a/apps/web/src/stores/connection-store.ts +++ b/apps/web/src/stores/connection-store.ts @@ -51,6 +51,10 @@ export const useConnectionStore = create((set, get) => ({ } else { set({ lastHealthCheck: Date.now() }); } + } else { + if (get().status === "connected") { + get().setDisconnected(); + } } } catch { if (get().status === "connected") { diff --git a/tests/unit/web/connection-store.test.ts b/tests/unit/web/connection-store.test.ts index 601d435a..4527e894 100644 --- a/tests/unit/web/connection-store.test.ts +++ b/tests/unit/web/connection-store.test.ts @@ -81,6 +81,24 @@ describe("connection-store", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("checkHealth transitions connected → disconnected on fetch failure", async () => { + fetchMock.mockImplementation(failHealth); + expect(useConnectionStore.getState().status).toBe("connected"); + await useConnectionStore.getState().checkHealth(); + expect(useConnectionStore.getState().status).toBe("disconnected"); + expect(useConnectionStore.getState().failedSince).toBeTypeOf("number"); + }); + + it("checkHealth transitions offline → reconnected on success", async () => { + fetchMock.mockImplementation(okHealth); + useConnectionStore.getState().setOffline(); + expect(useConnectionStore.getState().status).toBe("offline"); + await useConnectionStore.getState().checkHealth(); + expect(useConnectionStore.getState().status).toBe("reconnected"); + expect(useConnectionStore.getState().failedSince).toBeNull(); + expect(useConnectionStore.getState().lastHealthCheck).toBeTypeOf("number"); + }); + it("startPolling is idempotent", () => { useConnectionStore.getState().setDisconnected(); useConnectionStore.getState().startPolling(); From 1ad916448a579afa54c792e6938db89a53eecd29 Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Mon, 20 Apr 2026 21:53:46 +0800 Subject: [PATCH 03/15] test: add checkHealth non-ok HTTP response coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checkHealth method handles non-ok responses (e.g. 503) but only network-level errors had test coverage. Add nonOkHealth helper and test to verify connected → disconnected transition on HTTP 503. --- tests/unit/web/connection-store.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit/web/connection-store.test.ts b/tests/unit/web/connection-store.test.ts index 4527e894..94538ee4 100644 --- a/tests/unit/web/connection-store.test.ts +++ b/tests/unit/web/connection-store.test.ts @@ -14,6 +14,10 @@ function failHealth() { return Promise.reject(new TypeError("Failed to fetch")); } +function nonOkHealth() { + return Promise.resolve(new Response(null, { status: 503 })); +} + describe("connection-store", () => { beforeEach(() => { vi.useFakeTimers(); @@ -81,6 +85,13 @@ describe("connection-store", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("checkHealth transitions connected → disconnected on non-ok response", async () => { + fetchMock.mockImplementation(nonOkHealth); + expect(useConnectionStore.getState().status).toBe("connected"); + await useConnectionStore.getState().checkHealth(); + expect(useConnectionStore.getState().status).toBe("disconnected"); + }); + it("checkHealth transitions connected → disconnected on fetch failure", async () => { fetchMock.mockImplementation(failHealth); expect(useConnectionStore.getState().status).toBe("connected"); From d40d7b38473ea6d8e863b83cb6959a8b661935da Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Mon, 20 Apr 2026 21:56:08 +0800 Subject: [PATCH 04/15] feat: add lazyWithRetry for chunk load failure recovery --- apps/web/src/lib/lazy-with-retry.ts | 34 +++++++++++++++++++++ tests/unit/web/lazy-with-retry.test.ts | 42 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 apps/web/src/lib/lazy-with-retry.ts create mode 100644 tests/unit/web/lazy-with-retry.test.ts diff --git a/apps/web/src/lib/lazy-with-retry.ts b/apps/web/src/lib/lazy-with-retry.ts new file mode 100644 index 00000000..96d95d92 --- /dev/null +++ b/apps/web/src/lib/lazy-with-retry.ts @@ -0,0 +1,34 @@ +import { type ComponentType, lazy } from "react"; + +function isChunkError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const msg = error.message.toLowerCase(); + return ( + msg.includes("dynamically imported module") || + msg.includes("loading chunk") || + msg.includes("loading css chunk") || + msg.includes("failed to fetch") + ); +} + +export async function retryDynamicImport( + importFn: () => Promise, + retries = 3, + delay = 1000, +): Promise { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + return await importFn(); + } catch (error) { + if (!isChunkError(error) || attempt === retries) throw error; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw new Error("unreachable"); +} + +export function lazyWithRetry>( + importFn: () => Promise<{ default: T }>, +): React.LazyExoticComponent { + return lazy(() => retryDynamicImport(importFn)); +} diff --git a/tests/unit/web/lazy-with-retry.test.ts b/tests/unit/web/lazy-with-retry.test.ts new file mode 100644 index 00000000..86b4af30 --- /dev/null +++ b/tests/unit/web/lazy-with-retry.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from "vitest"; + +import { retryDynamicImport } from "@/lib/lazy-with-retry"; + +describe("retryDynamicImport", () => { + it("resolves on first success", async () => { + const mod = { default: () => null }; + const importFn = vi.fn().mockResolvedValue(mod); + const result = await retryDynamicImport(importFn); + expect(result).toBe(mod); + expect(importFn).toHaveBeenCalledTimes(1); + }); + + it("retries on failure and resolves on eventual success", async () => { + const mod = { default: () => null }; + const importFn = vi + .fn() + .mockRejectedValueOnce(new TypeError("Failed to fetch dynamically imported module")) + .mockRejectedValueOnce(new TypeError("Failed to fetch dynamically imported module")) + .mockResolvedValue(mod); + const result = await retryDynamicImport(importFn, 3, 0); + expect(result).toBe(mod); + expect(importFn).toHaveBeenCalledTimes(3); + }); + + it("rejects after all retries exhausted", async () => { + const err = new TypeError("Failed to fetch dynamically imported module"); + const importFn = vi.fn().mockRejectedValue(err); + await expect(retryDynamicImport(importFn, 3, 0)).rejects.toThrow( + "Failed to fetch dynamically imported module", + ); + expect(importFn).toHaveBeenCalledTimes(3); + }); + + it("only retries chunk-related errors, not other errors", async () => { + const err = new Error("Some other error"); + const importFn = vi.fn().mockRejectedValue(err); + await expect(retryDynamicImport(importFn, 3, 0)).rejects.toThrow("Some other error"); + expect(importFn).toHaveBeenCalledTimes(1); + }); +}); From 1e9453366b1aa039406d5f2107357d98cc8fe227 Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Mon, 20 Apr 2026 22:00:27 +0800 Subject: [PATCH 05/15] feat: add connection status banner component --- .../components/common/connection-banner.tsx | 40 +++++++++++++++ tests/unit/web/connection-banner.test.ts | 49 +++++++++++++++++++ vitest.config.ts | 7 +++ 3 files changed, 96 insertions(+) create mode 100644 apps/web/src/components/common/connection-banner.tsx create mode 100644 tests/unit/web/connection-banner.test.ts diff --git a/apps/web/src/components/common/connection-banner.tsx b/apps/web/src/components/common/connection-banner.tsx new file mode 100644 index 00000000..f48aaa82 --- /dev/null +++ b/apps/web/src/components/common/connection-banner.tsx @@ -0,0 +1,40 @@ +import { CheckCircle2, Loader2, WifiOff } from "lucide-react"; +import { useConnectionStore } from "@/stores/connection-store"; + +export function ConnectionBanner() { + const status = useConnectionStore((s) => s.status); + + if (status === "connected") return null; + + const config = { + disconnected: { + bg: "bg-amber-500 dark:bg-amber-600", + text: "text-amber-950 dark:text-amber-50", + icon: , + message: "Reconnecting to server\u2026", + }, + offline: { + bg: "bg-amber-500 dark:bg-amber-600", + text: "text-amber-950 dark:text-amber-50", + icon: , + message: "You\u2019re offline", + }, + reconnected: { + bg: "bg-emerald-500 dark:bg-emerald-600", + text: "text-emerald-950 dark:text-emerald-50", + icon: , + message: "Connected", + }, + }[status]; + + return ( +
+ {config.icon} + {config.message} +
+ ); +} diff --git a/tests/unit/web/connection-banner.test.ts b/tests/unit/web/connection-banner.test.ts new file mode 100644 index 00000000..0930f42b --- /dev/null +++ b/tests/unit/web/connection-banner.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.stubGlobal("fetch", vi.fn()); + +import { useConnectionStore } from "@/stores/connection-store"; + +describe("ConnectionBanner", () => { + afterEach(() => { + useConnectionStore.setState({ status: "connected", failedSince: null, lastHealthCheck: null }); + }); + + it("renders nothing when connected", async () => { + const { renderBanner } = await setupRender(); + useConnectionStore.setState({ status: "connected" }); + const { container } = renderBanner(); + expect(container.innerHTML).toBe(""); + }); + + it("renders amber banner when disconnected", async () => { + const { renderBanner } = await setupRender(); + useConnectionStore.setState({ status: "disconnected" }); + const { container } = renderBanner(); + expect(container.textContent).toContain("Reconnecting to server"); + }); + + it("renders offline banner when offline", async () => { + const { renderBanner } = await setupRender(); + useConnectionStore.setState({ status: "offline" }); + const { container } = renderBanner(); + expect(container.textContent).toContain("offline"); + }); + + it("renders green banner when reconnected", async () => { + const { renderBanner } = await setupRender(); + useConnectionStore.setState({ status: "reconnected" }); + const { container } = renderBanner(); + expect(container.textContent).toContain("Connected"); + }); +}); + +async function setupRender() { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { ConnectionBanner } = await import("@/components/common/connection-banner"); + return { + renderBanner: () => render(React.createElement(ConnectionBanner)), + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index 9262f11e..d07ba580 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,9 @@ import { defineConfig } from "vitest/config"; // Resolve api-workspace packages that pnpm only exposes under apps/api/node_modules. const apiNodeModules = path.resolve(__dirname, "apps/api/node_modules"); +// Resolve web-workspace packages that pnpm only exposes under apps/web/node_modules. +const webNodeModules = path.resolve(__dirname, "apps/web/node_modules"); + // Temp dir for integration test DB + workspace (set BEFORE any app code loads) const testDir = path.join(os.tmpdir(), `ashim-test-${crypto.randomUUID().slice(0, 8)}`); @@ -88,6 +91,10 @@ export default defineConfig({ jsqr: path.join(apiNodeModules, "jsqr"), pdfkit: path.join(apiNodeModules, "pdfkit"), sharp: path.join(apiNodeModules, "sharp"), + // Map web-only dependencies so component tests can resolve them + // from the root vitest runner. + react: path.join(webNodeModules, "react"), + "react-dom": path.join(webNodeModules, "react-dom"), }, }, }); From 6602818cdf015a28af7a8b39d948c8ff43d20b5a Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Mon, 20 Apr 2026 22:02:17 +0800 Subject: [PATCH 06/15] feat: add connection monitor hook for reactive failure detection --- apps/web/src/hooks/use-connection-monitor.ts | 48 ++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 apps/web/src/hooks/use-connection-monitor.ts diff --git a/apps/web/src/hooks/use-connection-monitor.ts b/apps/web/src/hooks/use-connection-monitor.ts new file mode 100644 index 00000000..35df2bac --- /dev/null +++ b/apps/web/src/hooks/use-connection-monitor.ts @@ -0,0 +1,48 @@ +import { useEffect } from "react"; +import { useConnectionStore } from "@/stores/connection-store"; + +export function useConnectionMonitor() { + useEffect(() => { + const store = useConnectionStore; + + const handleOffline = () => store.getState().setOffline(); + const handleOnline = () => { + store.getState().setOnline(); + store.getState().startPolling(); + }; + + window.addEventListener("offline", handleOffline); + window.addEventListener("online", handleOnline); + + store.getState().checkHealth(); + + const unsubscribe = store.subscribe((state, prev) => { + if (state.status === prev.status) return; + + if (state.status === "disconnected") { + store.getState().startPolling(); + } + + if (state.status === "reconnected") { + store.getState().stopPolling(); + store.getState().refreshStaleData(); + setTimeout(() => { + if (store.getState().status === "reconnected") { + store.setState({ status: "connected" }); + } + }, 2500); + } + + if (state.status === "offline") { + store.getState().stopPolling(); + } + }); + + return () => { + window.removeEventListener("offline", handleOffline); + window.removeEventListener("online", handleOnline); + store.getState().stopPolling(); + unsubscribe(); + }; + }, []); +} From 4cb1a35f10936d440f63054cc79d96ab0756a3c6 Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Mon, 20 Apr 2026 22:03:58 +0800 Subject: [PATCH 07/15] feat: wire connection banner, monitor, and lazyWithRetry into App --- apps/web/src/App.tsx | 90 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 15 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 38c51240..eb6f8992 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,39 +1,55 @@ -import { Component, type ErrorInfo, lazy, type ReactNode, Suspense } from "react"; +import { Component, type ErrorInfo, type ReactNode, Suspense } from "react"; import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom"; import { Toaster } from "sonner"; +import { ConnectionBanner } from "./components/common/connection-banner"; import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider"; import { useAuth } from "./hooks/use-auth"; +import { useConnectionMonitor } from "./hooks/use-connection-monitor"; +import { lazyWithRetry } from "./lib/lazy-with-retry"; -// Lazy-load all pages so each page's JS (and its icons/deps) is only -// downloaded when the user navigates there, shrinking the main bundle. -const AutomatePage = lazy(() => +// Lazy-load all pages with automatic retry so chunk failures from +// deployments are recovered transparently instead of white-screening. +const AutomatePage = lazyWithRetry(() => import("./pages/automate-page").then((m) => ({ default: m.AutomatePage })), ); -const ChangePasswordPage = lazy(() => +const ChangePasswordPage = lazyWithRetry(() => import("./pages/change-password-page").then((m) => ({ default: m.ChangePasswordPage })), ); -const FilesPage = lazy(() => import("./pages/files-page").then((m) => ({ default: m.FilesPage }))); -const FullscreenGridPage = lazy(() => +const FilesPage = lazyWithRetry(() => + import("./pages/files-page").then((m) => ({ default: m.FilesPage })), +); +const FullscreenGridPage = lazyWithRetry(() => import("./pages/fullscreen-grid-page").then((m) => ({ default: m.FullscreenGridPage })), ); -const HomePage = lazy(() => import("./pages/home-page").then((m) => ({ default: m.HomePage }))); -const LoginPage = lazy(() => import("./pages/login-page").then((m) => ({ default: m.LoginPage }))); -const PrivacyPolicyPage = lazy(() => +const HomePage = lazyWithRetry(() => + import("./pages/home-page").then((m) => ({ default: m.HomePage })), +); +const LoginPage = lazyWithRetry(() => + import("./pages/login-page").then((m) => ({ default: m.LoginPage })), +); +const PrivacyPolicyPage = lazyWithRetry(() => import("./pages/privacy-policy-page").then((m) => ({ default: m.PrivacyPolicyPage })), ); -const ToolPage = lazy(() => import("./pages/tool-page").then((m) => ({ default: m.ToolPage }))); +const ToolPage = lazyWithRetry(() => + import("./pages/tool-page").then((m) => ({ default: m.ToolPage })), +); class ErrorBoundary extends Component< { children: ReactNode }, - { hasError: boolean; error: Error | null } + { hasError: boolean; error: Error | null; isChunkError: boolean } > { constructor(props: { children: ReactNode }) { super(props); - this.state = { hasError: false, error: null }; + this.state = { hasError: false, error: null, isChunkError: false }; } static getDerivedStateFromError(error: Error) { - return { hasError: true, error }; + const msg = error.message.toLowerCase(); + const isChunkError = + msg.includes("dynamically imported module") || + msg.includes("loading chunk") || + msg.includes("failed to fetch"); + return { hasError: true, error, isChunkError }; } componentDidCatch(error: Error, info: ErrorInfo) { @@ -42,6 +58,43 @@ class ErrorBoundary extends Component< render() { if (this.state.hasError) { + if (this.state.isChunkError) { + return ( +
+
+
+ + Refresh + + +
+

Update Available

+

+ A new version of ashim has been deployed. +

+ +
+
+ ); + } return (
@@ -52,7 +105,7 @@ class ErrorBoundary extends Component<