Skip to content

Conversation

@tyler-dane
Copy link
Contributor

@tyler-dane tyler-dane commented Feb 5, 2026

  • Added useVersionCheck hook to monitor application version and notify users of updates.
  • Integrated version check into the SidebarIconRow component, displaying a download icon when an update is available.
  • Introduced a BUILD_VERSION constant in the webpack configuration to manage versioning.
  • Created a version.json file during production builds to store the current version.
  • Added tests for the useVersionCheck hook to ensure correct functionality and behavior.
    Closes Chunk load error | Unhandled Rejection #1431

Note

Medium Risk
Touches the web build pipeline and introduces periodic network polling plus a user-triggered reload path; failures are mostly limited to extra requests or missing update prompts.

Overview
Adds a production build version identifier (BUILD_VERSION) and emits a version.json asset so the client can compare its running version against the deployed one.

Introduces useVersionCheck, which polls /version.json on mount, on tab re-visibility after a threshold, and on a 5-minute interval (disabled in dev), and wires it into the calendar sidebar to show a refresh icon that reloads the page when an update is available. Includes comprehensive hook tests and removes a stray storage-cleanup success log.

Written by Cursor Bugbot for commit cd550c5. This will update automatically on new commits. Configure here.

Copilot AI review requested due to automatic review settings February 5, 2026 03:10
@tyler-dane tyler-dane changed the title feat(web): implement version check and update notification system feat(web): add version update flow Feb 5, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR implements a version checking and update notification system for the web application. When a new version is deployed, users with older versions are notified via an icon in the sidebar that they can click to reload and get the latest version.

Changes:

  • Added webpack configuration to generate unique build versions and create a version.json file during production builds
  • Created useVersionCheck hook that periodically checks for version updates via visibility change events and polling
  • Integrated the version check into the sidebar UI with a clickable icon to trigger reload when updates are available

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
packages/web/webpack.config.mjs Generates BUILD_VERSION constant and creates version.json file during non-dev builds
packages/web/src/common/hooks/useVersionCheck.ts Hook that checks for version mismatches between client and server
packages/web/src/common/hooks/useVersionCheck.test.ts Tests for version check hook covering visibility changes, polling, and concurrent check prevention
packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx Integrates version check hook and displays update notification icon with reload handler
packages/web/src/components/Icons/Download.tsx New icon component for the update notification (uses ArrowsClockwise icon)
packages/web/declaration.d.ts TypeScript declaration for BUILD_VERSION global constant

- Added `useVersionCheck` hook to monitor application version and notify users of updates.
- Integrated version check into the `SidebarIconRow` component, displaying a download icon when an update is available.
- Introduced a `BUILD_VERSION` constant in the webpack configuration to manage versioning.
- Created a `version.json` file during production builds to store the current version.
- Added tests for the `useVersionCheck` hook to ensure correct functionality and behavior.
- Introduced a new `RefreshIcon` component styled with `styled-components` and integrated from `@phosphor-icons/react`.
- Replaced the `DownloadIcon` in the `SidebarIconRow` component with the new `RefreshIcon` to enhance the user interface for update notifications.
- Updated `useVersionCheck` hook to include detailed comments on version checking behavior, including checks on initial mount, tab visibility, and polling intervals.
- Added tests for the `useVersionCheck` hook to verify version checks on initial mount and ensure no checks occur in development mode.
- Simplified the `handleUpdateReload` function in `SidebarIconRow` to directly reload the window instead of modifying the URL.
Copilot AI review requested due to automatic review settings February 5, 2026 03:27
@tyler-dane tyler-dane force-pushed the feat/1431-version-updates branch from 70d4717 to 76b7605 Compare February 5, 2026 03:27
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.

- Updated `webpack.config.mjs` to include the current git hash in the `BUILD_VERSION` for better traceability.
- Modified `useVersionCheck` hook to log errors using `console.error` instead of `console.debug`, improving error visibility.
- Added additional tests for `useVersionCheck` to cover various scenarios, including network failures and version mismatches.
- Cleaned up logging in `clearAllBrowserStorage` function by removing unnecessary console logs.
- Updated `SidebarIconRow` to provide clearer update notifications with improved tooltip descriptions.
@tyler-dane tyler-dane marked this pull request as ready for review February 5, 2026 03:50
Copilot AI review requested due to automatic review settings February 5, 2026 03:50
@tyler-dane tyler-dane merged commit e5a3a12 into main Feb 5, 2026
5 of 6 checks passed
@tyler-dane tyler-dane deleted the feat/1431-version-updates branch February 5, 2026 03:50
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 5 comments.

Comment on lines +15 to +268
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" }),
});
});
});
});
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.
{gCalImport.importing ? (
<TooltipWrapper description="Importing your calendar events in the background">
<SpinnerIcon disabled />
<SpinnerIcon />
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 disabled prop from SpinnerIcon 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 is intentional, consider documenting why the disabled prop was removed, or if it's accidental, consider reverting this change.

Copilot uses AI. Check for mistakes.
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.
const IS_DEV = argv.mode === "development";
// git hash makes the build traceable
const GIT_HASH = execSync("git rev-parse --short HEAD").toString().trim();
const BUILD_VERSION = IS_DEV ? "dev" : `${Date.now()}-${GIT_HASH}`;
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 BUILD_VERSION uses Date.now() which generates a timestamp at build time. This means every build will have a different version, even if no code has changed. This could lead to users being notified of updates when there are no actual changes. Consider using a more semantic versioning approach (e.g., package.json version + git hash) or using the git commit hash alone, which only changes when code actually changes.

Suggested change
const BUILD_VERSION = IS_DEV ? "dev" : `${Date.now()}-${GIT_HASH}`;
const BUILD_VERSION = IS_DEV ? "dev" : GIT_HASH;

Copilot uses AI. Check for mistakes.
Comment on lines +137 to +157
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();
},
);
},
});
}
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.
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

This PR is being reviewed by Cursor Bugbot

Details

You are on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle.

To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

export default (env, argv) => {
const IS_DEV = argv.mode === "development";
// git hash makes the build traceable
const GIT_HASH = execSync("git rev-parse --short HEAD").toString().trim();
Copy link

Choose a reason for hiding this comment

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

Git command runs unconditionally, breaks builds without git

Low Severity

The execSync("git rev-parse --short HEAD") command runs unconditionally every time webpack loads, even in development mode where GIT_HASH isn't used (since BUILD_VERSION is just "dev"). This causes webpack to crash in environments without git installed or without a .git directory (e.g., building from a source tarball, some Docker containers, or CI systems with shallow/no git). The git command could be moved inside a conditional that only executes when !IS_DEV.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chunk load error | Unhandled Rejection

1 participant