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
39 changes: 39 additions & 0 deletions docs-site/docs/guides/offline-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,45 @@ if ('serviceWorker' in navigator) {
}
```

### Controlled update prompt

When a new version of the dashboard is deployed, the service worker installs in the background without forcing an immediate reload. Instead, it notifies the application that an update is available, and the user sees a prompt asking them to activate the new version. This prevents stale-chunk crashes and unexpected reloads.

**How it works:**

1. The new SW installs and enters a **waiting** state.
2. The application detects the waiting worker and sets `updateAvailable = true`.
3. A UI banner (SWUpdatePrompt) invites the user to update.
4. When the user clicks **Update**, the app sends a `SKIP_WAITING` message to the waiting worker.
5. The SW activates, takes control of all clients, and the page reloads with the new version.

**Developer API:**

```ts
import {
subscribeToSWUpdates,
applySWUpdate,
isSWUpdateAvailable,
} from '@/utils/offline';

// Subscribe to update availability
const unsub = subscribeToSWUpdates((available) => {
if (available) showUpdateBanner();
});

// Check if an update is waiting
if (isSWUpdateAvailable()) {
// Show update UI
}

// Activate the update
await applySWUpdate(); // sends SKIP_WAITING, triggers reload
```

**Compatibility:** Requires `'serviceWorker' in navigator`. Functions are no-ops in unsupported environments.

**Security:** The `SKIP_WAITING` message is only posted to the same-origin service worker registered by the application. No user data is transmitted during the update flow.

## Cache invalidation

```ts
Expand Down
11 changes: 10 additions & 1 deletion public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,16 @@ self.addEventListener('install', (event) => {
caches
.open(SHELL_CACHE)
.then((cache) => cache.addAll(SHELL_ASSETS))
.then(() => self.skipWaiting()),
// Do NOT self.skipWaiting() here — let the client control activation
// via the SKIP_WAITING message so users can choose when to update.
.then(() => {
// Notify all clients that a new SW version is installed and waiting
self.clients.matchAll().then((clients) => {
clients.forEach((client) => {
client.postMessage({ type: 'SW_INSTALLED' });
});
});
}),
);
});

Expand Down
57 changes: 57 additions & 0 deletions src/components/SWUpdatePrompt.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React, { useState, useEffect } from 'react';
import { RefreshCw, X } from 'lucide-react';
import { subscribeToSWUpdates, applySWUpdate } from '../utils/offline';

export default function SWUpdatePrompt() {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [dismissed, setDismissed] = useState(false);

useEffect(() => {
const unsubscribe = subscribeToSWUpdates((available) => {
setUpdateAvailable(available);
if (available) setDismissed(false);
});
return unsubscribe;
}, []);

if (!updateAvailable || dismissed) return null;

const handleUpdate = async () => {
setDismissed(true);
await applySWUpdate();
};

return (
<div
role="status"
aria-live="polite"
className="fixed bottom-6 right-6 left-6 md:left-auto md:w-[420px] bg-[#1e2327]/95 backdrop-blur-xl border border-cyan-500/20 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-2xl p-4 flex items-start gap-4 z-[2000] animate-in fade-in slide-in-from-bottom-8 duration-500"
>
<div className="bg-cyan-500/10 p-2.5 rounded-xl text-cyan-500">
<RefreshCw size={22} />
</div>

<div className="flex-1 min-w-0">
<h4 className="text-white font-bold text-sm tracking-tight">Update Available</h4>
<p className="text-gray-400 text-xs mt-1 leading-relaxed">
A new version of the dashboard is ready. Update now to get the latest features and fixes.
</p>
</div>

<div className="flex items-center gap-2 shrink-0">
<button
onClick={handleUpdate}
className="px-4 py-2 bg-cyan-500 text-white font-bold text-xs rounded-lg hover:bg-cyan-400 transition-colors shadow-sm"
>
Update
</button>
<button
onClick={() => setDismissed(true)}
className="p-2 text-gray-500 hover:text-white transition-colors rounded-lg"
>
<X size={18} />
</button>
</div>
</div>
);
}
1 change: 1 addition & 0 deletions src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import "./styles/globals.css";
import { initMonitoring } from "./utils/monitoring";
import { selfHealingManager } from "./lib/errorHandling/SelfHealingManager";
import { registerBuiltInStrategies, registerNetworkProbes } from "./lib/errorHandling/RecoveryStrategyRegistry.ts";
import { registerServiceWorker } from "./utils/offline";

// ── Monitoring must be the very first thing that runs so Sentry and the
// global error handlers are in place before any React code executes.
Expand Down
2 changes: 2 additions & 0 deletions src/routes/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import KeyboardNavigation from '../components/accessibility/KeyboardNavigation';
import ThemeToggle from '../components/layout/ThemeToggle';
import OfflineBanner from '../components/layout/OfflineBanner';
import PWAInstallBanner from '../components/PWAInstallBanner';
import SWUpdatePrompt from '../components/SWUpdatePrompt';
import { useSwipeGesture } from '../hooks/useSwipeGesture';
import DevToolbar from '../components/dashboard/DevToolbar';
import DebugAssistantButton from '../components/debug/DebugAssistantButton';
Expand Down Expand Up @@ -384,6 +385,7 @@ export default function DashboardLayout() {
<ErrorBoundary onRetry={handleRetry} maxRetries={3}>
<OfflineBanner />
<PWAInstallBanner />
<SWUpdatePrompt />
<div
style={{
display: 'flex',
Expand Down
96 changes: 96 additions & 0 deletions src/utils/offline.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,109 @@ export async function registerServiceWorker() {
logger.warn('Background sync registration failed:', err);
}
}

// Initialise the controlled SW update prompt
initSWUpdatePrompt(registration);
} catch (error) {
logger.error('Service Worker registration failed:', {}, error);
}

initOfflineDetection();
}

// ─── Controlled SW Update Prompt ──────────────────────────────────────────────

let _swRegistration = null;
let _swUpdateCallbacks = [];
let _swUpdateAvailable = false;

/**
* Initialise the controlled service-worker update prompt.
*
* Listens for updatefound / statechange on the registration so that the
* application can ask the user before activating a new SW version, preventing
* stale-chunk crashes and unexpected reloads.
*
* Must be called after a successful navigator.serviceWorker.register() call.
*
* @param {ServiceWorkerRegistration} registration
*/
export function initSWUpdatePrompt(registration) {
if (!registration) return;
_swRegistration = registration;

// If a waiting worker already exists (e.g. after a page reload while a
// newer SW was waiting), surface it immediately.
if (registration.waiting) {
notifySWUpdateAvailable();
}

// Listen for new SW installations
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (!newWorker) return;

newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && registration.active) {
// A new SW has installed and is now waiting for activation.
// This means an update is available (not the initial install).
notifySWUpdateAvailable();
}
});
});

// Reload the page once the new SW takes control, so all assets come from
// the new version. Use a guard to avoid loops if the reload triggers a
// controllerchange on the new page.
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) return;
refreshing = true;
window.location.reload();
});
}

/**
* Subscribe to SW update availability changes.
*
* @param {(available: boolean) => void} callback
* @returns {() => void} unsubscribe function
*/
export function subscribeToSWUpdates(callback) {
_swUpdateCallbacks.push(callback);
// Immediately notify with the current state
try { callback(_swUpdateAvailable); } catch { /* ignore */ }
return () => {
_swUpdateCallbacks = _swUpdateCallbacks.filter((cb) => cb !== callback);
};
}

/**
* Activate the waiting service worker and reload the page with the new version.
* Does nothing if no update is available.
*/
export async function applySWUpdate() {
if (!_swRegistration || !_swRegistration.waiting) return;

_swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
}

/**
* Returns whether a new SW version is currently waiting to be activated.
*
* @returns {boolean}
*/
export function isSWUpdateAvailable() {
return _swUpdateAvailable;
}

function notifySWUpdateAvailable() {
_swUpdateAvailable = true;
_swUpdateCallbacks.forEach((cb) => {
try { cb(true); } catch { /* ignore */ }
});
}

/**
* Sets up online/offline event listeners.
*/
Expand Down
Loading