Skip to content

Commit aaeb160

Browse files
committed
Add in-app offline bundle reset flow
1 parent 956d4ca commit aaeb160

2 files changed

Lines changed: 73 additions & 10 deletions

File tree

e2e/offline.spec.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test';
22

33
test.describe('offline bundle', () => {
44
test('download button shows progress and ends in ready state', async ({ page }) => {
5-
test.setTimeout(300_000);
5+
test.setTimeout(420_000);
66

77
await page.goto('/');
88
await page.evaluate(async () => {
@@ -28,11 +28,22 @@ test.describe('offline bundle', () => {
2828

2929
const statusChip = page.getByTestId('offline-download-status');
3030
await expect(statusChip).toBeVisible({ timeout: 20_000 });
31-
await expect(statusChip).toContainText(/^\d+\/(\d+|\?)$/, { timeout: 20_000 });
31+
await expect(statusChip).toContainText(/^(preparing\.\.\.|finalizing\.\.\.|\d+\/\d+.*)$/i, { timeout: 20_000 });
3232
await expect(statusChip).toContainText(/offline ready/i, { timeout: 240_000 });
3333

3434
await expect(offlineButton).toHaveAttribute('aria-label', /offline bundle ready/i, { timeout: 240_000 });
3535

36+
const resetButton = page.getByTestId('offline-reset-button');
37+
await expect(resetButton).toBeVisible();
38+
await resetButton.click();
39+
await expect(statusChip).toBeHidden({ timeout: 20_000 });
40+
41+
const clearedState = await page.evaluate(() => localStorage.getItem('svt:offline-ready-v1') === null);
42+
expect(clearedState).toBe(true);
43+
44+
await offlineButton.click();
45+
await expect(statusChip).toContainText(/offline ready/i, { timeout: 240_000 });
46+
3647
const readyState = await page.evaluate(async () => {
3748
const state = localStorage.getItem('svt:offline-ready-v1');
3849
if (state !== 'ready' || !('caches' in window)) return false;

src/routes/+layout.svelte

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
let offlineDone = $state(0);
4343
let offlineTotal = $state(0);
4444
let offlineMessage = $state('');
45+
let offlineCurrentAsset = $state('');
4546
const offlineFeatureEnabled = import.meta.env.PROD;
4647
const LEGACY_COI_CLEANUP_ONCE_KEY = 'svt:legacy-coi-cleanup-once';
4748
@@ -67,6 +68,23 @@
6768
return new URL(String(url), window.location.href).href;
6869
}
6970
71+
function basenameFromUrl(url) {
72+
try {
73+
const pathname = new URL(String(url), window.location.href).pathname;
74+
const base = pathname.split('/').filter(Boolean).pop() || pathname;
75+
return decodeURIComponent(base);
76+
} catch {
77+
return String(url || '');
78+
}
79+
}
80+
81+
function compactAssetLabel(text, max = 18) {
82+
const value = String(text || '').trim();
83+
if (!value) return '';
84+
if (value.length <= max) return value;
85+
return `${value.slice(0, Math.max(1, max - 1))}...`;
86+
}
87+
7088
function offlineReadyMarkerUrl() {
7189
return toAbsolute(offlineReadySentinelUrl(runtimeBasePath()));
7290
}
@@ -121,13 +139,14 @@
121139
return { removed, controlledByLegacy };
122140
}
123141
124-
async function clearOfflineArtifactsForDev() {
142+
async function clearOfflineArtifacts() {
125143
if (!browser) return;
126144
localStorage.removeItem(OFFLINE_STATE_KEY);
127145
offlineMode = 'idle';
128146
offlineDone = 0;
129147
offlineTotal = 0;
130148
offlineMessage = '';
149+
offlineCurrentAsset = '';
131150
132151
if ('serviceWorker' in navigator) {
133152
const scopeRoot = new URL(`${base || ''}/`, window.location.href).href;
@@ -149,6 +168,11 @@
149168
}
150169
}
151170
171+
async function resetOfflineBundle() {
172+
if (!browser || offlineMode === 'downloading') return;
173+
await clearOfflineArtifacts();
174+
}
175+
152176
async function resolveOfflineAssetUrls(config) {
153177
const basePath = runtimeBasePath();
154178
const urls = buildConfiguredRuntimeUrls({
@@ -195,8 +219,10 @@
195219
return 'Offline bundle download is available in preview/prod builds';
196220
}
197221
if (offlineMode === 'downloading') {
198-
const total = offlineTotal || '?';
199-
return `Downloading offline bundle (${offlineDone}/${total})`;
222+
if (offlineTotal <= 0) return 'Preparing offline bundle...';
223+
if (offlineDone >= offlineTotal) return 'Finalizing offline bundle...';
224+
const current = offlineCurrentAsset ? ` (${offlineCurrentAsset})` : '';
225+
return `Downloading offline bundle (${offlineDone + 1}/${offlineTotal})${current}`;
200226
}
201227
if (offlineMode === 'ready') {
202228
return offlineMessage || 'Offline bundle is ready';
@@ -209,16 +235,22 @@
209235
210236
function offlineButtonLabel() {
211237
if (!offlineFeatureEnabled) return 'Offline bundle unavailable in dev mode';
212-
if (offlineMode === 'downloading') return 'Downloading offline bundle';
238+
if (offlineMode === 'downloading') {
239+
if (offlineTotal <= 0) return 'Preparing offline bundle';
240+
if (offlineDone >= offlineTotal) return 'Finalizing offline bundle';
241+
return `Downloading offline bundle ${offlineDone + 1}/${offlineTotal}`;
242+
}
213243
if (offlineMode === 'ready') return 'Offline bundle ready';
214244
if (offlineMode === 'error') return 'Retry offline bundle download';
215245
return 'Download offline bundle';
216246
}
217247
218248
function offlineProgressText() {
219249
if (offlineMode === 'downloading') {
220-
const total = offlineTotal || '?';
221-
return `${offlineDone}/${total}`;
250+
if (offlineTotal <= 0) return 'preparing...';
251+
if (offlineDone >= offlineTotal) return 'finalizing...';
252+
const current = offlineCurrentAsset ? ` ${offlineCurrentAsset}` : '';
253+
return `${offlineDone + 1}/${offlineTotal}${current}`;
222254
}
223255
if (offlineMode === 'ready') return 'offline ready';
224256
if (offlineMode === 'error') return 'download failed';
@@ -231,6 +263,7 @@
231263
offlineDone = 0;
232264
offlineTotal = 0;
233265
offlineMessage = 'Preparing offline bundle...';
266+
offlineCurrentAsset = '';
234267
235268
try {
236269
await ensureOfflineServiceWorker();
@@ -251,6 +284,7 @@
251284
let success = 0;
252285
253286
for (const url of urls) {
287+
offlineCurrentAsset = compactAssetLabel(basenameFromUrl(url));
254288
try {
255289
const request = new Request(url, {
256290
method: 'GET',
@@ -273,6 +307,7 @@
273307
failures.push(`${url}: ${String(error?.message || error)}`);
274308
} finally {
275309
offlineDone += 1;
310+
if (offlineDone >= offlineTotal) offlineCurrentAsset = '';
276311
}
277312
}
278313
@@ -296,11 +331,13 @@
296331
if (skippedCrossOrigin > 0) {
297332
offlineMessage += ` Skipped ${skippedCrossOrigin} cross-origin URLs.`;
298333
}
334+
offlineCurrentAsset = '';
299335
localStorage.setItem(OFFLINE_STATE_KEY, 'ready');
300336
}
301337
} catch (error) {
302338
offlineMode = 'error';
303339
offlineMessage = String(error?.message || error || 'Offline download failed');
340+
offlineCurrentAsset = '';
304341
localStorage.removeItem(OFFLINE_STATE_KEY);
305342
if ('caches' in window) {
306343
caches
@@ -386,7 +423,7 @@
386423
}
387424
388425
if (!offlineFeatureEnabled) {
389-
clearOfflineArtifactsForDev().catch(() => {});
426+
clearOfflineArtifacts().catch(() => {});
390427
return;
391428
}
392429
const offlineRequested = localStorage.getItem(OFFLINE_STATE_KEY) === 'ready';
@@ -509,13 +546,28 @@
509546
</button>
510547
{#if offlineMode !== 'idle'}
511548
<span
512-
class="text-[0.68rem] leading-none whitespace-nowrap px-2 py-[0.32rem] rounded-[8px] border {offlineMode === 'ready' ? 'text-teal border-teal/40 bg-teal/5' : offlineMode === 'error' ? 'text-destructive border-destructive/40 bg-destructive/5' : 'text-muted-foreground border-border bg-surface-2'}"
549+
class="text-[0.68rem] leading-none whitespace-nowrap max-w-[170px] overflow-hidden text-ellipsis px-2 py-[0.32rem] rounded-[8px] border {offlineMode === 'ready' ? 'text-teal border-teal/40 bg-teal/5' : offlineMode === 'error' ? 'text-destructive border-destructive/40 bg-destructive/5' : 'text-muted-foreground border-border bg-surface-2'}"
513550
data-testid="offline-download-status"
514551
aria-live="polite"
515552
title={offlineButtonTitle()}
516553
>
517554
{offlineProgressText()}
518555
</span>
556+
{#if offlineMode === 'ready' || offlineMode === 'error'}
557+
<button
558+
onclick={resetOfflineBundle}
559+
class="flex items-center justify-center w-8 h-8 rounded-[8px] hover:bg-surface-2 transition-colors text-muted-foreground"
560+
data-testid="offline-reset-button"
561+
aria-label="Reset offline bundle"
562+
title="Reset offline bundle cache"
563+
>
564+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
565+
<path d="M3 6h18" />
566+
<path d="M8 6V4h8v2" />
567+
<path d="M6 6l1 14h10l1-14" />
568+
</svg>
569+
</button>
570+
{/if}
519571
{/if}
520572
<button
521573
onclick={toggleDarkMode}

0 commit comments

Comments
 (0)