-
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
Changes from all commits
dfbd413
8342361
76b7605
cd550c5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,3 +26,5 @@ declare module "*.jpg" { | |
| declare module "*.jpeg" { | ||
| export = imageUrl; | ||
| } | ||
|
|
||
| declare const BUILD_VERSION: string; | ||
| 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; | ||
tyler-dane marked this conversation as resolved.
Show resolved
Hide 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; | ||
| 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
|
||
| 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 }; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,8 +47,6 @@ export async function clearAllBrowserStorage(): Promise<void> { | |
| }); | ||
| } | ||
| } | ||
|
Comment on lines
47
to
49
|
||
|
|
||
| console.log("Browser storage cleared successfully"); | ||
| } catch (error) { | ||
| console.error("Error clearing browser storage:", error); | ||
| throw error; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.