Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/web/declaration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ declare module "*.jpg" {
declare module "*.jpeg" {
export = imageUrl;
}

// Build version injected by webpack
declare const BUILD_VERSION: string;
94 changes: 94 additions & 0 deletions packages/web/src/common/hooks/useChunkLoadErrorHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
70 changes: 70 additions & 0 deletions packages/web/src/common/hooks/useChunkLoadErrorHandler.ts
Original file line number Diff line number Diff line change
@@ -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,
);
};
}, []);
};
55 changes: 55 additions & 0 deletions packages/web/src/common/hooks/useVersionCheck.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
75 changes: 75 additions & 0 deletions packages/web/src/common/hooks/useVersionCheck.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
2 changes: 2 additions & 0 deletions packages/web/src/components/App/App.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -10,6 +11,7 @@ import { CompassRouterProvider } from "@web/routers";
export const App = () => {
useSetupKeyboardEvents();
useSetupMovementEvents();
useChunkLoadErrorHandler();

return (
<React.StrictMode>
Expand Down
35 changes: 35 additions & 0 deletions packages/web/src/components/Icons/Download.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from "react";

interface DownloadIconProps {
size?: number;
color?: string;
}

export const DownloadIcon: React.FC<DownloadIconProps> = ({
size = 20,
color = "currentColor",
}) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="Download"
>
<path
d="M12 3V15M12 15L8 11M12 15L16 11"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 17V19C3 19.5304 3.21071 20.0391 3.58579 20.4142C3.96086 20.7893 4.46957 21 5 21H19C19.5304 21 20.0391 20.7893 20.4142 20.4142C20.7893 20.0391 21 19.5304 21 19V17"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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) {
Expand All @@ -31,6 +34,10 @@ export const SidebarIconRow = () => {
}
};

const handleUpdateClick = () => {
window.location.reload();
};

const getCommandPaletteShortcut = () => {
return (
<Text size="s" style={{ display: "flex", alignItems: "center" }}>
Expand Down Expand Up @@ -88,6 +95,14 @@ export const SidebarIconRow = () => {
<SpinnerIcon disabled />
</TooltipWrapper>
) : undefined}
{isUpdateAvailable ? (
<TooltipWrapper
description="New version available! Click to refresh and get the latest updates."
onClick={handleUpdateClick}
>
<DownloadIcon size={20} color={theme.color.primary.default} />
</TooltipWrapper>
) : undefined}
</LeftIconGroup>
</IconRow>
);
Expand Down
Loading