From bd15f3146b03b5df4e371d45507b05c8a04cc65f Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 26 Oct 2026 12:20:23 +0200 Subject: [PATCH] feat: load a new build by itself after a deploy The service worker served the cached page first, so a driver ran the old build until the app was opened twice (Joachim on 2026-10-26 kept the old one after the invoice-match deploy). Each build now writes its id to /version.json. On launch a newer live build clears the cached pages and reloads once; while the app stays open an Update banner appears instead, so a reload never cuts off a photo or an upload. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 +- app/layout.tsx | 2 + components/UpdateWatcher.tsx | 109 +++++++++++++++++++++++++++++++++++ next.config.js | 16 ++++- public/sw.js | 3 + 5 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 components/UpdateWatcher.tsx diff --git a/.gitignore b/.gitignore index 6b083ca..b4cf553 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ node_modules/ .next/ dist/ out/ -.env \ No newline at end of file +.env +# Written by next.config.js on every build +public/version.json \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index a380651..e0e9cc1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ import { AuthProvider } from "@/components/AuthProvider"; import OfflineIndicator from "@/components/OfflineIndicator"; import InstallPrompt from "@/components/InstallPrompt"; import { PWABridge } from "@/components/PWABridge"; +import UpdateWatcher from "@/components/UpdateWatcher"; import LocationTracker from "@/components/LocationTracker"; import { applyDarkMode } from "@/lib/theme"; @@ -52,6 +53,7 @@ export default function RootLayout({ + {/* diff --git a/components/UpdateWatcher.tsx b/components/UpdateWatcher.tsx new file mode 100644 index 0000000..388939b --- /dev/null +++ b/components/UpdateWatcher.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { RefreshCw } from 'lucide-react'; + +const BUILD_ID = process.env.NEXT_PUBLIC_BUILD_ID ?? ''; +const RELOADED_FOR = '1driver_reloaded_for'; +const CHECK_EVERY_MS = 10 * 60 * 1000; + +/** The build the server is serving now, or '' when unknown (offline, dev). */ +async function liveBuildId(): Promise { + try { + const res = await fetch(`/version.json?t=${Date.now()}`, { cache: 'no-store' }); + if (!res.ok) return ''; + const body = (await res.json()) as { id?: unknown }; + return typeof body.id === 'string' ? body.id : ''; + } catch { + return ''; + } +} + +/** + * Drop the cached pages (not the hashed /_next/static files) so the service + * worker fetches the new HTML, then reload. Without this the first reload + * after a deploy still shows the old cached page. + */ +async function loadNewVersion(): Promise { + try { + for (const name of await caches.keys()) { + const cache = await caches.open(name); + for (const req of await cache.keys()) { + const url = new URL(req.url); + if (url.origin === location.origin && !url.pathname.startsWith('/_next/static/')) { + await cache.delete(req); + } + } + } + const reg = await navigator.serviceWorker?.getRegistration(); + await reg?.update(); + } catch { + // Reload anyway; the worst case is one more stale open. + } + location.reload(); +} + +// Keeps drivers on the latest build. On launch a new build loads straight +// away (once per build, so a stale server can't cause a reload loop). While +// the app stays open it only shows an Update banner: a reload then could cut +// off a camera photo or an upload in progress. +export default function UpdateWatcher() { + const [ready, setReady] = useState(false); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (process.env.NODE_ENV !== 'production' || !BUILD_ID) return; + let stopped = false; + + const check = async (onLaunch: boolean) => { + const live = await liveBuildId(); + if (stopped || !live || live === BUILD_ID) return; + let alreadyTried = false; + try { + alreadyTried = sessionStorage.getItem(RELOADED_FOR) === live; + if (onLaunch && !alreadyTried) sessionStorage.setItem(RELOADED_FOR, live); + } catch { + alreadyTried = true; // no storage: never auto-reload, the banner is enough + } + if (onLaunch && !alreadyTried) void loadNewVersion(); + else setReady(true); + }; + + void check(true); + const onVisible = () => { + if (document.visibilityState === 'visible') void check(false); + }; + document.addEventListener('visibilitychange', onVisible); + const timer = setInterval(() => void check(false), CHECK_EVERY_MS); + return () => { + stopped = true; + document.removeEventListener('visibilitychange', onVisible); + clearInterval(timer); + }; + }, []); + + if (!ready) return null; + + return ( +
+
+

A new version of the app is ready.

+ +
+
+ ); +} diff --git a/next.config.js b/next.config.js index 6bf7eca..fb77dc6 100644 --- a/next.config.js +++ b/next.config.js @@ -1,3 +1,17 @@ +const fs = require('fs'); +const path = require('path'); + +// One id per build, shared with the build workers through process.env (they +// inherit it), and written to public/version.json so a running app can ask +// the server which build is live (components/UpdateWatcher). +if (!process.env.NEXT_PUBLIC_BUILD_ID) { + process.env.NEXT_PUBLIC_BUILD_ID = Date.now().toString(36); + fs.writeFileSync( + path.join(__dirname, 'public', 'version.json'), + JSON.stringify({ id: process.env.NEXT_PUBLIC_BUILD_ID }) + '\n', + ); +} + /** @type {import('next').NextConfig} */ const nextConfig = { // Only use static export for production builds @@ -8,4 +22,4 @@ const nextConfig = { trailingSlash: true, }; -module.exports = nextConfig; \ No newline at end of file +module.exports = nextConfig; diff --git a/public/sw.js b/public/sw.js index d2e12f9..4a0354b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -53,6 +53,9 @@ self.addEventListener('fetch', (event) => { // Skip cross-origin except PocketBase API if (url.origin !== self.location.origin && !url.href.startsWith(POCKETBASE_URL)) return; + // Which build is live: always the network, never cached (UpdateWatcher) + if (url.pathname === '/version.json') return; + // PocketBase API - Network First if (url.href.startsWith(POCKETBASE_URL)) { event.respondWith(networkFirst(event.request, API_CACHE));