diff --git a/AGENTS.md b/AGENTS.md index 064c3ccd..adbd3a42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -463,6 +463,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro - The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`. - API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`. - Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`. +- The build also emits `client/dist/version.json` (`{"version": …}`) from a small `vite.config.ts` plugin using the **same** `appVersion` that feeds `define.APP_VERSION`, so the file and the baked-in bundle version are identical by construction. The server reads it at startup (`ws.gateway.ts` via `readClientBuildVersion`/`resolveClientDistPath`) and announces it to each socket on connect (`app-version` event) so a tab left open across a redeploy can guard-reload before hitting a stale chunk (version-coherence). No runtime env / Dockerfile change — the file already ships in `client/dist`; missing/empty file ⇒ feature inert. ## Conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b44878a..b52a8364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Open tabs pick up a new deploy on their own.** After the server is + redeployed while a tab is left open for hours, the tab now learns the new + build version over the existing WebSocket (announced per-connect, so a natural + reconnect delivers it) and shows a "A new version is available" banner with an + Update button. To avoid dropping a half-written comment or form, the tab is + not reloaded when you merely switch away from it; instead it auto-reloads at + the next safe point — the next in-app navigation (or immediately if you click + Update) — before it can hit a stale lazy-loaded chunk. At most one automatic + reload happens per browser session (shared with the existing chunk-load + recovery), so a permanent version skew degrades to the banner rather than a + reload loop. When the build carries no version info the feature stays inert. + - **Place several images side by side in a row.** A new "Inline (side by side)" alignment mode in the image bubble menu renders consecutive inline images as a row that wraps onto the next line on narrow screens. The row is diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 837217a3..fb862a3a 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1,4 +1,5 @@ { + "A new version is available": "A new version is available", "Account": "Account", "Active": "Active", "Add": "Add", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index ddde9041..814bca0b 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1,4 +1,5 @@ { + "A new version is available": "Доступна новая версия", "Account": "Аккаунт", "Active": "Активный", "Add": "Добавить", diff --git a/apps/client/src/components/chunk-load-error-boundary.tsx b/apps/client/src/components/chunk-load-error-boundary.tsx index c3e39a00..6080017b 100644 --- a/apps/client/src/components/chunk-load-error-boundary.tsx +++ b/apps/client/src/components/chunk-load-error-boundary.tsx @@ -1,8 +1,11 @@ import { ReactNode } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { Button, Center, Stack, Text } from "@mantine/core"; - -const RELOAD_FLAG = "chunk-reload-attempted"; +import { + hasAutoReloaded, + markAutoReloaded, + recordReloadBreadcrumb, +} from "@/lib/reload-guard"; // Heuristic detection of a failed dynamic import. Since the code-splitting work, // every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy @@ -25,16 +28,15 @@ function handleError(error: unknown) { if (!isChunkLoadError(error)) return; // A stale-chunk 404 is cured by a full reload that re-fetches index.html and // the new chunk manifest. Auto-reload once, guarding against a reload loop - // (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the - // flag is already set we fall through to the manual recovery UI below. - try { - if (sessionStorage.getItem(RELOAD_FLAG)) return; - sessionStorage.setItem(RELOAD_FLAG, "1"); - } catch { - // sessionStorage unavailable (private mode / disabled): skip the automatic - // reload rather than risk an unguarded loop; the fallback UI still recovers. - return; - } + // (e.g. a genuinely missing chunk) with the shared one-shot session flag + // (see @/lib/reload-guard — shared with the proactive version-coherence + // path). If it is already set, or the write fails (storage unavailable), we + // fall through to the manual recovery UI below rather than risk a loop. + if (hasAutoReloaded()) return; + if (!markAutoReloaded()) return; + // Trace before the reload clears the console (same diagnostic breadcrumb the + // proactive version-coherence path writes, tagged with this path). + recordReloadBreadcrumb({ path: "chunk-boundary" }); window.location.reload(); } diff --git a/apps/client/src/features/user/guarded-reload.test.tsx b/apps/client/src/features/user/guarded-reload.test.tsx new file mode 100644 index 00000000..72ca5234 --- /dev/null +++ b/apps/client/src/features/user/guarded-reload.test.tsx @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, act, cleanup } from "@testing-library/react"; +import { MemoryRouter, useNavigate } from "react-router-dom"; + +// Mocks for the dirty shell's side-effecting collaborators. +vi.mock("@mantine/notifications", () => ({ + notifications: { show: vi.fn() }, +})); +vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } })); +vi.mock("@/lib/reload-guard", () => ({ + hasAutoReloaded: vi.fn(() => false), + markAutoReloaded: vi.fn(() => true), + recordReloadBreadcrumb: vi.fn(), + takeReloadBreadcrumb: vi.fn(() => null), +})); + +import { notifications } from "@mantine/notifications"; +import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard"; +import { + triggerGuardedReload, + useVersionReloadOnNavigation, + __resetGuardedReloadForTests, +} from "./guarded-reload"; + +const show = notifications.show as unknown as ReturnType; +const mockHasAutoReloaded = hasAutoReloaded as unknown as ReturnType< + typeof vi.fn +>; +const mockMarkAutoReloaded = markAutoReloaded as unknown as ReturnType< + typeof vi.fn +>; + +let reload: ReturnType; +let visibility: DocumentVisibilityState; + +// Test harness mounted inside a router: it installs the navigation hook and +// exposes `navigate` so a test can drive an in-app router navigation. +let doNavigate: (to: string) => void; +function Harness() { + useVersionReloadOnNavigation(); + const navigate = useNavigate(); + doNavigate = navigate; + return null; +} + +function mountHarness() { + render( + + + , + ); +} + +function navigateTo(path: string) { + act(() => { + doNavigate(path); + }); +} + +beforeEach(() => { + __resetGuardedReloadForTests(); + vi.clearAllMocks(); + mockHasAutoReloaded.mockReturnValue(false); + mockMarkAutoReloaded.mockReturnValue(true); + + vi.stubGlobal("APP_VERSION", "test-A"); + + reload = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { reload }, + }); + + visibility = "visible"; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibility, + }); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("triggerGuardedReload (variant C)", () => { + it("noop when versions match: no banner, no reload", () => { + triggerGuardedReload("test-A"); + expect(show).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + }); + + it("noop when the server version is empty (fail-safe)", () => { + triggerGuardedReload(""); + triggerGuardedReload(undefined); + expect(show).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + }); + + it("real mismatch shows the banner but does NOT reload immediately", () => { + mountHarness(); + triggerGuardedReload("test-B"); + expect(show).toHaveBeenCalledTimes(1); + expect(show.mock.calls[0][0]).toMatchObject({ + id: "app-version-reload", + autoClose: false, + withCloseButton: true, + }); + expect(reload).not.toHaveBeenCalled(); + }); + + it("reloads EXACTLY ONCE on the first in-app navigation after a mismatch", () => { + mountHarness(); + triggerGuardedReload("test-B"); + expect(reload).not.toHaveBeenCalled(); + + navigateTo("/next"); + expect(reload).toHaveBeenCalledTimes(1); + + // A second navigation must NOT reload again (one-shot was consumed). + navigateTo("/again"); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("does NOT reload on a 2nd mismatch after the first was armed/consumed (one-shot)", () => { + mountHarness(); + triggerGuardedReload("test-B"); + navigateTo("/next"); + expect(reload).toHaveBeenCalledTimes(1); + + // Another app-version mismatch arrives (reconnect): must not re-arm. + triggerGuardedReload("test-C"); + navigateTo("/again"); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("does NOT reload merely from the tab going to the background", () => { + mountHarness(); + triggerGuardedReload("test-B"); + expect(reload).not.toHaveBeenCalled(); + + visibility = "hidden"; + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(reload).not.toHaveBeenCalled(); + }); + + it("a hidden-at-receipt tab is also NOT reloaded immediately (variant C uniform); reloads on next navigation", () => { + visibility = "hidden"; + mountHarness(); + triggerGuardedReload("test-B"); + expect(reload).not.toHaveBeenCalled(); + expect(show).toHaveBeenCalledTimes(1); + + navigateTo("/next"); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("the banner's Update button reloads immediately", () => { + triggerGuardedReload("test-B"); + const message = show.mock.calls[0][0].message as { + props: { onClick: () => void }; + }; + message.props.onClick(); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("banner-only (auto-reload already spent): banner, never auto-reload on navigation", () => { + mockHasAutoReloaded.mockReturnValue(true); + mountHarness(); + triggerGuardedReload("test-B"); + expect(show).toHaveBeenCalledTimes(1); + + navigateTo("/next"); + expect(reload).not.toHaveBeenCalled(); + }); + + it("does NOT reload when the flag write fails; falls back to the banner", () => { + mockMarkAutoReloaded.mockReturnValue(false); + mountHarness(); + triggerGuardedReload("test-B"); + navigateTo("/next"); + expect(reload).not.toHaveBeenCalled(); + // performAutoReload falls back to showing the banner (initial + fallback). + expect(show).toHaveBeenCalled(); + }); + + it("is idempotent within a tab-load: repeated emits do not stack banners", () => { + triggerGuardedReload("test-B"); + triggerGuardedReload("test-B"); + triggerGuardedReload("test-C"); + expect(show).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/client/src/features/user/guarded-reload.tsx b/apps/client/src/features/user/guarded-reload.tsx new file mode 100644 index 00000000..82b81d14 --- /dev/null +++ b/apps/client/src/features/user/guarded-reload.tsx @@ -0,0 +1,187 @@ +import { useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; +import { Button } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import i18n from "@/i18n.ts"; +import { + hasAutoReloaded, + markAutoReloaded, + recordReloadBreadcrumb, + takeReloadBreadcrumb, +} from "@/lib/reload-guard"; +import { decideVersionAction } from "@/features/user/version-coherence"; + +// Dirty shell around the pure `decideVersionAction`: it reads globals +// (APP_VERSION), touches sessionStorage via the shared reload-guard, drives the +// Mantine notification, and arms the router-navigation reload hook. Kept +// separate from the pure module so the decision stays unit-testable without a +// DOM. + +// One fixed id so repeated app-version signals (e.g. every reconnect) update a +// single banner instead of stacking a new one each time. +const BANNER_ID = "app-version-reload"; + +// Module-level idempotency for the current tab-load: once a mismatch has been +// handled we don't re-arm the navigation reload or re-show the banner on +// subsequent app-version emits. +let handled = false; + +// Variant C: on a real mismatch we do NOT reload the tab when it merely goes to +// the background (that would silently drop a half-written comment/form). Instead +// we arm a one-shot reload for the NEXT in-app router navigation — a point where +// the user is already leaving the current page, so an in-app navigation would +// discard that unsaved component-state anyway and the reload adds no extra loss. +let pendingNavReload = false; + +// Remembered from the last detected mismatch for the pre-reload breadcrumb and +// the (already-visible) banner. +let lastServerVersion = ""; +let lastClientVersion = ""; + +// Read the build version baked into THIS bundle. The `typeof` guard avoids a +// ReferenceError where the `APP_VERSION` global is absent (e.g. under vitest, +// where Vite's `define` did not run) — an unknown client version makes the +// pure decision no-op (fail-safe). +function readClientVersion(): string { + return (typeof APP_VERSION !== "undefined" ? APP_VERSION : "").trim(); +} + +// Perform the actual reload — but only after the shared one-shot flag is +// persisted. If the write fails (storage unavailable) we must NOT reload +// (mirrors the reactive chunk-load boundary's `catch → return`), and fall back +// to the manual banner so the user can still recover. +function performAutoReload(): void { + if (!markAutoReloaded()) { + showReloadBanner(); + return; + } + // Trace right before the reload (which clears the console): a persistent + // breadcrumb + a log line so the auto-reload is observable in a field report. + recordReloadBreadcrumb({ + path: "proactive", + serverVersion: lastServerVersion, + clientVersion: lastClientVersion, + }); + console.warn( + `[version-coherence] auto-reloading: client=${lastClientVersion} -> server=${lastServerVersion}`, + ); + window.location.reload(); +} + +function showReloadBanner(): void { + notifications.show({ + id: BANNER_ID, + title: i18n.t("A new version is available"), + message: ( + + ), + autoClose: false, + withCloseButton: true, + }); +} + +/** + * Handle a server `app-version` announcement: compare it to this bundle's + * version and, on a real mismatch, show the banner and arm a guarded reload for + * the next in-app navigation (variant C). + * + * - real mismatch (first this session) → banner + arm navigation reload. The + * banner's "Update" button reloads immediately (same one-shot guard). The tab + * is NOT reloaded on visibility change. + * - auto-reload already used / storage error → banner only (no arm), so there is + * at most one automatic reload per session (loop safety). + * - in sync / unknown version → noop (fail-safe). + */ +export function triggerGuardedReload( + rawServerVersion: string | undefined | null, +): void { + const serverVersion = (rawServerVersion ?? "").trim(); + const clientVersion = readClientVersion(); + + // A storage read error surfaces as autoReloadUsed=true → fail toward NOT + // reloading (banner only). + const autoReloadUsed = hasAutoReloaded(); + + const action = decideVersionAction({ + serverVersion, + clientVersion, + autoReloadUsed, + }); + if (action === "noop") return; + + // Idempotent per tab-load: don't re-arm or re-stack the banner across repeated + // emits (reconnects) once we've already acted. + if (handled) return; + handled = true; + + lastServerVersion = serverVersion; + lastClientVersion = clientVersion; + + if (action === "banner") { + // Entered banner-only (permanent skew, node oscillation, or spent + // auto-reload). Log for diagnosability; show the manual banner. + console.warn( + `[version-coherence] server=${serverVersion} client=${clientVersion}: ` + + "auto-reload already spent this session — showing manual banner", + ); + showReloadBanner(); + return; + } + + // action === "reload" (variant C): show the banner and defer the auto-reload + // to the next in-app navigation instead of reloading now / on visibility. + showReloadBanner(); + pendingNavReload = true; +} + +/** + * Consume the armed one-shot navigation reload, if any. Called by + * `useVersionReloadOnNavigation` on each in-app router navigation. + */ +export function consumeNavigationReload(): void { + if (!pendingNavReload) return; + pendingNavReload = false; + performAutoReload(); +} + +/** + * Hook (mounted inside the Router) that fires the armed one-shot reload on the + * NEXT in-app router navigation after a version mismatch. Skips the initial + * render so it only reacts to real navigations, not the first location. + */ +export function useVersionReloadOnNavigation(): void { + const location = useLocation(); + const firstRender = useRef(true); + useEffect(() => { + if (firstRender.current) { + firstRender.current = false; + return; + } + consumeNavigationReload(); + }, [location.key]); +} + +/** + * Surface (log once) the breadcrumb left by an auto-reload in the previous page + * load — the reload cleared the console, so this makes a "tab reloaded itself" + * report diagnosable. Call once on app startup. + */ +export function surfacePreviousReloadBreadcrumb(): void { + const crumb = takeReloadBreadcrumb(); + if (!crumb) return; + console.info( + `[version-coherence] previous auto-reload: path=${crumb.path} ` + + `client=${crumb.clientVersion ?? ""} -> server=${crumb.serverVersion ?? ""} ` + + `at=${new Date(crumb.at).toISOString()}`, + ); +} + +// Test-only: reset module-level latches between cases. +export function __resetGuardedReloadForTests(): void { + handled = false; + pendingNavReload = false; + lastServerVersion = ""; + lastClientVersion = ""; +} diff --git a/apps/client/src/features/user/user-provider.tsx b/apps/client/src/features/user/user-provider.tsx index 5c29203c..20d6bc92 100644 --- a/apps/client/src/features/user/user-provider.tsx +++ b/apps/client/src/features/user/user-provider.tsx @@ -13,6 +13,12 @@ import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; import { Error404 } from "@/components/ui/error-404.tsx"; import { queryClient } from "@/main.tsx"; import { makeConnectHandler } from "@/features/user/connect-resync.ts"; +import { + triggerGuardedReload, + useVersionReloadOnNavigation, + surfacePreviousReloadBreadcrumb, +} from "@/features/user/guarded-reload.tsx"; +import type { AppVersionSocketPayload } from "@/features/user/version-coherence.ts"; export function UserProvider({ children }: React.PropsWithChildren) { const [, setCurrentUser] = useAtom(currentUserAtom); @@ -22,6 +28,16 @@ export function UserProvider({ children }: React.PropsWithChildren) { // fetch collab token on load const { data: collab } = useCollabToken(); + // version-coherence: fire the armed one-shot reload on the next in-app + // navigation (variant C — a safe point, not on tab backgrounding). + useVersionReloadOnNavigation(); + + // Surface any breadcrumb left by an auto-reload in the previous page load + // (the reload cleared the console) so a field report stays diagnosable. + useEffect(() => { + surfacePreviousReloadBreadcrumb(); + }, []); + useEffect(() => { if (isLoading || isError) { return; @@ -47,6 +63,16 @@ export function UserProvider({ children }: React.PropsWithChildren) { handleConnect(); }); + // Register the version-coherence listener SYNCHRONOUSLY, before the socket + // connects: the server emits `app-version` immediately in handleConnection, + // so a listener attached after connect would miss it on a fast localhost + // connect. On a version mismatch the client shows a banner and defers the + // auto-reload to the next in-app navigation (variant C — avoids reloading a + // backgrounded tab that may hold unsaved input) before it hits a stale chunk. + newSocket.on("app-version", (payload?: AppVersionSocketPayload) => { + triggerGuardedReload(payload?.version); + }); + return () => { console.log("ws disconnected"); newSocket.disconnect(); diff --git a/apps/client/src/features/user/version-coherence.test.ts b/apps/client/src/features/user/version-coherence.test.ts new file mode 100644 index 00000000..787e9c9d --- /dev/null +++ b/apps/client/src/features/user/version-coherence.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { decideVersionAction } from "./version-coherence"; + +describe("decideVersionAction", () => { + it("noop when the server version is empty (fail-safe)", () => { + expect( + decideVersionAction({ + serverVersion: "", + clientVersion: "v1", + autoReloadUsed: false, + }), + ).toBe("noop"); + }); + + it("noop when the client version is empty (fail-safe)", () => { + expect( + decideVersionAction({ + serverVersion: "v1", + clientVersion: "", + autoReloadUsed: false, + }), + ).toBe("noop"); + }); + + it("noop when versions are equal (in sync)", () => { + expect( + decideVersionAction({ + serverVersion: "v1", + clientVersion: "v1", + autoReloadUsed: false, + }), + ).toBe("noop"); + }); + + it("reload on a real mismatch the first time this session", () => { + expect( + decideVersionAction({ + serverVersion: "test-B", + clientVersion: "test-A", + autoReloadUsed: false, + }), + ).toBe("reload"); + }); + + it("banner on a mismatch once the session auto-reload is spent", () => { + expect( + decideVersionAction({ + serverVersion: "test-B", + clientVersion: "test-A", + autoReloadUsed: true, + }), + ).toBe("banner"); + }); + + it("equal versions stay noop even if auto-reload was already used", () => { + expect( + decideVersionAction({ + serverVersion: "v1", + clientVersion: "v1", + autoReloadUsed: true, + }), + ).toBe("noop"); + }); +}); diff --git a/apps/client/src/features/user/version-coherence.ts b/apps/client/src/features/user/version-coherence.ts new file mode 100644 index 00000000..ba99d757 --- /dev/null +++ b/apps/client/src/features/user/version-coherence.ts @@ -0,0 +1,32 @@ +// Payload of the per-connect `app-version` socket.io event announced by the +// server (ws.gateway.ts) after a successful auth. A dedicated event — NOT a +// member of the room-scoped `WebSocketEvent` union (which is discriminated by +// `operation`), so it never touches use-query-subscription. +export type AppVersionSocketPayload = { version: string }; + +/** + * Pure decision for the version-coherence guard. + * + * All inputs are injected (no globals, no side effects) so it is unit-testable + * without a DOM or the build-time `APP_VERSION` global (undefined under vitest). + * + * - `autoReloadUsed` = a session-wide automatic reload has already happened, + * so we must not auto-reload again (loop safety, shared with the reactive + * chunk-load boundary). + * + * Returns: + * - "noop" — do nothing (unknown version on either side, or already in sync). + * - "banner" — show the manual "update available" banner only (no auto-reload). + * - "reload" — real first-time mismatch: eligible for a guarded auto-reload. + */ +export function decideVersionAction(args: { + serverVersion: string; + clientVersion: string; + autoReloadUsed: boolean; +}): "reload" | "banner" | "noop" { + const { serverVersion, clientVersion, autoReloadUsed } = args; + if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act + if (serverVersion === clientVersion) return "noop"; // in sync + if (autoReloadUsed) return "banner"; // one auto-reload per session already spent + return "reload"; // real mismatch, first time this session +} diff --git a/apps/client/src/lib/reload-guard.test.ts b/apps/client/src/lib/reload-guard.test.ts new file mode 100644 index 00000000..4afa7d2b --- /dev/null +++ b/apps/client/src/lib/reload-guard.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + hasAutoReloaded, + markAutoReloaded, + recordReloadBreadcrumb, + takeReloadBreadcrumb, +} from "./reload-guard"; + +const FLAG = "chunk-reload-attempted"; + +describe("reload-guard", () => { + beforeEach(() => { + sessionStorage.clear(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + sessionStorage.clear(); + vi.restoreAllMocks(); + }); + + it("hasAutoReloaded is false before any reload, true after mark", () => { + expect(hasAutoReloaded()).toBe(false); + expect(markAutoReloaded()).toBe(true); + expect(hasAutoReloaded()).toBe(true); + // Uses the same key the reactive chunk-load boundary reads. + expect(sessionStorage.getItem(FLAG)).toBe("1"); + }); + + it("hasAutoReloaded returns true when reading storage throws (fail toward not reloading)", () => { + vi.stubGlobal("sessionStorage", { + getItem: () => { + throw new Error("storage disabled"); + }, + setItem: () => { + throw new Error("storage disabled"); + }, + }); + try { + expect(hasAutoReloaded()).toBe(true); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("markAutoReloaded returns false when writing storage throws", () => { + vi.stubGlobal("sessionStorage", { + getItem: () => null, + setItem: () => { + throw new Error("storage disabled"); + }, + }); + try { + expect(markAutoReloaded()).toBe(false); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("records and then takes a breadcrumb once (cleared on read)", () => { + recordReloadBreadcrumb({ + path: "proactive", + serverVersion: "test-B", + clientVersion: "test-A", + }); + const crumb = takeReloadBreadcrumb(); + expect(crumb).toMatchObject({ + path: "proactive", + serverVersion: "test-B", + clientVersion: "test-A", + }); + expect(typeof crumb?.at).toBe("number"); + // Cleared on read → a second take returns null. + expect(takeReloadBreadcrumb()).toBeNull(); + }); + + it("takeReloadBreadcrumb returns null when nothing was recorded", () => { + expect(takeReloadBreadcrumb()).toBeNull(); + }); + + it("recordReloadBreadcrumb swallows a storage-write error (diagnostics only)", () => { + vi.stubGlobal("sessionStorage", { + getItem: () => null, + setItem: () => { + throw new Error("storage disabled"); + }, + removeItem: () => {}, + }); + try { + expect(() => + recordReloadBreadcrumb({ path: "chunk-boundary" }), + ).not.toThrow(); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/apps/client/src/lib/reload-guard.ts b/apps/client/src/lib/reload-guard.ts new file mode 100644 index 00000000..754827c1 --- /dev/null +++ b/apps/client/src/lib/reload-guard.ts @@ -0,0 +1,90 @@ +// Shared one-shot auto-reload guard. +// +// Both auto-reload paths — the reactive chunk-load-error-boundary (recovers +// AFTER a stale lazy chunk 404s) and the proactive version-coherence feature +// (reloads BEFORE the tab hits a stale chunk) — go through these functions so +// they share ONE session-scoped flag. Net guarantee: at most a single +// automatic reload per browser session across both paths. Once the flag is set +// (or sessionStorage is unavailable), every further mismatch degrades to a +// manual banner/UI — no reload loop under permanent skew, node oscillation, or +// a disabled storage. +const RELOAD_FLAG = "chunk-reload-attempted"; + +/** + * Has an automatic reload already been performed (or attempted) this session? + * + * A storage read error (private mode / disabled) is reported as `true` so the + * caller fails toward NOT reloading — an unguarded loop is worse than a stale + * tab the user can reload manually. + */ +export function hasAutoReloaded(): boolean { + try { + return sessionStorage.getItem(RELOAD_FLAG) !== null; + } catch { + return true; + } +} + +/** + * Record that an automatic reload is being performed this session. + * + * Returns whether the write succeeded. A `false` return (storage unavailable) + * means the caller MUST NOT reload — otherwise the flag would never stick and + * the reload could loop. + */ +export function markAutoReloaded(): boolean { + try { + sessionStorage.setItem(RELOAD_FLAG, "1"); + return true; + } catch { + return false; + } +} + +// Diagnostic breadcrumb for an automatic reload. Written right before +// window.location.reload() (which clears the console) and read back on the next +// page load, so a "the tab reloaded itself / it's looping" field report is +// diagnosable: which path fired (proactive version-coherence vs the reactive +// chunk-load boundary) and which version pair triggered it. sessionStorage +// survives a same-tab reload, unlike the console. +const RELOAD_BREADCRUMB_KEY = "reload-breadcrumb"; + +export type ReloadBreadcrumb = { + path: "proactive" | "chunk-boundary"; + serverVersion?: string; + clientVersion?: string; + at: number; +}; + +/** + * Persist a best-effort breadcrumb just before an automatic reload. Failures + * (storage unavailable) are swallowed — this is diagnostics only and must never + * block or alter the reload decision. + */ +export function recordReloadBreadcrumb( + entry: Omit, +): void { + try { + sessionStorage.setItem( + RELOAD_BREADCRUMB_KEY, + JSON.stringify({ ...entry, at: Date.now() }), + ); + } catch { + // best-effort diagnostics only + } +} + +/** + * Read and clear the breadcrumb left by an auto-reload in the previous page + * load. Cleared on read so it surfaces exactly once per reload. + */ +export function takeReloadBreadcrumb(): ReloadBreadcrumb | null { + try { + const raw = sessionStorage.getItem(RELOAD_BREADCRUMB_KEY); + if (!raw) return null; + sessionStorage.removeItem(RELOAD_BREADCRUMB_KEY); + return JSON.parse(raw) as ReloadBreadcrumb; + } catch { + return null; + } +} diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index c93c7a7b..25041afb 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -1,7 +1,8 @@ -import { defineConfig, loadEnv } from "vite"; +import { defineConfig, loadEnv, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; import { compression } from "vite-plugin-compression2"; import * as path from "path"; +import * as fs from "node:fs"; import { execSync } from "node:child_process"; const envPath = path.resolve(process.cwd(), "..", ".."); @@ -24,7 +25,32 @@ function resolveAppVersion(cwd: string): string { } } +// Emit /version.json = { "version": appVersion } so the server can read +// the exact same build id the bundle was compiled with. The value is the SAME +// `appVersion` fed into `define.APP_VERSION`, so version.json and the baked-in +// global are identical by construction — the single source of truth (no +// runtime-env second copy that could drift and cause a false version mismatch). +function versionJsonPlugin(version: string): Plugin { + let outDir = "dist"; + return { + name: "emit-version-json", + apply: "build", + configResolved(config) { + outDir = config.build.outDir; + }, + writeBundle() { + const root = path.resolve(process.cwd(), outDir); + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync( + path.join(root, "version.json"), + JSON.stringify({ version }), + ); + }, + }; +} + export default defineConfig(({ mode }) => { + const appVersion = resolveAppVersion(envPath); const { APP_URL, FILE_UPLOAD_SIZE_LIMIT, @@ -52,10 +78,11 @@ export default defineConfig(({ mode }) => { POSTHOG_HOST, POSTHOG_KEY, }, - APP_VERSION: JSON.stringify(resolveAppVersion(envPath)), + APP_VERSION: JSON.stringify(appVersion), }, plugins: [ react(), + versionJsonPlugin(appVersion), // Emit .br and .gz next to every built asset so the server can serve the // precompressed copy (see @fastify/static preCompressed in static.module.ts). compression({ diff --git a/apps/server/src/common/helpers/client-version.spec.ts b/apps/server/src/common/helpers/client-version.spec.ts new file mode 100644 index 00000000..604a6403 --- /dev/null +++ b/apps/server/src/common/helpers/client-version.spec.ts @@ -0,0 +1,52 @@ +import { join } from 'path'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import { readClientBuildVersion } from './client-version'; + +describe('readClientBuildVersion', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(join(os.tmpdir(), 'client-version-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const writeVersionJson = (content: string) => + fs.writeFileSync(join(dir, 'version.json'), content); + + it('returns the version from a valid version.json', () => { + writeVersionJson(JSON.stringify({ version: 'test-A' })); + expect(readClientBuildVersion(dir)).toBe('test-A'); + }); + + it('trims surrounding whitespace in the version', () => { + writeVersionJson(JSON.stringify({ version: ' v1.2.3 ' })); + expect(readClientBuildVersion(dir)).toBe('v1.2.3'); + }); + + it('returns "" when version.json is missing', () => { + expect(readClientBuildVersion(dir)).toBe(''); + }); + + it('returns "" on malformed JSON', () => { + writeVersionJson('{ not json'); + expect(readClientBuildVersion(dir)).toBe(''); + }); + + it('returns "" when the version field is absent', () => { + writeVersionJson(JSON.stringify({ notVersion: 'x' })); + expect(readClientBuildVersion(dir)).toBe(''); + }); + + it('returns "" when the version field is not a string', () => { + writeVersionJson(JSON.stringify({ version: 123 })); + expect(readClientBuildVersion(dir)).toBe(''); + }); + + it('returns "" when the path does not exist at all', () => { + expect(readClientBuildVersion(join(dir, 'nope'))).toBe(''); + }); +}); diff --git a/apps/server/src/common/helpers/client-version.ts b/apps/server/src/common/helpers/client-version.ts new file mode 100644 index 00000000..9c4281b7 --- /dev/null +++ b/apps/server/src/common/helpers/client-version.ts @@ -0,0 +1,36 @@ +import { join } from 'path'; +import * as fs from 'node:fs'; + +/** + * Resolve the absolute path to the built client bundle directory + * (`apps/client/dist`) shipped into the runtime image. + * + * The `../` depth is anchored on THIS module's compiled location + * (`dist/common/helpers`). `integrations/static` sits at the same depth under + * the compiled root, so both callers (StaticModule and readClientBuildVersion) + * MUST share this single helper rather than duplicating the depth — a copy in a + * module at a different depth would silently resolve to the wrong directory. + */ +export function resolveClientDistPath(): string { + return join(__dirname, '..', '..', '..', '..', 'client/dist'); +} + +/** + * Read the build version the client bundle was compiled with, from + * `/version.json` (written by the Vite build — the single + * source of truth shared by the baked-in `APP_VERSION` global and this file). + * + * Fail-safe: any error (missing file, unreadable, bad JSON, non-string + * version) yields `''`. The caller treats an empty version as "unknown" and + * the whole version-coherence feature stays silently inert — existing deploys + * without the file keep working unchanged. + */ +export function readClientBuildVersion(clientDistPath: string): string { + try { + const raw = fs.readFileSync(join(clientDistPath, 'version.json'), 'utf8'); + const version = (JSON.parse(raw) as { version?: unknown }).version; + return typeof version === 'string' ? version.trim() : ''; + } catch { + return ''; + } +} diff --git a/apps/server/src/common/helpers/index.ts b/apps/server/src/common/helpers/index.ts index 80e9a902..8a0e2be1 100644 --- a/apps/server/src/common/helpers/index.ts +++ b/apps/server/src/common/helpers/index.ts @@ -3,3 +3,4 @@ export * from './nanoid.utils'; export * from './file.helper'; export * from './constants'; export * from './security-headers'; +export * from './client-version'; diff --git a/apps/server/src/integrations/static/static.module.ts b/apps/server/src/integrations/static/static.module.ts index ea2d398f..6b4e878b 100644 --- a/apps/server/src/integrations/static/static.module.ts +++ b/apps/server/src/integrations/static/static.module.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import * as fs from 'node:fs'; import fastifyStatic from '@fastify/static'; import { EnvironmentService } from '../environment/environment.service'; +import { resolveClientDistPath } from '../../common/helpers/client-version'; /** * Resolve the response headers for a statically served client asset. @@ -56,14 +57,7 @@ export class StaticModule implements OnModuleInit { const httpAdapter = this.httpAdapterHost.httpAdapter; const app = httpAdapter.getInstance(); - const clientDistPath = join( - __dirname, - '..', - '..', - '..', - '..', - 'client/dist', - ); + const clientDistPath = resolveClientDistPath(); const indexFilePath = join(clientDistPath, 'index.html'); diff --git a/apps/server/src/ws/ws.gateway.ts b/apps/server/src/ws/ws.gateway.ts index a4f66257..087e109c 100644 --- a/apps/server/src/ws/ws.gateway.ts +++ b/apps/server/src/ws/ws.gateway.ts @@ -9,10 +9,14 @@ import { import { Server, Socket } from 'socket.io'; import { TokenService } from '../core/auth/services/token.service'; import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload'; -import { OnModuleDestroy } from '@nestjs/common'; +import { Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { WsService } from './ws.service'; import { getSpaceRoomName, getUserRoomName } from './ws.utils'; +import { + readClientBuildVersion, + resolveClientDistPath, +} from '../common/helpers/client-version'; import * as cookie from 'cookie'; @WebSocketGateway({ @@ -20,17 +24,40 @@ import * as cookie from 'cookie'; transports: ['websocket'], }) export class WsGateway - implements OnGatewayConnection, OnGatewayInit, OnModuleDestroy + implements + OnGatewayConnection, + OnGatewayInit, + OnModuleInit, + OnModuleDestroy { @WebSocketServer() server: Server; + private readonly logger = new Logger(WsGateway.name); + + // The build version of the client bundle shipped in this image, read once at + // startup from client/dist/version.json (single source of truth, same value + // baked into the client's APP_VERSION). Empty string => version.json missing + // or empty => the proactive version-coherence reload feature stays inert. + private appVersion = ''; + constructor( private tokenService: TokenService, private spaceMemberRepo: SpaceMemberRepo, private wsService: WsService, ) {} + onModuleInit(): void { + this.appVersion = readClientBuildVersion(resolveClientDistPath()); + if (this.appVersion) { + this.logger.log(`app-version reload: ACTIVE (v=${this.appVersion})`); + } else { + this.logger.log( + 'app-version reload: DISABLED (version.json missing/empty)', + ); + } + } + afterInit(server: Server): void { this.wsService.setServer(server); } @@ -55,6 +82,14 @@ export class WsGateway const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id)); client.join([userRoom, workspaceRoom, ...spaceRooms]); + + // Announce this container's client build version to the freshly + // authenticated socket. On a redeploy the client reconnects to the new + // container and receives the new version here, letting it guard-reload + // before it hits a stale lazy chunk. Per-connect only (no broadcast): + // natural reconnect covers both single-container and cluster without a + // thundering-herd fleet reload. + client.emit('app-version', { version: this.appVersion }); } catch (err) { client.emit('Unauthorized'); client.disconnect();