Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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;
185 changes: 185 additions & 0 deletions packages/web/src/common/hooks/useVersionCheck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
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;
},
}));
Comment thread
tyler-dane marked this conversation as resolved.

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;
Comment thread
tyler-dane marked this conversation as resolved.
});

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;

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

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

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

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

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 thread
tyler-dane marked this conversation as resolved.
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.debug("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 };
};
7 changes: 7 additions & 0 deletions packages/web/src/components/Icons/Refresh.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import styled from "styled-components";
import { ArrowsClockwiseIcon } from "@phosphor-icons/react";
Comment thread
tyler-dane marked this conversation as resolved.
import { iconStyles } from "./styled";

export const RefreshIcon = styled(ArrowsClockwiseIcon)`
${iconStyles}
`;
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 { RefreshIcon } from "@web/components/Icons/Refresh";
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,11 @@ export const SidebarIconRow = () => {
const tab = useAppSelector(selectSidebarTab);
const gCalImport = useAppSelector(selectImportGCalState);
const isCmdPaletteOpen = useAppSelector(selectIsCmdPaletteOpen);
const { isUpdateAvailable } = useVersionCheck();

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

const toggleCmdPalette = () => {
if (isCmdPaletteOpen) {
Expand Down Expand Up @@ -88,6 +95,14 @@ export const SidebarIconRow = () => {
<SpinnerIcon disabled />
</TooltipWrapper>
) : undefined}
{isUpdateAvailable ? (
<TooltipWrapper
description="Update available"
Comment thread
tyler-dane marked this conversation as resolved.
Outdated
onClick={handleUpdateReload}
>
<RefreshIcon color={theme.color.text.accent} />
</TooltipWrapper>
) : undefined}
</LeftIconGroup>
</IconRow>
);
Expand Down
26 changes: 26 additions & 0 deletions packages/web/webpack.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ const loadEnvFile = (envName) => {
*/
export default (env, argv) => {
const IS_DEV = argv.mode === "development";
const BUILD_VERSION = IS_DEV
? "dev"
: `${Date.now()}-${Math.random().toString(36).substring(7)}`;
Comment thread
tyler-dane marked this conversation as resolved.
Outdated

const ENVIRONMENT = argv.nodeEnv || "local";
loadEnvFile(ENVIRONMENT);
Expand Down Expand Up @@ -110,6 +113,7 @@ 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,
BUILD_VERSION: JSON.stringify(BUILD_VERSION),
}),
new HtmlWebpackPlugin({
filename: "index.html",
Expand All @@ -129,6 +133,28 @@ export default (env, argv) => {
_plugins.push(new BundleAnalyzerPlugin());
}

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();
Comment thread
tyler-dane marked this conversation as resolved.
},
);
},
});
}
Comment on lines +137 to +157
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.

In a deployment scenario with multiple servers behind a load balancer, each server might serve a different version.json file during a rolling deployment. This could cause users to see the update notification prematurely or inconsistently. Consider implementing a deployment strategy where version.json is only updated after all servers have been updated, or use a CDN with cache invalidation to ensure all users see the same version consistently.

Copilot uses AI. Check for mistakes.

return {
entry: "./src/index.tsx",
// got devtool sourcemap errors with: eval, eval-cheap-source-map
Expand Down
Loading