|
| 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 | +} |
0 commit comments