From acd65e365d624b6ef5cffeae6471315122eb6b0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Fran=C3=A7a?= Date: Sat, 11 Jul 2026 01:16:10 -0300 Subject: [PATCH] =?UTF-8?q?Revert=20"Revert=20"feat(web):=20prompt=20refre?= =?UTF-8?q?sh=20when=20the=20server=20version=20settles=20on=20=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit ebcbb42df57c7d22852260ab0834969ee446a381. --- apps/mesh/src/api/routes/public-config.ts | 14 +++ .../components/version-check-dialog.test.ts | 41 ++++++++ .../web/components/version-check-dialog.tsx | 95 +++++++++++++++++++ apps/mesh/src/web/layouts/shell-layout.tsx | 3 + apps/mesh/src/web/lib/query-keys.ts | 4 + 5 files changed, 157 insertions(+) create mode 100644 apps/mesh/src/web/components/version-check-dialog.test.ts create mode 100644 apps/mesh/src/web/components/version-check-dialog.tsx diff --git a/apps/mesh/src/api/routes/public-config.ts b/apps/mesh/src/api/routes/public-config.ts index 5b77e7dfd8..b5c9df9b36 100644 --- a/apps/mesh/src/api/routes/public-config.ts +++ b/apps/mesh/src/api/routes/public-config.ts @@ -11,6 +11,7 @@ import { isLocalMode } from "@/auth/local-mode"; import { getInternalUrl } from "@/core/server-constants"; import { getSettings } from "@/settings"; import { buildAuthConfig, type AuthConfig } from "@/api/routes/auth"; +import pkg from "../../../package.json" with { type: "json" }; const app = new Hono(); @@ -18,6 +19,11 @@ const app = new Hono(); * Public configuration exposed to the UI */ export type PublicConfig = { + /** + * Deployed server version (apps/mesh/package.json). Compared against the + * client's build-time `__MESH_VERSION__` to detect a stale bundle. + */ + version: string; /** * Theme customization for light and dark modes. * Contains CSS variable overrides that will be injected into the document. @@ -94,6 +100,7 @@ function buildPosthogConfig(): PublicConfig["posthog"] { */ app.get("/", (c) => { const config: PublicConfig = { + version: pkg.version, theme: getThemeConfig(), ...(getConfig().logo && { logo: getConfig().logo }), // Only expose internalUrl in local mode — production uses the public URL directly @@ -111,6 +118,13 @@ app.get("/", (c) => { }, }; + // No explicit Cache-Control previously meant browser/intermediary caching + // was left to default heuristics. version-check-dialog.tsx polls this on + // a timer specifically to detect drift — a cached response would return + // the same stale value forever, defeating the poll and (via a hard + // refresh clearing that cache) making a stuck dialog look like it only + // "fixes itself" after Cmd+Shift+R. + c.header("Cache-Control", "no-store"); return c.json({ success: true, config }); }); diff --git a/apps/mesh/src/web/components/version-check-dialog.test.ts b/apps/mesh/src/web/components/version-check-dialog.test.ts new file mode 100644 index 0000000000..e15c123df2 --- /dev/null +++ b/apps/mesh/src/web/components/version-check-dialog.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { nextDrift } from "./version-check-dialog"; + +const NONE = { version: null, count: 0 }; + +describe("nextDrift", () => { + test("resets when server version matches client", () => { + expect(nextDrift({ version: "4.0.0", count: 1 }, "4.1.0", "4.1.0")).toEqual( + NONE, + ); + }); + + test("resets when server version is missing", () => { + expect( + nextDrift({ version: "4.0.0", count: 1 }, undefined, "4.0.0"), + ).toEqual(NONE); + }); + + test("first drift starts a count of 1", () => { + expect(nextDrift(NONE, "4.1.0", "4.0.0")).toEqual({ + version: "4.1.0", + count: 1, + }); + }); + + test("same drift version again increments the count", () => { + const first = nextDrift(NONE, "4.1.0", "4.0.0"); + expect(nextDrift(first, "4.1.0", "4.0.0")).toEqual({ + version: "4.1.0", + count: 2, + }); + }); + + test("a different drift version restarts the count at 1", () => { + const first = nextDrift(NONE, "4.1.0", "4.0.0"); + expect(nextDrift(first, "4.2.0", "4.0.0")).toEqual({ + version: "4.2.0", + count: 1, + }); + }); +}); diff --git a/apps/mesh/src/web/components/version-check-dialog.tsx b/apps/mesh/src/web/components/version-check-dialog.tsx new file mode 100644 index 0000000000..9bd81ba84c --- /dev/null +++ b/apps/mesh/src/web/components/version-check-dialog.tsx @@ -0,0 +1,95 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@deco/ui/components/alert-dialog.tsx"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import type { PublicConfig } from "@/api/routes/public-config"; +import { KEYS } from "@/web/lib/query-keys"; + +const POLL_INTERVAL_MS = 5 * 60 * 1000; + +// A rolling deploy always has old and new pods answering simultaneously for a +// stretch (inherent to maxSurge/maxUnavailable, not fully eliminable by the +// LB or nginx alone) — this app redeploys ~12x/day, so it's often mid-rollout. +// A single poll can catch that transient window and misreport drift; the +// user could then be stuck refreshing into a still-old pod until the rollout +// actually finishes, with no way to tell the difference. Requiring the SAME +// newer version on two consecutive polls (~5-10 min apart) means we only nag +// once the deploy has actually settled everywhere. +const CONFIRMATIONS_REQUIRED = 2; + +type Drift = { version: string | null; count: number }; + +export function nextDrift( + prev: Drift, + serverVersion: string | undefined, + clientVersion: string, +): Drift { + if (!serverVersion || serverVersion === clientVersion) { + return { version: null, count: 0 }; + } + return prev.version === serverVersion + ? { version: serverVersion, count: prev.count + 1 } + : { version: serverVersion, count: 1 }; +} + +/** + * Polls /api/config on its own (short-lived, unlike the Infinity-staleTime + * publicConfig query used for boot) and prompts a refresh once the deployed + * server version drifts — consistently, across repeated polls — from this + * bundle's build-time __MESH_VERSION__. + */ +export function VersionCheckDialog() { + const { data: serverVersion, dataUpdatedAt } = useQuery({ + queryKey: KEYS.appVersionCheck(), + queryFn: async () => { + // The server sends Cache-Control: no-store, but force it client-side + // too — this poll is worthless if any layer serves a cached response. + const response = await fetch("/api/config", { cache: "no-store" }); + const { config }: { config: PublicConfig } = await response.json(); + return config.version; + }, + refetchInterval: POLL_INTERVAL_MS, + refetchIntervalInBackground: true, + staleTime: 0, + }); + + // `data` alone can stay referentially/value-equal across polls (same + // version reported twice), which wouldn't re-render under tracked-property + // optimization — dataUpdatedAt changes on every settled fetch, so tracking + // it here is what lets us actually see each poll, not just each change. + const [lastCheckedAt, setLastCheckedAt] = useState(0); + const [drift, setDrift] = useState({ version: null, count: 0 }); + + if (dataUpdatedAt !== lastCheckedAt) { + setLastCheckedAt(dataUpdatedAt); + setDrift((prev) => nextDrift(prev, serverVersion, __MESH_VERSION__)); + } + + const isStale = drift.count >= CONFIRMATIONS_REQUIRED; + + return ( + + + + A new version is available + + You're viewing an outdated version of this page. Refresh to get the + latest updates. + + + + window.location.reload()}> + Refresh + + + + + ); +} diff --git a/apps/mesh/src/web/layouts/shell-layout.tsx b/apps/mesh/src/web/layouts/shell-layout.tsx index 9c779c9c84..cd8acfaed6 100644 --- a/apps/mesh/src/web/layouts/shell-layout.tsx +++ b/apps/mesh/src/web/layouts/shell-layout.tsx @@ -3,6 +3,7 @@ import { OrgAccessGate } from "@/web/components/org-access-gate"; import { SplashScreen } from "@/web/components/splash-screen"; import { SidebarReleaseCard } from "@/web/components/release-channel/sidebar-release-card"; import { KeyboardShortcutsDialog } from "@/web/components/keyboard-shortcuts-dialog"; +import { VersionCheckDialog } from "@/web/components/version-check-dialog"; import { isModKey } from "@/web/lib/keyboard-shortcuts"; import RequiredAuthLayout from "@/web/layouts/required-auth-layout"; import { authClient } from "@/web/lib/auth-client"; @@ -375,6 +376,8 @@ function ShellLayoutContent() { open={shortcutsDialogOpen} onOpenChange={setShortcutsDialogOpen} /> + + ); } diff --git a/apps/mesh/src/web/lib/query-keys.ts b/apps/mesh/src/web/lib/query-keys.ts index 0082a67fd8..52ea66a5c1 100644 --- a/apps/mesh/src/web/lib/query-keys.ts +++ b/apps/mesh/src/web/lib/query-keys.ts @@ -11,6 +11,10 @@ export const KEYS = { // Public config (no auth required) publicConfig: () => ["publicConfig"] as const, + // Polled separately from publicConfig (which is cached forever) to detect + // a deployed version newer than the client's own build. + appVersionCheck: () => ["appVersionCheck"] as const, + // Auth-related queries session: () => ["session"] as const,