Skip to content

Commit b21fe0b

Browse files
authored
Revert "Revert "feat(web): prompt refresh when the server version settles on …" (#4463)
This reverts commit ebcbb42.
1 parent 8887dcf commit b21fe0b

5 files changed

Lines changed: 157 additions & 0 deletions

File tree

apps/mesh/src/api/routes/public-config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,19 @@ import { isLocalMode } from "@/auth/local-mode";
1111
import { getInternalUrl } from "@/core/server-constants";
1212
import { getSettings } from "@/settings";
1313
import { buildAuthConfig, type AuthConfig } from "@/api/routes/auth";
14+
import pkg from "../../../package.json" with { type: "json" };
1415

1516
const app = new Hono();
1617

1718
/**
1819
* Public configuration exposed to the UI
1920
*/
2021
export type PublicConfig = {
22+
/**
23+
* Deployed server version (apps/mesh/package.json). Compared against the
24+
* client's build-time `__MESH_VERSION__` to detect a stale bundle.
25+
*/
26+
version: string;
2127
/**
2228
* Theme customization for light and dark modes.
2329
* Contains CSS variable overrides that will be injected into the document.
@@ -94,6 +100,7 @@ function buildPosthogConfig(): PublicConfig["posthog"] {
94100
*/
95101
app.get("/", (c) => {
96102
const config: PublicConfig = {
103+
version: pkg.version,
97104
theme: getThemeConfig(),
98105
...(getConfig().logo && { logo: getConfig().logo }),
99106
// Only expose internalUrl in local mode — production uses the public URL directly
@@ -111,6 +118,13 @@ app.get("/", (c) => {
111118
},
112119
};
113120

121+
// No explicit Cache-Control previously meant browser/intermediary caching
122+
// was left to default heuristics. version-check-dialog.tsx polls this on
123+
// a timer specifically to detect drift — a cached response would return
124+
// the same stale value forever, defeating the poll and (via a hard
125+
// refresh clearing that cache) making a stuck dialog look like it only
126+
// "fixes itself" after Cmd+Shift+R.
127+
c.header("Cache-Control", "no-store");
114128
return c.json({ success: true, config });
115129
});
116130

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { nextDrift } from "./version-check-dialog";
3+
4+
const NONE = { version: null, count: 0 };
5+
6+
describe("nextDrift", () => {
7+
test("resets when server version matches client", () => {
8+
expect(nextDrift({ version: "4.0.0", count: 1 }, "4.1.0", "4.1.0")).toEqual(
9+
NONE,
10+
);
11+
});
12+
13+
test("resets when server version is missing", () => {
14+
expect(
15+
nextDrift({ version: "4.0.0", count: 1 }, undefined, "4.0.0"),
16+
).toEqual(NONE);
17+
});
18+
19+
test("first drift starts a count of 1", () => {
20+
expect(nextDrift(NONE, "4.1.0", "4.0.0")).toEqual({
21+
version: "4.1.0",
22+
count: 1,
23+
});
24+
});
25+
26+
test("same drift version again increments the count", () => {
27+
const first = nextDrift(NONE, "4.1.0", "4.0.0");
28+
expect(nextDrift(first, "4.1.0", "4.0.0")).toEqual({
29+
version: "4.1.0",
30+
count: 2,
31+
});
32+
});
33+
34+
test("a different drift version restarts the count at 1", () => {
35+
const first = nextDrift(NONE, "4.1.0", "4.0.0");
36+
expect(nextDrift(first, "4.2.0", "4.0.0")).toEqual({
37+
version: "4.2.0",
38+
count: 1,
39+
});
40+
});
41+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import {
2+
AlertDialog,
3+
AlertDialogAction,
4+
AlertDialogContent,
5+
AlertDialogDescription,
6+
AlertDialogFooter,
7+
AlertDialogHeader,
8+
AlertDialogTitle,
9+
} from "@deco/ui/components/alert-dialog.tsx";
10+
import { useQuery } from "@tanstack/react-query";
11+
import { useState } from "react";
12+
import type { PublicConfig } from "@/api/routes/public-config";
13+
import { KEYS } from "@/web/lib/query-keys";
14+
15+
const POLL_INTERVAL_MS = 5 * 60 * 1000;
16+
17+
// A rolling deploy always has old and new pods answering simultaneously for a
18+
// stretch (inherent to maxSurge/maxUnavailable, not fully eliminable by the
19+
// LB or nginx alone) — this app redeploys ~12x/day, so it's often mid-rollout.
20+
// A single poll can catch that transient window and misreport drift; the
21+
// user could then be stuck refreshing into a still-old pod until the rollout
22+
// actually finishes, with no way to tell the difference. Requiring the SAME
23+
// newer version on two consecutive polls (~5-10 min apart) means we only nag
24+
// once the deploy has actually settled everywhere.
25+
const CONFIRMATIONS_REQUIRED = 2;
26+
27+
type Drift = { version: string | null; count: number };
28+
29+
export function nextDrift(
30+
prev: Drift,
31+
serverVersion: string | undefined,
32+
clientVersion: string,
33+
): Drift {
34+
if (!serverVersion || serverVersion === clientVersion) {
35+
return { version: null, count: 0 };
36+
}
37+
return prev.version === serverVersion
38+
? { version: serverVersion, count: prev.count + 1 }
39+
: { version: serverVersion, count: 1 };
40+
}
41+
42+
/**
43+
* Polls /api/config on its own (short-lived, unlike the Infinity-staleTime
44+
* publicConfig query used for boot) and prompts a refresh once the deployed
45+
* server version drifts — consistently, across repeated polls — from this
46+
* bundle's build-time __MESH_VERSION__.
47+
*/
48+
export function VersionCheckDialog() {
49+
const { data: serverVersion, dataUpdatedAt } = useQuery({
50+
queryKey: KEYS.appVersionCheck(),
51+
queryFn: async () => {
52+
// The server sends Cache-Control: no-store, but force it client-side
53+
// too — this poll is worthless if any layer serves a cached response.
54+
const response = await fetch("/api/config", { cache: "no-store" });
55+
const { config }: { config: PublicConfig } = await response.json();
56+
return config.version;
57+
},
58+
refetchInterval: POLL_INTERVAL_MS,
59+
refetchIntervalInBackground: true,
60+
staleTime: 0,
61+
});
62+
63+
// `data` alone can stay referentially/value-equal across polls (same
64+
// version reported twice), which wouldn't re-render under tracked-property
65+
// optimization — dataUpdatedAt changes on every settled fetch, so tracking
66+
// it here is what lets us actually see each poll, not just each change.
67+
const [lastCheckedAt, setLastCheckedAt] = useState(0);
68+
const [drift, setDrift] = useState<Drift>({ version: null, count: 0 });
69+
70+
if (dataUpdatedAt !== lastCheckedAt) {
71+
setLastCheckedAt(dataUpdatedAt);
72+
setDrift((prev) => nextDrift(prev, serverVersion, __MESH_VERSION__));
73+
}
74+
75+
const isStale = drift.count >= CONFIRMATIONS_REQUIRED;
76+
77+
return (
78+
<AlertDialog open={isStale}>
79+
<AlertDialogContent>
80+
<AlertDialogHeader>
81+
<AlertDialogTitle>A new version is available</AlertDialogTitle>
82+
<AlertDialogDescription>
83+
You're viewing an outdated version of this page. Refresh to get the
84+
latest updates.
85+
</AlertDialogDescription>
86+
</AlertDialogHeader>
87+
<AlertDialogFooter>
88+
<AlertDialogAction onClick={() => window.location.reload()}>
89+
Refresh
90+
</AlertDialogAction>
91+
</AlertDialogFooter>
92+
</AlertDialogContent>
93+
</AlertDialog>
94+
);
95+
}

apps/mesh/src/web/layouts/shell-layout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { OrgAccessGate } from "@/web/components/org-access-gate";
33
import { SplashScreen } from "@/web/components/splash-screen";
44
import { SidebarReleaseCard } from "@/web/components/release-channel/sidebar-release-card";
55
import { KeyboardShortcutsDialog } from "@/web/components/keyboard-shortcuts-dialog";
6+
import { VersionCheckDialog } from "@/web/components/version-check-dialog";
67
import { isModKey } from "@/web/lib/keyboard-shortcuts";
78
import RequiredAuthLayout from "@/web/layouts/required-auth-layout";
89
import { authClient } from "@/web/lib/auth-client";
@@ -375,6 +376,8 @@ function ShellLayoutContent() {
375376
open={shortcutsDialogOpen}
376377
onOpenChange={setShortcutsDialogOpen}
377378
/>
379+
380+
<VersionCheckDialog />
378381
</ShellProjectProvider>
379382
);
380383
}

apps/mesh/src/web/lib/query-keys.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export const KEYS = {
1111
// Public config (no auth required)
1212
publicConfig: () => ["publicConfig"] as const,
1313

14+
// Polled separately from publicConfig (which is cached forever) to detect
15+
// a deployed version newer than the client's own build.
16+
appVersionCheck: () => ["appVersionCheck"] as const,
17+
1418
// Auth-related queries
1519
session: () => ["session"] as const,
1620

0 commit comments

Comments
 (0)