Skip to content

Commit 840404b

Browse files
authored
Merge pull request #427 from pinto-org/codex/app-version-refresh
[codex] Add app version refresh guard
2 parents 599ff2d + e35b471 commit 840404b

5 files changed

Lines changed: 194 additions & 1 deletion

File tree

public/_headers

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/version.json
2+
Cache-Control: no-store, max-age=0, must-revalidate

src/Providers.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Web3Provider } from "./Web3Provider";
22
import StateProvider from "./state/StateProvider";
33

44
import { Toaster } from "sonner";
5+
import AppVersionGuard from "./components/AppVersionGuard.tsx";
56
import { CheckmarkIcon, CloseIconAlt } from "./components/Icons.tsx";
67
import LoadingSpinner from "./components/LoadingSpinner.tsx";
78

@@ -10,6 +11,7 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
1011
<Web3Provider>
1112
<StateProvider>
1213
{children}
14+
<AppVersionGuard />
1315
<div className="sm:[&_[data-sonner-toaster]]:w-full max-sm:[&_[data-sonner-toaster]]:!w-full max-sm:[&_[data-sonner-toast]]:!w-fit">
1416
<Toaster
1517
toastOptions={{

src/components/AppVersionGuard.tsx

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { useEffect, useRef } from "react";
2+
import { toast } from "sonner";
3+
4+
const VERSION_URL = "/version.json";
5+
const INITIAL_CHECK_DELAY_MS = 30_000;
6+
const CHECK_INTERVAL_MS = 5 * 60_000;
7+
const FOCUS_CHECK_THROTTLE_MS = 60_000;
8+
9+
type AppVersion = typeof __PINTO_APP_VERSION__;
10+
11+
const fetchLatestVersion = async (): Promise<Partial<AppVersion> | undefined> => {
12+
const response = await fetch(`${VERSION_URL}?t=${Date.now()}`, {
13+
cache: "no-store",
14+
headers: {
15+
Accept: "application/json",
16+
},
17+
});
18+
19+
if (!response.ok) {
20+
return undefined;
21+
}
22+
23+
return response.json();
24+
};
25+
26+
const reloadApp = () => {
27+
window.location.reload();
28+
};
29+
30+
export default function AppVersionGuard() {
31+
const isCheckingRef = useRef(false);
32+
const isStaleRef = useRef(false);
33+
const hasNotifiedRef = useRef(false);
34+
const lastCheckAtRef = useRef(0);
35+
36+
useEffect(() => {
37+
if (!import.meta.env.PROD) {
38+
return;
39+
}
40+
41+
let isDisposed = false;
42+
43+
const handleStaleVersion = () => {
44+
if (isDisposed || isStaleRef.current) {
45+
return;
46+
}
47+
48+
isStaleRef.current = true;
49+
50+
if (document.visibilityState === "hidden") {
51+
reloadApp();
52+
return;
53+
}
54+
55+
if (hasNotifiedRef.current) {
56+
return;
57+
}
58+
59+
hasNotifiedRef.current = true;
60+
toast.warning("A new Pinto version is available.", {
61+
duration: Infinity,
62+
action: {
63+
label: "Reload",
64+
onClick: reloadApp,
65+
},
66+
});
67+
};
68+
69+
const checkForLatestVersion = async (force = false) => {
70+
if (isDisposed || isStaleRef.current || isCheckingRef.current) {
71+
return;
72+
}
73+
74+
const now = Date.now();
75+
if (!force && now - lastCheckAtRef.current < FOCUS_CHECK_THROTTLE_MS) {
76+
return;
77+
}
78+
79+
lastCheckAtRef.current = now;
80+
isCheckingRef.current = true;
81+
82+
try {
83+
const latestVersion = await fetchLatestVersion();
84+
if (latestVersion?.buildId && latestVersion.buildId !== __PINTO_APP_VERSION__.buildId) {
85+
handleStaleVersion();
86+
}
87+
} catch {
88+
// Version checks are best-effort; temporary network failures should not affect app usage.
89+
} finally {
90+
isCheckingRef.current = false;
91+
}
92+
};
93+
94+
const handleFocus = () => {
95+
void checkForLatestVersion();
96+
};
97+
98+
const handleVisibilityChange = () => {
99+
if (document.visibilityState === "hidden" && isStaleRef.current) {
100+
reloadApp();
101+
return;
102+
}
103+
104+
if (document.visibilityState === "visible") {
105+
void checkForLatestVersion(true);
106+
}
107+
};
108+
109+
const handlePageShow = (event: PageTransitionEvent) => {
110+
if (event.persisted) {
111+
void checkForLatestVersion(true);
112+
}
113+
};
114+
115+
const initialCheckId = window.setTimeout(() => void checkForLatestVersion(true), INITIAL_CHECK_DELAY_MS);
116+
const intervalId = window.setInterval(() => void checkForLatestVersion(), CHECK_INTERVAL_MS);
117+
118+
window.addEventListener("focus", handleFocus);
119+
window.addEventListener("pageshow", handlePageShow);
120+
document.addEventListener("visibilitychange", handleVisibilityChange);
121+
122+
return () => {
123+
isDisposed = true;
124+
window.clearTimeout(initialCheckId);
125+
window.clearInterval(intervalId);
126+
window.removeEventListener("focus", handleFocus);
127+
window.removeEventListener("pageshow", handlePageShow);
128+
document.removeEventListener("visibilitychange", handleVisibilityChange);
129+
};
130+
}, []);
131+
132+
return null;
133+
}

src/vite-env.d.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,14 @@ interface ImportMetaEnv {
3737

3838
declare module "*.md";
3939

40-
// biome-ignore lint/correctness/noUnusedVariables:
4140
interface ImportMeta {
4241
readonly env: ImportMetaEnv;
4342
}
43+
44+
declare const __PINTO_APP_VERSION__: {
45+
buildId: string;
46+
commit: string;
47+
branch: string;
48+
context: string;
49+
builtAt: string;
50+
};

vite.config.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,65 @@
11
import react from "@vitejs/plugin-react";
2+
import { execSync } from "node:child_process";
23
import path from "path";
34
import { defineConfig } from "vite";
45
import strip from '@rollup/plugin-strip';
56
import { configDefaults } from 'vitest/config';
67

8+
type AppVersion = {
9+
buildId: string;
10+
commit: string;
11+
branch: string;
12+
context: string;
13+
builtAt: string;
14+
};
15+
16+
const getGitCommit = () => {
17+
try {
18+
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
19+
} catch {
20+
return "unknown";
21+
}
22+
};
23+
24+
const getAppVersion = (): AppVersion => {
25+
const builtAt = new Date().toISOString();
26+
const commit =
27+
process.env.COMMIT_REF ||
28+
process.env.VERCEL_GIT_COMMIT_SHA ||
29+
process.env.CF_PAGES_COMMIT_SHA ||
30+
process.env.GITHUB_SHA ||
31+
getGitCommit();
32+
33+
return {
34+
buildId: process.env.VITE_APP_BUILD_ID || process.env.DEPLOY_ID || `${commit}-${builtAt}`,
35+
commit,
36+
branch: process.env.BRANCH || process.env.VERCEL_GIT_COMMIT_REF || process.env.GITHUB_REF_NAME || "",
37+
context: process.env.CONTEXT || process.env.VITE_NETLIFY_CONTEXT || "",
38+
builtAt,
39+
};
40+
};
41+
742
// https://vitejs.dev/config/
843
export default defineConfig(({ command }) => {
944
const isProduction = process.env.VITE_NETLIFY_CONTEXT === 'production';
45+
const appVersion = getAppVersion();
1046

1147
return {
48+
define: {
49+
__PINTO_APP_VERSION__: JSON.stringify(appVersion),
50+
},
1251
plugins: [
1352
react(),
53+
{
54+
name: "app-version",
55+
generateBundle() {
56+
this.emitFile({
57+
type: "asset",
58+
fileName: "version.json",
59+
source: `${JSON.stringify(appVersion)}\n`,
60+
});
61+
},
62+
},
1463
{
1564
name: "markdown-loader",
1665
transform(code, id) {

0 commit comments

Comments
 (0)