From 351bdb8ab1ff1eb11b1c7954dce6c6f2ab214a3e Mon Sep 17 00:00:00 2001 From: gbengaeben Date: Sat, 27 Jun 2026 18:31:58 +0000 Subject: [PATCH] perf: defer service worker registration with requestIdleCallback (#461) --- src/components/ServiceWorkerRegistration.tsx | 24 ++- .../ServiceWorkerRegistration.test.tsx | 158 +++++++++++++++--- 2 files changed, 155 insertions(+), 27 deletions(-) diff --git a/src/components/ServiceWorkerRegistration.tsx b/src/components/ServiceWorkerRegistration.tsx index ddc17afb..db4b1a18 100644 --- a/src/components/ServiceWorkerRegistration.tsx +++ b/src/components/ServiceWorkerRegistration.tsx @@ -4,9 +4,26 @@ import { useEffect } from "react"; import { logger } from "@/utils/logger"; import { startQueueAutoFlush } from "@/lib/offlineTransactionQueue"; +/** + * Schedules a callback during browser idle time, falling back to setTimeout. + */ +function scheduleWhenIdle(callback: () => void): () => void { + if (typeof window !== "undefined" && "requestIdleCallback" in window) { + const handle = window.requestIdleCallback(callback, { timeout: 5000 }); + return () => window.cancelIdleCallback(handle); + } + + const timeout = window.setTimeout(callback, 2000); + return () => window.clearTimeout(timeout); +} + /** * Registers the service worker, wires update notifications, and starts the * offline transaction-queue auto-flush listener. + * + * Registration is deferred via requestIdleCallback (with a 5 s timeout) + * to avoid blocking the initial paint and keep Time to Interactive low. + * The auto-flush listener is still started eagerly since it is cheap. */ export function ServiceWorkerRegistration(): null { useEffect(() => { @@ -45,16 +62,13 @@ export function ServiceWorkerRegistration(): null { } }; - if (document.readyState === "complete") { - register(); - } else { - window.addEventListener("load", register, { once: true }); - } + const cancelIdle = scheduleWhenIdle(register); const stopAutoFlush = startQueueAutoFlush(); return () => { cancelled = true; + cancelIdle(); stopAutoFlush(); }; }, []); diff --git a/src/components/__tests__/ServiceWorkerRegistration.test.tsx b/src/components/__tests__/ServiceWorkerRegistration.test.tsx index 6f6bba6b..6fbf149c 100644 --- a/src/components/__tests__/ServiceWorkerRegistration.test.tsx +++ b/src/components/__tests__/ServiceWorkerRegistration.test.tsx @@ -1,29 +1,62 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +/** + * Tests for ServiceWorkerRegistration with deferred registration. + * + * Uses jest + jsdom. requestIdleCallback / setTimeout behaviour is verified + * via direct property assignment on globalThis rather than replacing the + * entire window object. + */ + +// Mock logger +jest.mock('@/utils/logger', () => ({ + logger: { warn: jest.fn() }, +})); + +// Mock offline transaction queue +jest.mock('@/lib/offlineTransactionQueue', () => ({ + startQueueAutoFlush: jest.fn(() => jest.fn()), +})); + import { render } from '@testing-library/react'; import { ServiceWorkerRegistration } from '../ServiceWorkerRegistration'; -const mockRegister = vi.fn(); -const mockAddEventListener = vi.fn(); +const mockRegister = jest.fn(); +const mockSwAddEventListener = jest.fn(); +let requestIdleCallbackSpy: jest.Mock; +let cancelIdleCallbackSpy: jest.Mock; +let idleCallback: (() => void) | null = null; beforeEach(() => { - vi.stubGlobal('navigator', { - serviceWorker: { + jest.clearAllMocks(); + idleCallback = null; + + // Set up requestIdleCallback on the existing jsdom window + requestIdleCallbackSpy = jest.fn((cb: () => void) => { + idleCallback = cb; + return 1; + }); + cancelIdleCallbackSpy = jest.fn(); + + (globalThis as any).requestIdleCallback = requestIdleCallbackSpy; + (globalThis as any).cancelIdleCallback = cancelIdleCallbackSpy; + + // Set up service worker mock + Object.defineProperty(navigator, 'serviceWorker', { + value: { register: mockRegister, controller: null, - addEventListener: mockAddEventListener, + addEventListener: mockSwAddEventListener, }, + writable: true, + configurable: true, }); - vi.stubGlobal('window', { - location: { reload: vi.fn() }, - addEventListener: vi.fn(), - }); - vi.stubGlobal('document', { - readyState: 'loading', - }); + + process.env.NODE_ENV = 'development'; }); afterEach(() => { - vi.restoreAllMocks(); + delete (globalThis as any).requestIdleCallback; + delete (globalThis as any).cancelIdleCallback; + jest.restoreAllMocks(); }); describe('ServiceWorkerRegistration', () => { @@ -32,30 +65,111 @@ describe('ServiceWorkerRegistration', () => { expect(container.firstChild).toBeNull(); }); - it('attempts service worker registration on load', async () => { - mockRegister.mockResolvedValue({ installing: null, addEventListener: vi.fn() }); + it('defers registration via requestIdleCallback', () => { render(); - await vi.dynamicImportSettled(); + + expect(requestIdleCallbackSpy).toHaveBeenCalledWith( + expect.any(Function), + { timeout: 5000 } + ); expect(mockRegister).not.toHaveBeenCalled(); }); + it('registers service worker when idle callback fires', async () => { + mockRegister.mockResolvedValue({ + installing: null, + addEventListener: jest.fn(), + }); + + render(); + + expect(idleCallback).not.toBeNull(); + await idleCallback!(); + + expect(mockRegister).toHaveBeenCalledWith('/sw.js'); + }); + + it('sets up controllerchange listener', async () => { + mockRegister.mockResolvedValue({ + installing: null, + addEventListener: jest.fn(), + }); + + render(); + + await idleCallback!(); + + expect(mockSwAddEventListener).toHaveBeenCalledWith( + 'controllerchange', + expect.any(Function) + ); + }); + it('does not register when serviceWorker is unavailable', () => { - vi.stubGlobal('navigator', {}); + const origSw = (navigator as any).serviceWorker; + delete (navigator as any).serviceWorker; + render(); + expect(requestIdleCallbackSpy).not.toHaveBeenCalled(); expect(mockRegister).not.toHaveBeenCalled(); + + // Restore + Object.defineProperty(navigator, 'serviceWorker', { + value: origSw, + writable: true, + configurable: true, + }); }); it('does not register in test environment', () => { - vi.stubGlobal('process', { env: { NODE_ENV: 'test' } }); + process.env.NODE_ENV = 'test'; + render(); + expect(requestIdleCallbackSpy).not.toHaveBeenCalled(); expect(mockRegister).not.toHaveBeenCalled(); }); it('handles registration error gracefully', async () => { mockRegister.mockRejectedValue(new Error('SW registration failed')); - vi.stubGlobal('document', { readyState: 'complete' }); + const { logger } = require('@/utils/logger'); + render(); - await vi.dynamicImportSettled(); - expect(mockRegister).toHaveBeenCalledWith('/sw.js'); + await idleCallback!(); + + expect(logger.warn).toHaveBeenCalledWith( + 'Service worker registration failed', + expect.any(Error) + ); + }); + + it('falls back to setTimeout when requestIdleCallback is unavailable', () => { + delete (globalThis as any).requestIdleCallback; + + const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout').mockReturnValue( + 2 as unknown as NodeJS.Timeout + ); + + render(); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2000); + expect(mockRegister).not.toHaveBeenCalled(); + + setTimeoutSpy.mockRestore(); + }); + + it('cancels idle callback on unmount', () => { + const { unmount } = render(); + unmount(); + + expect(cancelIdleCallbackSpy).toHaveBeenCalledWith(1); + }); + + it('cleans up auto-flush on unmount', () => { + const { unmount } = render(); + unmount(); + + // The startQueueAutoFlush mock returns a cleanup fn which should be called + const { startQueueAutoFlush } = require('@/lib/offlineTransactionQueue'); + expect(startQueueAutoFlush).toHaveBeenCalled(); }); });