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