Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/version.json
Cache-Control: no-store, max-age=0, must-revalidate
2 changes: 2 additions & 0 deletions src/Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -10,6 +11,7 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
<Web3Provider>
<StateProvider>
{children}
<AppVersionGuard />
<div className="sm:[&_[data-sonner-toaster]]:w-full max-sm:[&_[data-sonner-toaster]]:!w-full max-sm:[&_[data-sonner-toast]]:!w-fit">
<Toaster
toastOptions={{
Expand Down
133 changes: 133 additions & 0 deletions src/components/AppVersionGuard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { useEffect, useRef } from "react";
import { toast } from "sonner";

const VERSION_URL = "/version.json";
const INITIAL_CHECK_DELAY_MS = 30_000;
const CHECK_INTERVAL_MS = 5 * 60_000;
const FOCUS_CHECK_THROTTLE_MS = 60_000;

type AppVersion = typeof __PINTO_APP_VERSION__;

const fetchLatestVersion = async (): Promise<Partial<AppVersion> | 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;
}
9 changes: 8 additions & 1 deletion src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
49 changes: 49 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
Loading