-
Notifications
You must be signed in to change notification settings - Fork 50
feat(web): add version update flow #1438
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this 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.jsonfile during production builds - Created
useVersionCheckhook 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 |
packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx
Outdated
Show resolved
Hide resolved
packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx
Outdated
Show resolved
Hide resolved
- 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.
70d4717 to
76b7605
Compare
There was a problem hiding this 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.
There was a problem hiding this 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.
| 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" }), | ||
| }); | ||
| }); | ||
| }); | ||
| }); |
Copilot
AI
Feb 5, 2026
There was a problem hiding this comment.
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.
| {gCalImport.importing ? ( | ||
| <TooltipWrapper description="Importing your calendar events in the background"> | ||
| <SpinnerIcon disabled /> | ||
| <SpinnerIcon /> |
Copilot
AI
Feb 5, 2026
There was a problem hiding this comment.
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
AI
Feb 5, 2026
There was a problem hiding this comment.
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.
| 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}`; |
Copilot
AI
Feb 5, 2026
There was a problem hiding this comment.
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.
| const BUILD_VERSION = IS_DEV ? "dev" : `${Date.now()}-${GIT_HASH}`; | |
| const BUILD_VERSION = IS_DEV ? "dev" : GIT_HASH; |
| 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(); | ||
| }, | ||
| ); | ||
| }, | ||
| }); | ||
| } |
Copilot
AI
Feb 5, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this 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(); |
There was a problem hiding this comment.
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.


useVersionCheckhook to monitor application version and notify users of updates.SidebarIconRowcomponent, displaying a download icon when an update is available.BUILD_VERSIONconstant in the webpack configuration to manage versioning.version.jsonfile during production builds to store the current version.useVersionCheckhook 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 aversion.jsonasset so the client can compare its running version against the deployed one.Introduces
useVersionCheck, which polls/version.jsonon 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.