diff --git a/packages/web/declaration.d.ts b/packages/web/declaration.d.ts index 5b5bf1821..4d9e88615 100644 --- a/packages/web/declaration.d.ts +++ b/packages/web/declaration.d.ts @@ -26,3 +26,6 @@ declare module "*.jpg" { declare module "*.jpeg" { export = imageUrl; } + +// Build version injected by webpack +declare const BUILD_VERSION: string; diff --git a/packages/web/src/common/hooks/useChunkLoadErrorHandler.test.ts b/packages/web/src/common/hooks/useChunkLoadErrorHandler.test.ts new file mode 100644 index 000000000..8e5f2e72f --- /dev/null +++ b/packages/web/src/common/hooks/useChunkLoadErrorHandler.test.ts @@ -0,0 +1,94 @@ +import { renderHook } from "@testing-library/react"; +import { useChunkLoadErrorHandler } from "./useChunkLoadErrorHandler"; + +describe("useChunkLoadErrorHandler", () => { + let reloadMock: jest.Mock; + const originalLocation = window.location; + + beforeEach(() => { + reloadMock = jest.fn(); + // Mock location with reload function + delete (window as any).location; + window.location = { ...originalLocation, reload: reloadMock } as any; + }); + + afterEach(() => { + window.location = originalLocation; + }); + + it("should set up error event listeners", () => { + const addEventListenerSpy = jest.spyOn(window, "addEventListener"); + + renderHook(() => useChunkLoadErrorHandler()); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + "error", + expect.any(Function), + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + "unhandledrejection", + expect.any(Function), + ); + }); + + it("should clean up event listeners on unmount", () => { + const removeEventListenerSpy = jest.spyOn(window, "removeEventListener"); + + const { unmount } = renderHook(() => useChunkLoadErrorHandler()); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "error", + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "unhandledrejection", + expect.any(Function), + ); + }); + + it("should reload page when ChunkLoadError is detected in error event", () => { + renderHook(() => useChunkLoadErrorHandler()); + + // Simulate a ChunkLoadError + const errorEvent = new ErrorEvent("error", { + message: "Loading chunk 123 failed", + error: new Error("Loading chunk 123 failed"), + }); + + window.dispatchEvent(errorEvent); + + expect(reloadMock).toHaveBeenCalled(); + }); + + it("should reload page when ChunkLoadError is detected in promise rejection", () => { + renderHook(() => useChunkLoadErrorHandler()); + + // Simulate a ChunkLoadError in promise rejection by dispatching custom event + const rejectionEvent = new Event("unhandledrejection") as any; + rejectionEvent.reason = { + name: "ChunkLoadError", + message: "Loading chunk failed", + }; + rejectionEvent.preventDefault = jest.fn(); + + window.dispatchEvent(rejectionEvent); + + expect(reloadMock).toHaveBeenCalled(); + }); + + it("should not reload page for non-chunk errors", () => { + renderHook(() => useChunkLoadErrorHandler()); + + // Simulate a regular error + const errorEvent = new ErrorEvent("error", { + message: "Regular error", + error: new Error("Regular error"), + }); + + window.dispatchEvent(errorEvent); + + expect(reloadMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/common/hooks/useChunkLoadErrorHandler.ts b/packages/web/src/common/hooks/useChunkLoadErrorHandler.ts new file mode 100644 index 000000000..1f23ab067 --- /dev/null +++ b/packages/web/src/common/hooks/useChunkLoadErrorHandler.ts @@ -0,0 +1,70 @@ +import { useEffect } from "react"; + +/** + * Hook to handle ChunkLoadError by detecting when a chunk fails to load + * (typically after a deployment) and automatically reloading the page. + */ +export const useChunkLoadErrorHandler = () => { + useEffect(() => { + let isReloading = false; + + const handleError = (event: ErrorEvent) => { + if (isReloading) return; + + const isChunkLoadError = + event.message?.includes("Loading chunk") || + event.message?.includes("ChunkLoadError") || + event.error?.name === "ChunkLoadError"; + + if (isChunkLoadError) { + console.info( + "Detected chunk load error - new version available. Reloading page...", + ); + + // Prevent default error handling + event.preventDefault(); + + // Set flag to prevent multiple reloads + isReloading = true; + + // Reload the page to get the new chunks + window.location.reload(); + } + }; + + const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + if (isReloading) return; + + const isChunkLoadError = + event.reason?.message?.includes("Loading chunk") || + event.reason?.name === "ChunkLoadError"; + + if (isChunkLoadError) { + console.info( + "Detected chunk load error - new version available. Reloading page...", + ); + + // Prevent default error handling + event.preventDefault(); + + // Set flag to prevent multiple reloads + isReloading = true; + + // Reload the page to get the new chunks + window.location.reload(); + } + }; + + // Listen for both error events and unhandled promise rejections + window.addEventListener("error", handleError); + window.addEventListener("unhandledrejection", handleUnhandledRejection); + + return () => { + window.removeEventListener("error", handleError); + window.removeEventListener( + "unhandledrejection", + handleUnhandledRejection, + ); + }; + }, []); +}; diff --git a/packages/web/src/common/hooks/useVersionCheck.test.ts b/packages/web/src/common/hooks/useVersionCheck.test.ts new file mode 100644 index 000000000..94ae47377 --- /dev/null +++ b/packages/web/src/common/hooks/useVersionCheck.test.ts @@ -0,0 +1,55 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { useVersionCheck } from "./useVersionCheck"; + +// Mock fetch globally +const mockFetch = jest.fn(); +global.fetch = mockFetch as any; + +describe("useVersionCheck", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("should return isUpdateAvailable as false initially", () => { + const { result } = renderHook(() => useVersionCheck()); + + expect(result.current.isUpdateAvailable).toBe(false); + expect(result.current.currentVersion).toBeDefined(); + }); + + it("should skip version check in development mode", () => { + const { result } = renderHook(() => useVersionCheck()); + + // In dev mode, fetch should not be called + expect(mockFetch).not.toHaveBeenCalled(); + expect(result.current.isUpdateAvailable).toBe(false); + }); + + it("should handle fetch errors gracefully", async () => { + // Temporarily override BUILD_VERSION to simulate production + const originalBuildVersion = (global as any).BUILD_VERSION; + (global as any).BUILD_VERSION = "1.0.0"; + + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + const { result } = renderHook(() => useVersionCheck()); + + await waitFor(() => { + expect(result.current.isUpdateAvailable).toBe(false); + }); + + // Restore original value + (global as any).BUILD_VERSION = originalBuildVersion; + }); + + it("should return current version", () => { + const { result } = renderHook(() => useVersionCheck()); + + expect(result.current.currentVersion).toBe("dev"); + }); +}); diff --git a/packages/web/src/common/hooks/useVersionCheck.ts b/packages/web/src/common/hooks/useVersionCheck.ts new file mode 100644 index 000000000..f29f79295 --- /dev/null +++ b/packages/web/src/common/hooks/useVersionCheck.ts @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; + +const VERSION_CHECK_INTERVAL = 60000; // Check every 60 seconds + +// Check if we're in a webpack dev environment +const IS_DEV = + typeof BUILD_VERSION === "undefined" || + BUILD_VERSION === "dev" || + process.env.NODE_ENV === "development"; + +const CURRENT_VERSION = IS_DEV + ? "dev" + : typeof BUILD_VERSION !== "undefined" + ? BUILD_VERSION + : "unknown"; + +interface VersionCheckResult { + isUpdateAvailable: boolean; + currentVersion: string; +} + +/** + * Hook to periodically check if a new version of the app is available. + * Returns true if the deployed version differs from the current running version. + */ +export const useVersionCheck = (): VersionCheckResult => { + const [isUpdateAvailable, setIsUpdateAvailable] = useState(false); + + useEffect(() => { + // Skip version checking in development + if (IS_DEV) { + return; + } + + const checkVersion = async () => { + try { + // Fetch the version from the server with cache-busting + const response = await fetch(`/version.json?t=${Date.now()}`, { + cache: "no-store", + headers: { + "Cache-Control": "no-cache", + }, + }); + + if (response.ok) { + const data = await response.json(); + const serverVersion = data.version; + + // Check if server version differs from current version + // Once an update is detected, we keep showing the indicator + // until the user refreshes to get the new version + if (serverVersion && serverVersion !== CURRENT_VERSION) { + setIsUpdateAvailable(true); + } + } + } catch (error) { + // Silently fail - version check is not critical + console.debug("Version check failed:", error); + } + }; + + // Check immediately on mount + checkVersion(); + + // Then check periodically + const interval = setInterval(checkVersion, VERSION_CHECK_INTERVAL); + + return () => clearInterval(interval); + }, []); + + return { + isUpdateAvailable, + currentVersion: CURRENT_VERSION, + }; +}; diff --git a/packages/web/src/components/App/App.tsx b/packages/web/src/components/App/App.tsx index ceaaf02b5..ee65b75bf 100644 --- a/packages/web/src/components/App/App.tsx +++ b/packages/web/src/components/App/App.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useChunkLoadErrorHandler } from "@web/common/hooks/useChunkLoadErrorHandler"; import { useSetupKeyboardEvents } from "@web/common/hooks/useKeyboardEvent"; import { useSetupMovementEvents } from "@web/common/hooks/useMovementEvent"; import { @@ -10,6 +11,7 @@ import { CompassRouterProvider } from "@web/routers"; export const App = () => { useSetupKeyboardEvents(); useSetupMovementEvents(); + useChunkLoadErrorHandler(); return ( diff --git a/packages/web/src/components/Icons/Download.tsx b/packages/web/src/components/Icons/Download.tsx new file mode 100644 index 000000000..9d090c0d2 --- /dev/null +++ b/packages/web/src/components/Icons/Download.tsx @@ -0,0 +1,35 @@ +import React from "react"; + +interface DownloadIconProps { + size?: number; + color?: string; +} + +export const DownloadIcon: React.FC = ({ + size = 20, + color = "currentColor", +}) => ( + + + + +); diff --git a/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx index 0afaccc00..6f9f25731 100644 --- a/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx +++ b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx @@ -1,7 +1,9 @@ +import { useVersionCheck } from "@web/common/hooks/useVersionCheck"; import { theme } from "@web/common/styles/theme"; import { getModifierKeyIcon } from "@web/common/utils/shortcut/shortcut.util"; import { CalendarIcon } from "@web/components/Icons/Calendar"; import { CommandIcon } from "@web/components/Icons/Command"; +import { DownloadIcon } from "@web/components/Icons/Download"; import { SpinnerIcon } from "@web/components/Icons/Spinner"; import { TodoIcon } from "@web/components/Icons/Todo"; import { Text } from "@web/components/Text"; @@ -22,6 +24,7 @@ export const SidebarIconRow = () => { const tab = useAppSelector(selectSidebarTab); const gCalImport = useAppSelector(selectImportGCalState); const isCmdPaletteOpen = useAppSelector(selectIsCmdPaletteOpen); + const { isUpdateAvailable } = useVersionCheck(); const toggleCmdPalette = () => { if (isCmdPaletteOpen) { @@ -31,6 +34,10 @@ export const SidebarIconRow = () => { } }; + const handleUpdateClick = () => { + window.location.reload(); + }; + const getCommandPaletteShortcut = () => { return ( @@ -88,6 +95,14 @@ export const SidebarIconRow = () => { ) : undefined} + {isUpdateAvailable ? ( + + + + ) : undefined} ); diff --git a/packages/web/webpack.config.mjs b/packages/web/webpack.config.mjs index 17710a6c0..b96f7bb8b 100644 --- a/packages/web/webpack.config.mjs +++ b/packages/web/webpack.config.mjs @@ -96,6 +96,11 @@ export default (env, argv) => { const styleLoader = IS_DEV ? "style-loader" : MiniCssExtractPlugin.loader; + // Generate a version hash based on build time + const BUILD_VERSION = IS_DEV + ? "dev" + : `${Date.now()}-${Math.random().toString(36).substring(7)}`; + const envObject = `{ API_BASEURL: ${JSON.stringify(API_BASEURL)}, GOOGLE_CLIENT_ID: ${JSON.stringify(GOOGLE_CLIENT_ID)}, @@ -110,6 +115,8 @@ export default (env, argv) => { // Define process.env as an object literal (not a JSON string) // This allows both process.env.KEY and process.env["KEY"] bracket notation to work "process.env": envObject, + // Define BUILD_VERSION for version checking + BUILD_VERSION: JSON.stringify(BUILD_VERSION), }), new HtmlWebpackPlugin({ filename: "index.html", @@ -125,6 +132,29 @@ export default (env, argv) => { }), ]; + // Generate version.json file for version checking + if (!IS_DEV) { + _plugins.push({ + apply: (compiler) => { + compiler.hooks.emit.tapAsync( + "GenerateVersionPlugin", + (compilation, callback) => { + const versionContent = JSON.stringify( + { version: BUILD_VERSION }, + null, + 2, + ); + compilation.assets["version.json"] = { + source: () => versionContent, + size: () => versionContent.length, + }; + callback(); + }, + ); + }, + }); + } + if (ANALYZE_BUNDLE) { _plugins.push(new BundleAnalyzerPlugin()); }