diff --git a/public/_headers b/public/_headers new file mode 100644 index 000000000..50922fca6 --- /dev/null +++ b/public/_headers @@ -0,0 +1,2 @@ +/version.json + Cache-Control: no-store, max-age=0, must-revalidate diff --git a/src/Providers.tsx b/src/Providers.tsx index 183f639b2..178258c6b 100644 --- a/src/Providers.tsx +++ b/src/Providers.tsx @@ -2,6 +2,7 @@ import { Web3Provider } from "./Web3Provider"; import StateProvider from "./state/StateProvider"; import { Toaster } from "sonner"; +import AppVersionGuard from "./components/AppVersionGuard.tsx"; import { CheckmarkIcon, CloseIconAlt } from "./components/Icons.tsx"; import LoadingSpinner from "./components/LoadingSpinner.tsx"; @@ -10,6 +11,7 @@ const Providers = ({ children }: { children: React.ReactNode }) => { {children} +
| undefined> => { + const response = await fetch(`${VERSION_URL}?t=${Date.now()}`, { + cache: "no-store", + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + return undefined; + } + + return response.json(); +}; + +const reloadApp = () => { + window.location.reload(); +}; + +export default function AppVersionGuard() { + const isCheckingRef = useRef(false); + const isStaleRef = useRef(false); + const hasNotifiedRef = useRef(false); + const lastCheckAtRef = useRef(0); + + useEffect(() => { + if (!import.meta.env.PROD) { + return; + } + + let isDisposed = false; + + const handleStaleVersion = () => { + if (isDisposed || isStaleRef.current) { + return; + } + + isStaleRef.current = true; + + if (document.visibilityState === "hidden") { + reloadApp(); + return; + } + + if (hasNotifiedRef.current) { + return; + } + + hasNotifiedRef.current = true; + toast.warning("A new Pinto version is available.", { + duration: Infinity, + action: { + label: "Reload", + onClick: reloadApp, + }, + }); + }; + + const checkForLatestVersion = async (force = false) => { + if (isDisposed || isStaleRef.current || isCheckingRef.current) { + return; + } + + const now = Date.now(); + if (!force && now - lastCheckAtRef.current < FOCUS_CHECK_THROTTLE_MS) { + return; + } + + lastCheckAtRef.current = now; + isCheckingRef.current = true; + + try { + const latestVersion = await fetchLatestVersion(); + if (latestVersion?.buildId && latestVersion.buildId !== __PINTO_APP_VERSION__.buildId) { + handleStaleVersion(); + } + } catch { + // Version checks are best-effort; temporary network failures should not affect app usage. + } finally { + isCheckingRef.current = false; + } + }; + + const handleFocus = () => { + void checkForLatestVersion(); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden" && isStaleRef.current) { + reloadApp(); + return; + } + + if (document.visibilityState === "visible") { + void checkForLatestVersion(true); + } + }; + + const handlePageShow = (event: PageTransitionEvent) => { + if (event.persisted) { + void checkForLatestVersion(true); + } + }; + + const initialCheckId = window.setTimeout(() => void checkForLatestVersion(true), INITIAL_CHECK_DELAY_MS); + const intervalId = window.setInterval(() => void checkForLatestVersion(), CHECK_INTERVAL_MS); + + window.addEventListener("focus", handleFocus); + window.addEventListener("pageshow", handlePageShow); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + isDisposed = true; + window.clearTimeout(initialCheckId); + window.clearInterval(intervalId); + window.removeEventListener("focus", handleFocus); + window.removeEventListener("pageshow", handlePageShow); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, []); + + return null; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 53d06ba46..3079ffdbe 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -37,7 +37,14 @@ interface ImportMetaEnv { declare module "*.md"; -// biome-ignore lint/correctness/noUnusedVariables: interface ImportMeta { readonly env: ImportMetaEnv; } + +declare const __PINTO_APP_VERSION__: { + buildId: string; + commit: string; + branch: string; + context: string; + builtAt: string; +}; diff --git a/vite.config.ts b/vite.config.ts index d51a23c3f..8ad17b68b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,65 @@ import react from "@vitejs/plugin-react"; +import { execSync } from "node:child_process"; import path from "path"; import { defineConfig } from "vite"; import strip from '@rollup/plugin-strip'; import { configDefaults } from 'vitest/config'; +type AppVersion = { + buildId: string; + commit: string; + branch: string; + context: string; + builtAt: string; +}; + +const getGitCommit = () => { + try { + return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + } catch { + return "unknown"; + } +}; + +const getAppVersion = (): AppVersion => { + const builtAt = new Date().toISOString(); + const commit = + process.env.COMMIT_REF || + process.env.VERCEL_GIT_COMMIT_SHA || + process.env.CF_PAGES_COMMIT_SHA || + process.env.GITHUB_SHA || + getGitCommit(); + + return { + buildId: process.env.VITE_APP_BUILD_ID || process.env.DEPLOY_ID || `${commit}-${builtAt}`, + commit, + branch: process.env.BRANCH || process.env.VERCEL_GIT_COMMIT_REF || process.env.GITHUB_REF_NAME || "", + context: process.env.CONTEXT || process.env.VITE_NETLIFY_CONTEXT || "", + builtAt, + }; +}; + // https://vitejs.dev/config/ export default defineConfig(({ command }) => { const isProduction = process.env.VITE_NETLIFY_CONTEXT === 'production'; + const appVersion = getAppVersion(); return { + define: { + __PINTO_APP_VERSION__: JSON.stringify(appVersion), + }, plugins: [ react(), + { + name: "app-version", + generateBundle() { + this.emitFile({ + type: "asset", + fileName: "version.json", + source: `${JSON.stringify(appVersion)}\n`, + }); + }, + }, { name: "markdown-loader", transform(code, id) {