Skip to content
Draft
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
55 changes: 48 additions & 7 deletions src/lib/register-service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,60 @@ export async function registerServiceWorker (): Promise<ServiceWorkerRegistratio
scope: '/'
})

// sw was registered immediately
if (swRegistration.active?.state === 'activated') {
return swRegistration
}

return waitForActivation(swRegistration)
}

/**
* Waits for a service worker to reach the 'activated' state.
*
* The naive approach of only listening for 'updatefound' has edge cases that
* can leave users stuck on a loading screen forever:
*
* 1. Race condition: a worker may already be in 'installing' or 'waiting' state
* when we start listening, so we'd miss the activation event.
*
* 2. Silent failures: if a worker becomes 'redundant' (e.g., replaced by a
* newer version mid-install), we need to detect this and fail explicitly.
*
* 3. Indefinite hangs: without a timeout, a stuck worker activation would
* hang the page forever with no feedback.
*
* To handle these, we track all existing workers (installing, waiting, active)
* plus any new ones via 'updatefound', and enforce a 30-second timeout.
*/
async function waitForActivation (swRegistration: ServiceWorkerRegistration): Promise<ServiceWorkerRegistration> {
return new Promise((resolve, reject) => {
swRegistration.addEventListener('updatefound', () => {
const newWorker = swRegistration.installing
newWorker?.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
resolve(swRegistration)
}
const succeed = (): void => {
clearTimeout(timeoutId)
resolve(swRegistration)
}

const fail = (msg: string): void => {
clearTimeout(timeoutId)
reject(new Error(msg))
}

const timeoutId = setTimeout(() => {
fail('Service worker failed to activate within 30 seconds. Refresh the page to retry.')
}, 30_000)

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() }
}

trackWorker(swRegistration.installing)
trackWorker(swRegistration.waiting)
trackWorker(swRegistration.active)
swRegistration.addEventListener('updatefound', () => {
trackWorker(swRegistration.installing)
})
})
}
81 changes: 81 additions & 0 deletions test-e2e/service-worker-registration.test.ts
Original file line number Diff line number Diff line change
@@ -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.
})
Loading