Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/web/declaration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ declare module "*.jpg" {
declare module "*.jpeg" {
export = imageUrl;
}

declare const BUILD_VERSION: string;
268 changes: 268 additions & 0 deletions packages/web/src/common/hooks/useVersionCheck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import { act } from "react";
import { renderHook } from "@testing-library/react";
import { useVersionCheck } from "@web/common/hooks/useVersionCheck";

let mockIsDev = false;
jest.mock("@web/common/constants/env.constants", () => ({
get IS_DEV() {
return mockIsDev;
},
}));

const MIN_HIDDEN_DURATION_MS = 30_000;
const BACKUP_CHECK_INTERVAL_MS = 5 * 60 * 1000;

describe("useVersionCheck", () => {
let visibilityState = "visible";
const flushPromises = async () => {
await Promise.resolve();
await Promise.resolve();
};

beforeEach(() => {
mockIsDev = false;
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-02-05T00:00:00.000Z"));

visibilityState = "visible";
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => visibilityState,
});

global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ version: "dev" }),
}) as typeof fetch;
});

afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
});

it("checks version on initial mount", async () => {
renderHook(() => useVersionCheck());
await act(async () => {
await flushPromises();
});

expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringMatching(/^\/version\.json\?t=\d+$/),
expect.objectContaining({
cache: "no-store",
headers: { "Cache-Control": "no-cache" },
}),
);
});

it("does not check for updates in development mode", async () => {
mockIsDev = true;
const addEventListenerSpy = jest.spyOn(document, "addEventListener");
const setIntervalSpy = jest.spyOn(window, "setInterval");

renderHook(() => useVersionCheck());
await act(async () => {
await flushPromises();
});

expect(global.fetch).not.toHaveBeenCalled();
expect(addEventListenerSpy).not.toHaveBeenCalled();
expect(setIntervalSpy).not.toHaveBeenCalled();

act(() => {
jest.advanceTimersByTime(BACKUP_CHECK_INTERVAL_MS);
});

expect(global.fetch).not.toHaveBeenCalled();

addEventListenerSpy.mockRestore();
setIntervalSpy.mockRestore();
});

it("sets isUpdateAvailable when server version differs", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ version: "1.0.0" }),
}) as typeof fetch;

const { result } = renderHook(() => useVersionCheck());

await act(async () => {
await flushPromises();
});

expect(result.current.isUpdateAvailable).toBe(true);
});

it("keeps isUpdateAvailable false when versions match", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ version: "dev" }),
}) as typeof fetch;

const { result } = renderHook(() => useVersionCheck());

await act(async () => {
await flushPromises();
});

expect(result.current.isUpdateAvailable).toBe(false);
});

it("handles network failures without crashing", async () => {
const consoleErrorSpy = jest
.spyOn(console, "error")
.mockImplementation(() => undefined);

global.fetch = jest
.fn()
.mockRejectedValue(new Error("Network down")) as typeof fetch;

const { result } = renderHook(() => useVersionCheck());

await act(async () => {
await flushPromises();
});

expect(result.current.isUpdateAvailable).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Version check failed:",
expect.any(Error),
);

consoleErrorSpy.mockRestore();
});

it("ignores invalid version payloads", async () => {
const consoleErrorSpy = jest
.spyOn(console, "error")
.mockImplementation(() => undefined);

global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ version: 123 }),
}) as typeof fetch;

const { result } = renderHook(() => useVersionCheck());

await act(async () => {
await flushPromises();
});

expect(result.current.isUpdateAvailable).toBe(false);
expect(consoleErrorSpy).not.toHaveBeenCalled();

consoleErrorSpy.mockRestore();
});

it("checks when tab becomes visible after being hidden long enough", async () => {
renderHook(() => useVersionCheck());
await act(async () => {
await flushPromises();
});
(global.fetch as jest.Mock).mockClear();

act(() => {
visibilityState = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
jest.advanceTimersByTime(MIN_HIDDEN_DURATION_MS + 1_000);
visibilityState = "visible";
document.dispatchEvent(new Event("visibilitychange"));
});

expect(global.fetch).toHaveBeenCalledTimes(1);
});

it("does not check when tab becomes visible after a short hide", async () => {
renderHook(() => useVersionCheck());
await act(async () => {
await flushPromises();
});
(global.fetch as jest.Mock).mockClear();

act(() => {
visibilityState = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
jest.advanceTimersByTime(MIN_HIDDEN_DURATION_MS - 10_000);
visibilityState = "visible";
document.dispatchEvent(new Event("visibilitychange"));
});

expect(global.fetch).not.toHaveBeenCalled();
});

it("cleans up the visibility listener on unmount", () => {
const addEventListenerSpy = jest.spyOn(document, "addEventListener");
const removeEventListenerSpy = jest.spyOn(document, "removeEventListener");

const { unmount } = renderHook(() => useVersionCheck());

const handler = addEventListenerSpy.mock.calls.find(
([eventName]) => eventName === "visibilitychange",
)?.[1];

unmount();

expect(removeEventListenerSpy).toHaveBeenCalledWith(
"visibilitychange",
handler,
);

addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});

it("runs the backup poll on the interval", async () => {
renderHook(() => useVersionCheck());
await act(async () => {
await flushPromises();
});
(global.fetch as jest.Mock).mockClear();

act(() => {
jest.advanceTimersByTime(BACKUP_CHECK_INTERVAL_MS);
});

expect(global.fetch).toHaveBeenCalledTimes(1);
});

it("prevents concurrent checks", async () => {
let resolveFetch: ((value: unknown) => void) | undefined;
const fetchPromise = new Promise((resolve) => {
resolveFetch = resolve;
});

renderHook(() => useVersionCheck());
await act(async () => {
await flushPromises();
});

global.fetch = jest.fn().mockReturnValue(fetchPromise) as typeof fetch;
(global.fetch as jest.Mock).mockClear();

act(() => {
visibilityState = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
jest.advanceTimersByTime(MIN_HIDDEN_DURATION_MS + 1_000);
visibilityState = "visible";
document.dispatchEvent(new Event("visibilitychange"));

visibilityState = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
jest.advanceTimersByTime(MIN_HIDDEN_DURATION_MS + 1_000);
visibilityState = "visible";
document.dispatchEvent(new Event("visibilitychange"));
});

expect(global.fetch).toHaveBeenCalledTimes(1);

act(() => {
resolveFetch?.({
ok: true,
json: async () => ({ version: "dev" }),
});
});
});
});
Comment on lines +15 to +268
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests don't cover the scenario where the fetch returns a non-OK response (e.g., 404, 500). While the implementation handles this correctly by returning early on line 46-48 of useVersionCheck.ts, consider adding a test case to verify this behavior, such as: it("handles non-OK responses gracefully", async () => { ... }) to ensure isUpdateAvailable remains false when the server returns an error status.

Copilot uses AI. Check for mistakes.
115 changes: 115 additions & 0 deletions packages/web/src/common/hooks/useVersionCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { z } from "zod";
import { IS_DEV } from "@web/common/constants/env.constants";

const MIN_HIDDEN_DURATION_MS = 30_000;
const BACKUP_CHECK_INTERVAL_MS = 5 * 60 * 1000;
const CURRENT_VERSION =
typeof BUILD_VERSION === "string" ? BUILD_VERSION : "dev";
const versionResponseSchema = z.object({
version: z.string().optional(),
});

export interface VersionCheckResult {
isUpdateAvailable: boolean;
currentVersion: string;
}

/**
* Checks for new application versions by polling `/version.json`.
*
* Performs version checks:
* - On initial mount
* - When the tab becomes visible after being hidden for 30+ seconds
* - Every 5 minutes as a backup poll
*
* Disabled in development mode.
*/
export const useVersionCheck = (): VersionCheckResult => {
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
const hiddenAtRef = useRef<number | null>(null);
const isCheckingRef = useRef(false);

const checkVersion = useCallback(async () => {
if (isCheckingRef.current) {
return;
}

isCheckingRef.current = true;

try {
const response = await fetch(`/version.json?t=${Date.now()}`, {
cache: "no-store",
headers: { "Cache-Control": "no-cache" },
});

if (!response.ok) {
return;
}

const parsedResponse = versionResponseSchema.safeParse(
await response.json(),
);

if (!parsedResponse.success) {
return;
}

const { version: serverVersion } = parsedResponse.data;

if (!serverVersion) {
return;
}

setIsUpdateAvailable(serverVersion !== CURRENT_VERSION);
} catch (error) {
console.error("Version check failed:", error);
} finally {
isCheckingRef.current = false;
}
}, []);

useEffect(() => {
if (IS_DEV) {
return;
}

const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") {
hiddenAtRef.current = Date.now();
return;
}

if (document.visibilityState !== "visible") {
return;
}

const hiddenAt = hiddenAtRef.current;
hiddenAtRef.current = null;

if (hiddenAt === null) {
return;
}

const hiddenDuration = Date.now() - hiddenAt;
if (hiddenDuration >= MIN_HIDDEN_DURATION_MS) {
checkVersion();
}
};

checkVersion();

document.addEventListener("visibilitychange", handleVisibilityChange);
const backupInterval = window.setInterval(
checkVersion,
BACKUP_CHECK_INTERVAL_MS,
);

return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
clearInterval(backupInterval);
};
}, [checkVersion]);

return { isUpdateAvailable, currentVersion: CURRENT_VERSION };
};
2 changes: 0 additions & 2 deletions packages/web/src/common/utils/cleanup/browser.cleanup.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ export async function clearAllBrowserStorage(): Promise<void> {
});
}
}
Comment on lines 47 to 49
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removal of the console.log statement appears to be an unrelated change. This change is not mentioned in the PR description and doesn't relate to the version update flow feature. If this cleanup is intentional, consider including it in a separate PR focused on code cleanup, or document it in the PR description.

Copilot uses AI. Check for mistakes.

console.log("Browser storage cleared successfully");
} catch (error) {
console.error("Error clearing browser storage:", error);
throw error;
Expand Down
Loading
Loading