Skip to content
Merged
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
24 changes: 19 additions & 5 deletions src/components/ServiceWorkerRegistration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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();
};
}, []);
Expand Down
158 changes: 136 additions & 22 deletions src/components/__tests__/ServiceWorkerRegistration.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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(<ServiceWorkerRegistration />);
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(<ServiceWorkerRegistration />);

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(<ServiceWorkerRegistration />);

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(<ServiceWorkerRegistration />);
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(<ServiceWorkerRegistration />);
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(<ServiceWorkerRegistration />);
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(<ServiceWorkerRegistration />);

expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2000);
expect(mockRegister).not.toHaveBeenCalled();

setTimeoutSpy.mockRestore();
});

it('cancels idle callback on unmount', () => {
const { unmount } = render(<ServiceWorkerRegistration />);
unmount();

expect(cancelIdleCallbackSpy).toHaveBeenCalledWith(1);
});

it('cleans up auto-flush on unmount', () => {
const { unmount } = render(<ServiceWorkerRegistration />);
unmount();

// The startQueueAutoFlush mock returns a cleanup fn which should be called
const { startQueueAutoFlush } = require('@/lib/offlineTransactionQueue');
expect(startQueueAutoFlush).toHaveBeenCalled();
});
});