Add automatic cache clearing and version management to prevent white screen issues
Some checks failed
Build and Push Docker Images / docker (push) Failing after 44s

Implements comprehensive service worker solution with:
- Dynamic versioning using git commit hash or timestamp
- Automatic cache invalidation on new deployments
- Hourly update checks and user notifications
- Network-first caching strategy with 24-hour expiration
- Build automation via prebuild script
- Update notification UI component

This prevents stale cached code from causing white screens by ensuring users always get the latest version after deployment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Matt 2025-11-04 15:32:50 +01:00
parent c4059d5988
commit a279bb6aa9
11 changed files with 571 additions and 43 deletions

View File

@ -1,17 +1,39 @@
'use client';
import { usePathname } from 'next/navigation';
import { useState, useEffect } from 'react';
import { Header } from './Header';
import { Footer } from './Footer';
import { UpdateNotification } from './UpdateNotification';
import * as swRegistration from '../lib/serviceWorkerRegistration';
export function RootLayoutClient({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isAdminRoute = pathname?.startsWith('/admin');
const isCheckoutSuccess = pathname?.startsWith('/checkout/success');
const [updateAvailable, setUpdateAvailable] = useState<ServiceWorkerRegistration | null>(null);
useEffect(() => {
// Register service worker with update detection
swRegistration.register({
onUpdate: (registration) => {
console.log('New version available!');
setUpdateAvailable(registration);
},
onSuccess: (registration) => {
console.log('Service worker registered successfully');
}
});
}, []);
if (isAdminRoute || isCheckoutSuccess) {
// Admin routes and checkout success render without header/footer
return <>{children}</>;
return (
<>
{children}
<UpdateNotification registration={updateAvailable} />
</>
);
}
// Regular routes render with header/footer
@ -22,6 +44,7 @@ export function RootLayoutClient({ children }: { children: React.ReactNode }) {
{children}
</main>
<Footer />
<UpdateNotification registration={updateAvailable} />
</>
);
}

View File

@ -0,0 +1,72 @@
'use client';
import { useState, useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
interface UpdateNotificationProps {
registration: ServiceWorkerRegistration | null;
}
export function UpdateNotification({ registration }: UpdateNotificationProps) {
const [showNotification, setShowNotification] = useState(false);
useEffect(() => {
if (registration) {
setShowNotification(true);
}
}, [registration]);
const handleUpdate = () => {
if (registration && registration.waiting) {
// Tell the service worker to skip waiting
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
// Listen for the controller change and reload
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}
};
const handleDismiss = () => {
setShowNotification(false);
};
if (!showNotification) {
return null;
}
return (
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 max-w-md w-full mx-4">
<div className="rounded-lg p-4 shadow-2xl border border-blue-500/30 backdrop-blur-md bg-slate-900/90">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<RefreshCw className="w-6 h-6 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">
New version available!
</p>
<p className="text-xs text-slate-300 mt-1">
A new version of Puffin Offset is ready. Please update to get the latest features and improvements.
</p>
</div>
</div>
<div className="mt-4 flex gap-2">
<button
onClick={handleUpdate}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
Update Now
</button>
<button
onClick={handleDismiss}
className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition-colors"
>
Later
</button>
</div>
</div>
</div>
);
}

View File

@ -4,6 +4,11 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/webp" href="/puffinOffset.webp" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Cache Control - Prevent stale cached versions -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />

View File

@ -0,0 +1,106 @@
// Service Worker registration with automatic update detection for Next.js
// This ensures users always get the latest version after deployment
type Config = {
onUpdate?: (registration: ServiceWorkerRegistration) => void;
onSuccess?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
// Wait for page load to avoid impacting initial page load performance
window.addEventListener('load', () => {
const swUrl = `/sw.js`;
registerValidSW(swUrl, config);
// Check for updates every hour
setInterval(() => {
checkForUpdates(swUrl);
}, 60 * 60 * 1000); // 1 hour
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
// Check for updates on initial registration
registration.update();
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// New content is available; please refresh
console.log('New content available! Please refresh.');
// Execute onUpdate callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// Content is cached for offline use
console.log('Content cached for offline use.');
// Execute onSuccess callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error('Error during service worker registration:', error);
});
}
function checkForUpdates(swUrl: string) {
navigator.serviceWorker
.getRegistration(swUrl)
.then((registration) => {
if (registration) {
registration.update();
}
})
.catch((error) => {
console.error('Error checking for service worker updates:', error);
});
}
export function unregister() {
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}
// Force refresh when a new service worker is waiting
export function skipWaitingAndReload() {
if (typeof window !== 'undefined') {
navigator.serviceWorker.ready.then((registration) => {
if (registration.waiting) {
// Tell the waiting service worker to skip waiting and become active
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
});
// Listen for the controller change and reload
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}
}

View File

@ -36,6 +36,20 @@ const nextConfig = {
},
],
},
// Service Worker - no cache to ensure updates are detected immediately
{
source: '/sw.js',
headers: [
{
key: 'Cache-Control',
value: 'no-cache, no-store, must-revalidate',
},
{
key: 'Service-Worker-Allowed',
value: '/',
},
],
},
];
},

View File

@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "next dev",
"prebuild": "node scripts/inject-sw-version.js",
"build": "next build",
"start": "next start",
"lint": "next lint",

View File

@ -0,0 +1,70 @@
import { useState, useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
interface UpdateNotificationProps {
registration: ServiceWorkerRegistration | null;
}
export function UpdateNotification({ registration }: UpdateNotificationProps) {
const [showNotification, setShowNotification] = useState(false);
useEffect(() => {
if (registration) {
setShowNotification(true);
}
}, [registration]);
const handleUpdate = () => {
if (registration && registration.waiting) {
// Tell the service worker to skip waiting
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
// Listen for the controller change and reload
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}
};
const handleDismiss = () => {
setShowNotification(false);
};
if (!showNotification) {
return null;
}
return (
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 max-w-md w-full mx-4">
<div className="glass-card rounded-lg p-4 shadow-2xl border border-blue-500/30 backdrop-blur-md bg-slate-900/90">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<RefreshCw className="w-6 h-6 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">
New version available!
</p>
<p className="text-xs text-slate-300 mt-1">
A new version of Puffin Offset is ready. Please update to get the latest features and improvements.
</p>
</div>
</div>
<div className="mt-4 flex gap-2">
<button
onClick={handleUpdate}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
Update Now
</button>
<button
onClick={handleDismiss}
className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition-colors"
>
Later
</button>
</div>
</div>
</div>
);
}

View File

@ -1,13 +1,35 @@
import { StrictMode } from 'react';
import { StrictMode, useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import { ErrorBoundary } from './components/ErrorBoundary';
import { UpdateNotification } from './components/UpdateNotification';
import * as swRegistration from './utils/serviceWorkerRegistration';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>
);
function Root() {
const [updateAvailable, setUpdateAvailable] = useState<ServiceWorkerRegistration | null>(null);
useEffect(() => {
// Register service worker with update detection
swRegistration.register({
onUpdate: (registration) => {
console.log('New version available!');
setUpdateAvailable(registration);
},
onSuccess: (registration) => {
console.log('Service worker registered successfully:', registration);
}
});
}, []);
return (
<StrictMode>
<ErrorBoundary>
<App />
<UpdateNotification registration={updateAvailable} />
</ErrorBoundary>
</StrictMode>
);
}
createRoot(document.getElementById('root')!).render(<Root />);

View File

@ -0,0 +1,104 @@
// Service Worker registration with automatic update detection
// This ensures users always get the latest version after deployment
type Config = {
onUpdate?: (registration: ServiceWorkerRegistration) => void;
onSuccess?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if ('serviceWorker' in navigator) {
// Wait for page load to avoid impacting initial page load performance
window.addEventListener('load', () => {
const swUrl = `/sw.js`;
registerValidSW(swUrl, config);
// Check for updates every hour
setInterval(() => {
checkForUpdates(swUrl);
}, 60 * 60 * 1000); // 1 hour
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
// Check for updates on initial registration
registration.update();
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// New content is available; please refresh
console.log('New content available! Please refresh.');
// Execute onUpdate callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// Content is cached for offline use
console.log('Content cached for offline use.');
// Execute onSuccess callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error('Error during service worker registration:', error);
});
}
function checkForUpdates(swUrl: string) {
navigator.serviceWorker
.getRegistration(swUrl)
.then((registration) => {
if (registration) {
registration.update();
}
})
.catch((error) => {
console.error('Error checking for service worker updates:', error);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}
// Force refresh when a new service worker is waiting
export function skipWaitingAndReload() {
navigator.serviceWorker.ready.then((registration) => {
if (registration.waiting) {
// Tell the waiting service worker to skip waiting and become active
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
});
// Listen for the controller change and reload
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}

View File

@ -1,65 +1,124 @@
const CACHE_NAME = 'puffin-calculator-v2'; // Bumped to clear old cached code
// Service Worker with automatic versioning and cache invalidation
// Version is updated on each build to force cache refresh
const BUILD_TIMESTAMP = '__BUILD_TIMESTAMP__'; // Replaced during build
const CACHE_NAME = `puffin-calculator-${BUILD_TIMESTAMP}`;
const MAX_CACHE_AGE = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
const urlsToCache = [
'/',
'/mobile-app',
'/static/js/bundle.js',
'/static/css/main.css',
'/puffinOffset.webp',
'/manifest.json'
];
// Install event - cache resources
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker version:', BUILD_TIMESTAMP);
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('[SW] Caching app shell');
return cache.addAll(urlsToCache);
})
.then(() => {
// Force the waiting service worker to become the active service worker
return self.skipWaiting();
})
.catch((error) => {
console.log('Cache install failed:', error);
console.error('[SW] Cache install failed:', error);
})
);
});
// Activate event - clear old caches
// Activate event - clear old caches and claim clients
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker version:', BUILD_TIMESTAMP);
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log('Clearing old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
Promise.all([
// Clear old caches
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log('[SW] Clearing old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
}),
// Claim all clients immediately
self.clients.claim()
])
);
});
// Fetch event - serve from cache when offline
// Fetch event - Network first, fall back to cache
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip cross-origin requests
if (url.origin !== location.origin) {
return;
}
event.respondWith(
caches.match(event.request)
// Try network first
fetch(request)
.then((response) => {
// Return cached version or fetch from network
return response || fetch(event.request);
}
)
// Don't cache if not a success response
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone the response
const responseToCache = response.clone();
// Cache the fetched resource
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseToCache);
});
return response;
})
.catch(() => {
// Network failed, try cache
return caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
// Check cache age for HTML documents
if (request.destination === 'document') {
const cacheDate = cachedResponse.headers.get('sw-cache-date');
if (cacheDate) {
const age = Date.now() - parseInt(cacheDate, 10);
if (age > MAX_CACHE_AGE) {
console.log('[SW] Cached HTML is too old, returning without cache');
return new Response('Cache expired. Please connect to the internet.', {
status: 503,
statusText: 'Service Unavailable'
});
}
}
}
return cachedResponse;
}
// Not in cache and network failed
return new Response('Offline and not cached', {
status: 503,
statusText: 'Service Unavailable'
});
});
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
// Listen for skip waiting message
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
console.log('[SW] Received SKIP_WAITING message');
self.skipWaiting();
}
});

View File

@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* Inject build timestamp into service worker
* This script replaces __BUILD_TIMESTAMP__ with the current timestamp
* to force cache invalidation on new deployments
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const SW_SOURCE = path.join(__dirname, '..', 'public', 'sw.js');
try {
// Read the service worker file
let swContent = fs.readFileSync(SW_SOURCE, 'utf8');
// Check if placeholder exists
if (!swContent.includes('__BUILD_TIMESTAMP__')) {
console.log('⚠️ Service worker appears to be already processed');
process.exit(0);
}
// Generate version (use git commit hash if available, otherwise use timestamp)
let version;
try {
// Try to get git commit hash using safe execFileSync
version = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
console.log(`📦 Using git commit hash: ${version}`);
} catch (error) {
// Fallback to timestamp
version = Date.now().toString();
console.log(`📦 Using timestamp: ${version}`);
}
// Replace the placeholder with the version
swContent = swContent.replace(/__BUILD_TIMESTAMP__/g, version);
// Write the updated service worker
fs.writeFileSync(SW_SOURCE, swContent);
console.log(`✅ Service worker version injected: ${version}`);
console.log(`📝 Updated: ${SW_SOURCE}`);
} catch (error) {
console.error('❌ Error injecting service worker version:', error);
process.exit(1);
}