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
27 changes: 25 additions & 2 deletions frontend/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

let { children } = $props();
let updateAvailable = $state(false);
let updating = $state(false);
let waitingWorker: ServiceWorker | null = null;

let pageTitle = $derived.by(() => {
Expand Down Expand Up @@ -54,13 +55,24 @@
}

function applyUpdate() {
updating = true;
waitingWorker?.postMessage({ type: 'SKIP_WAITING' });
}

// Detect new service worker versions
onMount(() => {
if (!browser || !('serviceWorker' in navigator)) return;

// In dev mode, clean up any stale SW registrations and bail out.
// Vite serves a new SW on every load, which causes an infinite
// controllerchange → reload loop if a registration persists.
if (import.meta.env.DEV) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((reg) => reg.unregister());
});
return;
}

// Reload when the new SW takes control (after SKIP_WAITING → skipWaiting → activate → clients.claim)
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
Expand All @@ -82,6 +94,13 @@
}
});
});

// Check for updates when the tab regains focus
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
registration.update();
}
});
});
});

Expand Down Expand Up @@ -223,8 +242,12 @@

{#if updateAvailable}
<div class="update-banner">
<span>A new version of Skyreader is available.</span>
<button class="update-btn" onclick={applyUpdate}> Update </button>
{#if updating}
<span>Updating...</span>
{:else}
<span>A new version of Skyreader is available.</span>
<button class="update-btn" onclick={applyUpdate}> Update </button>
{/if}
</div>
{/if}

Expand Down
23 changes: 12 additions & 11 deletions frontend/src/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,9 @@ const LAST_REFRESH_KEY = 'lastRefreshAt';
const API_CACHE_ROUTES = ['/api/v2/feeds/fetch', '/api/social/feed', '/api/social/popular'];

self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(async (cache) => {
await cache.addAll(STATIC_ASSETS);
// Cache the SPA fallback page so the app shell works offline.
// adapter-static generates index.html but it's not included in
// $service-worker's build/files arrays.
await cache.add('/');
})
);
// Only cache the SPA fallback — static assets are cached lazily on fetch.
// This keeps install fast so the update banner appears quickly.
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.add('/')));
// Do NOT call skipWaiting() here. Activating a new SW while old pages are
// still running can break lazily-loaded code-split chunks whose filenames
// changed between builds. Instead, the app sends a SKIP_WAITING message
Expand Down Expand Up @@ -61,11 +55,18 @@ self.addEventListener('fetch', (event) => {
// Skip chrome-extension and other non-http schemes
if (!url.protocol.startsWith('http')) return;

// Static assets: cache-first
// Static assets: cache-first, cache on miss (lazy population)
if (STATIC_ASSETS.includes(url.pathname)) {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
if (cached) return cached;
return fetch(event.request).then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
});
})
);
return;
Expand Down
Loading