From 747c2393db0e5f65a7a064d4c9ebe1729f50ecf3 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 21 Jan 2026 16:46:32 +0100 Subject: [PATCH 1/4] fix: handle service worker registration edge cases belt and suspenders protection against edge cases that could leave users stuck on loading screen. these should not impact normal operation, but will save debugging time if we ever hit them thanks to clear error messages and timeout: 1. race condition: only listened for `updatefound`, but if a worker was already installing or waiting, we'd miss it and wait forever 2. no timeout: if worker failed to activate, the promise would never resolve and page would hang indefinitely 3. silent failures: if worker became redundant (replaced by newer version mid-install), we had no handling and would wait forever now we track existing installing/waiting workers, add a 30s timeout, and properly reject if worker becomes redundant. --- src/lib/register-service-worker.ts | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/lib/register-service-worker.ts b/src/lib/register-service-worker.ts index 60f37453..ec16f2a9 100644 --- a/src/lib/register-service-worker.ts +++ b/src/lib/register-service-worker.ts @@ -6,19 +6,35 @@ export async function registerServiceWorker (): Promise { return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error('Service worker failed to activate within 30 seconds. Refresh the page to retry.')) + }, 30_000) + + const onStateChange = (e: Event): void => { + const sw = e.target as ServiceWorker + if (sw.state === 'activated') { + clearTimeout(timeoutId) + resolve(swRegistration) + } else if (sw.state === 'redundant') { + clearTimeout(timeoutId) + reject(new Error('Service worker became redundant. Refresh the page to retry.')) + } + } + + // track workers that may already be installing or waiting + swRegistration.installing?.addEventListener('statechange', onStateChange) + swRegistration.waiting?.addEventListener('statechange', onStateChange) swRegistration.addEventListener('updatefound', () => { - const newWorker = swRegistration.installing - newWorker?.addEventListener('statechange', () => { - if (newWorker.state === 'activated') { - resolve(swRegistration) - } - }) + swRegistration.installing?.addEventListener('statechange', onStateChange) }) }) } From 2d86369c718663902792ff0aa92bfa715a9cb5a7 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 21 Jan 2026 17:03:38 +0100 Subject: [PATCH 2/4] fix: simplify waitForActivation with settled flag pattern refactor to use idempotent succeed/fail helpers that prevent double resolution. check worker state after adding listener to close race condition window where worker could activate before listener attached. --- src/lib/register-service-worker.ts | 60 +++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/src/lib/register-service-worker.ts b/src/lib/register-service-worker.ts index ec16f2a9..138aab1a 100644 --- a/src/lib/register-service-worker.ts +++ b/src/lib/register-service-worker.ts @@ -13,28 +13,60 @@ export async function registerServiceWorker (): Promise { return new Promise((resolve, reject) => { + let settled = false + + const succeed = (): void => { + if (settled) { return } + settled = true + clearTimeout(timeoutId) + resolve(swRegistration) + } + + const fail = (msg: string): void => { + if (settled) { return } + settled = true + clearTimeout(timeoutId) + reject(new Error(msg)) + } + const timeoutId = setTimeout(() => { - reject(new Error('Service worker failed to activate within 30 seconds. Refresh the page to retry.')) + fail('Service worker failed to activate within 30 seconds. Refresh the page to retry.') }, 30_000) - const onStateChange = (e: Event): void => { - const sw = e.target as ServiceWorker - if (sw.state === 'activated') { - clearTimeout(timeoutId) - resolve(swRegistration) - } else if (sw.state === 'redundant') { - clearTimeout(timeoutId) - reject(new Error('Service worker became redundant. Refresh the page to retry.')) - } + const trackWorker = (sw: ServiceWorker | null): void => { + if (sw == null) { return } + sw.addEventListener('statechange', () => { + if (sw.state === 'activated') { succeed() } else if (sw.state === 'redundant') { fail('Service worker became redundant. Refresh the page to retry.') } + }) + if (sw.state === 'activated') { succeed() } } - // track workers that may already be installing or waiting - swRegistration.installing?.addEventListener('statechange', onStateChange) - swRegistration.waiting?.addEventListener('statechange', onStateChange) + trackWorker(swRegistration.installing) + trackWorker(swRegistration.waiting) + trackWorker(swRegistration.active) swRegistration.addEventListener('updatefound', () => { - swRegistration.installing?.addEventListener('statechange', onStateChange) + trackWorker(swRegistration.installing) }) }) } From e21d350e10d3d21c8ed79357a67a6f6acc05fe28 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 21 Jan 2026 22:00:23 +0100 Subject: [PATCH 3/4] test: add service worker registration tests (experimental) an attempt at E2E tests for SW registration, but these are closer to unit tests than true E2E - they test the registration mechanism in isolation rather than user-facing behavior. may not be worth keeping, but provides some regression coverage for: - fresh registration activation - re-registration after unregister - multiple register/unregister cycles note: timeout error page cannot be tested due to Playwright limitation (SW script fetches are not interceptable) --- test-e2e/service-worker-registration.test.ts | 81 ++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 test-e2e/service-worker-registration.test.ts diff --git a/test-e2e/service-worker-registration.test.ts b/test-e2e/service-worker-registration.test.ts new file mode 100644 index 00000000..ca64dec3 --- /dev/null +++ b/test-e2e/service-worker-registration.test.ts @@ -0,0 +1,81 @@ +import { test, expect } from './fixtures/config-test-fixtures.js' +import { swScopeVerification } from './fixtures/sw-scope-verification.js' +import { waitForServiceWorker } from './fixtures/wait-for-service-worker.js' + +test.describe('service worker registration', () => { + test('activates successfully on fresh registration', async ({ page, baseURL }) => { + // Unregister any existing service worker + await page.goto(baseURL ?? 'http://localhost:3333') + await page.evaluate(async () => { + const registrations = await navigator.serviceWorker.getRegistrations() + await Promise.all(registrations.map(r => r.unregister())) + }) + + // Navigate to trigger fresh registration + await page.goto(baseURL ?? 'http://localhost:3333') + + // Wait for SW to activate + await waitForServiceWorker(page) + + // Verify SW is properly registered + await swScopeVerification(page, expect) + }) + + test('re-registers after being unregistered', async ({ page, baseURL }) => { + // First ensure SW is registered + await page.goto(baseURL ?? 'http://localhost:3333') + await waitForServiceWorker(page) + + // Unregister SW + await page.evaluate(async () => { + const registrations = await navigator.serviceWorker.getRegistrations() + await Promise.all(registrations.map(r => r.unregister())) + }) + + // Verify SW is unregistered + const hasNoRegistration = await page.evaluate(async () => { + return await navigator.serviceWorker.getRegistration() === undefined + }) + expect(hasNoRegistration).toBe(true) + + // Navigate to root to trigger re-registration (avoid IPFS path which causes redirects) + await page.goto(baseURL ?? 'http://localhost:3333', { waitUntil: 'networkidle' }) + + // Wait for SW to activate again + await waitForServiceWorker(page) + + // Verify SW is properly registered + await swScopeVerification(page, expect) + }) + + test('handles multiple register/unregister cycles', async ({ page, baseURL }) => { + for (let i = 0; i < 3; i++) { + // Navigate and wait for SW + await page.goto(baseURL ?? 'http://localhost:3333') + await waitForServiceWorker(page) + + // Verify SW is registered + await swScopeVerification(page, expect) + + // Unregister + await page.evaluate(async () => { + const registrations = await navigator.serviceWorker.getRegistrations() + await Promise.all(registrations.map(r => r.unregister())) + }) + + // Verify unregistered + const hasNoRegistration = await page.evaluate(async () => { + return await navigator.serviceWorker.getRegistration() === undefined + }) + expect(hasNoRegistration).toBe(true) + } + }) + + // NOTE: Testing the activation timeout error page is not possible in E2E because + // Playwright cannot intercept service worker script fetches: + // "Requests for updated Service Worker main script code currently cannot be routed" + // @see https://playwright.dev/docs/service-workers + // + // The timeout functionality is tested implicitly - if waitForActivation hangs + // indefinitely, the tests above would fail. +}) From 8f48aa4333f39142dc8aeb0e66f75a16ccdfbb46 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Thu, 22 Jan 2026 14:38:31 +0100 Subject: [PATCH 4/4] chore: remove redundant settled flag --- src/lib/register-service-worker.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/lib/register-service-worker.ts b/src/lib/register-service-worker.ts index 138aab1a..46baa668 100644 --- a/src/lib/register-service-worker.ts +++ b/src/lib/register-service-worker.ts @@ -29,23 +29,16 @@ export async function registerServiceWorker (): Promise { return new Promise((resolve, reject) => { - let settled = false - const succeed = (): void => { - if (settled) { return } - settled = true clearTimeout(timeoutId) resolve(swRegistration) } const fail = (msg: string): void => { - if (settled) { return } - settled = true clearTimeout(timeoutId) reject(new Error(msg)) }