From 0626bb6326ce5bf888a09169fb2a7424e5e886c9 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:14:37 +0200 Subject: [PATCH] feat(app): prompt to reload when a newer deploy is live (#951) Long-open tabs keep running the JS bundle they first loaded, so a shipped change can look "missing" (e.g. a new settings field appearing only after a full reload) until the whole app is reloaded. Add a small, unobtrusive prompt that detects a newer deploy and offers a one-click reload. - next.config: inline the deploy's commit SHA into the client bundle as NEXT_PUBLIC_BUILD_ID (empty in dev / self-hosted, which disables the check). - /api/version: public, no-store route returning the running deployment's SHA at request time. - DeployReloadPrompt: compares the two on load, on tab focus, and on a 30-min backstop; shows a bottom banner with "Ladda om" on mismatch. Mounted once in the root layout. No service worker, degrades to a no-op with no build id. Co-authored-by: Claude Opus 4.8 (1M context) --- app/api/version/route.ts | 20 ++++++++ app/layout.tsx | 2 + components/system/DeployReloadPrompt.tsx | 65 ++++++++++++++++++++++++ messages/en.json | 2 + messages/sv.json | 2 + next.config.ts | 7 +++ 6 files changed, 98 insertions(+) create mode 100644 app/api/version/route.ts create mode 100644 components/system/DeployReloadPrompt.tsx diff --git a/app/api/version/route.ts b/app/api/version/route.ts new file mode 100644 index 00000000..1fbc8461 --- /dev/null +++ b/app/api/version/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server' + +/** + * Public, unauthenticated build-version probe. + * + * The client compares the id it was built with (NEXT_PUBLIC_BUILD_ID, inlined + * into its JS bundle at build time) against this value, which is read at + * request time from the currently running deployment. A mismatch means a newer + * deploy is live and the open tab is running a stale bundle, so the client + * offers a reload (see components/system/DeployReloadPrompt). + * + * force-dynamic + no-store so it always reflects the live deployment rather + * than a value baked in at build. + */ +export const dynamic = 'force-dynamic' + +export function GET() { + const id = process.env.VERCEL_GIT_COMMIT_SHA ?? '' + return NextResponse.json({ id }, { headers: { 'Cache-Control': 'no-store' } }) +} diff --git a/app/layout.tsx b/app/layout.tsx index 203301b9..831eceb9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ import Script from "next/script"; import { NextIntlClientProvider } from "next-intl"; import { getLocale, getMessages } from "next-intl/server"; import { Toaster } from "@/components/ui/toaster"; +import { DeployReloadPrompt } from "@/components/system/DeployReloadPrompt"; import { ThemeProvider } from "@/components/theme-provider"; import { RecaptLoader } from "@/components/RecaptLoader"; import { RecaptHideWidget } from "@/components/RecaptHideWidget"; @@ -86,6 +87,7 @@ export default async function RootLayout({ > {children} + diff --git a/components/system/DeployReloadPrompt.tsx b/components/system/DeployReloadPrompt.tsx new file mode 100644 index 00000000..28cde865 --- /dev/null +++ b/components/system/DeployReloadPrompt.tsx @@ -0,0 +1,65 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Button } from '@/components/ui/button' + +// Inlined at build time from next.config's `env` (the deploy's commit SHA on +// Vercel; empty in dev / self-hosted, which turns the check off). +const BUILD_ID = process.env.NEXT_PUBLIC_BUILD_ID || '' + +/** + * Detects when a newer deploy is live while this tab is still running an old JS + * bundle, and offers a one-click reload. This is why a just-shipped change can + * appear "missing" in a long-open tab until the whole app is reloaded. + * + * Compares the build id baked into this bundle against /api/version (the + * running deployment's id), re-checking when the tab regains focus plus a slow + * interval backstop. No-op when no build id is set. + */ +export function DeployReloadPrompt() { + const t = useTranslations('common') + const [stale, setStale] = useState(false) + + useEffect(() => { + if (!BUILD_ID || stale) return + + let cancelled = false + async function check() { + try { + const res = await fetch('/api/version', { cache: 'no-store' }) + if (!res.ok) return + const { id } = await res.json() + if (!cancelled && id && id !== BUILD_ID) setStale(true) + } catch { + // Transient network error: ignore, the next trigger retries. + } + } + + function onVisible() { + if (document.visibilityState === 'visible') check() + } + + check() + document.addEventListener('visibilitychange', onVisible) + const interval = setInterval(check, 30 * 60 * 1000) // 30 min backstop + return () => { + cancelled = true + document.removeEventListener('visibilitychange', onVisible) + clearInterval(interval) + } + }, [stale]) + + if (!stale) return null + + return ( +
+
+ {t('update_available')} + +
+
+ ) +} diff --git a/messages/en.json b/messages/en.json index 06106062..dbc07fa8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,5 +1,7 @@ { "common": { + "update_available": "A new version is available.", + "reload": "Reload", "save": "Save", "saving": "Saving...", "cancel": "Cancel", diff --git a/messages/sv.json b/messages/sv.json index 8c480844..5abf1f1c 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1,5 +1,7 @@ { "common": { + "update_available": "En ny version finns tillgänglig.", + "reload": "Ladda om", "save": "Spara", "saving": "Sparar...", "cancel": "Avbryt", diff --git a/next.config.ts b/next.config.ts index 033a1be1..4723feaa 100644 --- a/next.config.ts +++ b/next.config.ts @@ -52,6 +52,13 @@ const cspDirectives = [ const nextConfig: NextConfig = { output: 'standalone', + // Build id inlined into the client bundle so a running tab can tell when a + // newer deploy is live (see components/system/DeployReloadPrompt). On Vercel + // this is the commit SHA; empty elsewhere (dev / self-hosted), which disables + // the check. The /api/version route reads the same var at runtime to compare. + env: { + NEXT_PUBLIC_BUILD_ID: process.env.VERCEL_GIT_COMMIT_SHA ?? '', + }, // Multiple lockfiles exist above this project (e.g. a parent yarn.lock), // which makes Turbopack infer the wrong workspace root. Pin it explicitly. turbopack: {