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 <noreply@anthropic.com>
This commit is contained in:
parent
c2faca98bd
commit
bd15f3146b
5 changed files with 132 additions and 2 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -2,4 +2,6 @@ node_modules/
|
||||||
.next/
|
.next/
|
||||||
dist/
|
dist/
|
||||||
out/
|
out/
|
||||||
.env
|
.env
|
||||||
|
# Written by next.config.js on every build
|
||||||
|
public/version.json
|
||||||
|
|
@ -5,6 +5,7 @@ import { AuthProvider } from "@/components/AuthProvider";
|
||||||
import OfflineIndicator from "@/components/OfflineIndicator";
|
import OfflineIndicator from "@/components/OfflineIndicator";
|
||||||
import InstallPrompt from "@/components/InstallPrompt";
|
import InstallPrompt from "@/components/InstallPrompt";
|
||||||
import { PWABridge } from "@/components/PWABridge";
|
import { PWABridge } from "@/components/PWABridge";
|
||||||
|
import UpdateWatcher from "@/components/UpdateWatcher";
|
||||||
import LocationTracker from "@/components/LocationTracker";
|
import LocationTracker from "@/components/LocationTracker";
|
||||||
import { applyDarkMode } from "@/lib/theme";
|
import { applyDarkMode } from "@/lib/theme";
|
||||||
|
|
||||||
|
|
@ -52,6 +53,7 @@ export default function RootLayout({
|
||||||
</a>
|
</a>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<PWABridge />
|
<PWABridge />
|
||||||
|
<UpdateWatcher />
|
||||||
<OfflineIndicator />
|
<OfflineIndicator />
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
{/*
|
{/*
|
||||||
|
|
|
||||||
109
components/UpdateWatcher.tsx
Normal file
109
components/UpdateWatcher.tsx
Normal file
|
|
@ -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<string> {
|
||||||
|
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<void> {
|
||||||
|
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 (
|
||||||
|
<div className="fixed bottom-[76px] left-3 right-3 z-[85] safe-bottom" role="status" aria-live="polite">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 rounded-2xl bg-[#26251e] text-white px-4 py-3"
|
||||||
|
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.25)' }}
|
||||||
|
>
|
||||||
|
<p className="flex-1 text-[14px] leading-snug">A new version of the app is ready.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setBusy(true);
|
||||||
|
void loadNewVersion();
|
||||||
|
}}
|
||||||
|
className="h-10 px-4 rounded-xl bg-[#f54e00] text-[14px] font-semibold flex items-center gap-1.5 disabled:opacity-60 touch-manipulation"
|
||||||
|
>
|
||||||
|
<RefreshCw size={15} className={busy ? 'animate-spin' : ''} aria-hidden="true" />
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
// Only use static export for production builds
|
// Only use static export for production builds
|
||||||
|
|
@ -8,4 +22,4 @@ const nextConfig = {
|
||||||
trailingSlash: true,
|
trailingSlash: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ self.addEventListener('fetch', (event) => {
|
||||||
// Skip cross-origin except PocketBase API
|
// Skip cross-origin except PocketBase API
|
||||||
if (url.origin !== self.location.origin && !url.href.startsWith(POCKETBASE_URL)) return;
|
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
|
// PocketBase API - Network First
|
||||||
if (url.href.startsWith(POCKETBASE_URL)) {
|
if (url.href.startsWith(POCKETBASE_URL)) {
|
||||||
event.respondWith(networkFirst(event.request, API_CACHE));
|
event.respondWith(networkFirst(event.request, API_CACHE));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue