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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe('ContactForm Accessibility', () => {
successMessage: null,
isOnline: true,
wasQueuedOffline: false,
retryQueue: vi.fn(),
};

beforeEach(() => {
Expand Down
1 change: 1 addition & 0 deletions src/components/forms/ContactForm/ContactForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('ContactForm', () => {
successMessage: null,
isOnline: true,
wasQueuedOffline: false,
retryQueue: vi.fn(),
};

beforeEach(() => {
Expand Down
1 change: 1 addition & 0 deletions src/hooks/useWeb3Forms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ vi.mock('@/utils/offline-queue', () => ({
vi.mock('@/utils/background-sync', () => ({
registerBackgroundSync: vi.fn().mockResolvedValue(true),
processQueue: vi.fn().mockResolvedValue(undefined),
startFormQueueFallback: vi.fn(() => () => {}),
getSyncStatus: vi.fn().mockResolvedValue({
supported: false,
registered: false,
Expand Down
25 changes: 24 additions & 1 deletion src/hooks/useWeb3Forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import {
} from '@/schemas/contact.schema';
import { useOfflineQueue } from './useOfflineQueue';
import { addToQueue, getQueueSize } from '@/utils/offline-queue';
import { registerBackgroundSync } from '@/utils/background-sync';
import {
registerBackgroundSync,
processQueue,
startFormQueueFallback,
} from '@/utils/background-sync';

const logger = createLogger('hooks:web3Forms');

Expand Down Expand Up @@ -42,6 +46,8 @@ export interface UseWeb3FormsReturn {
isOnline: boolean;
queueCount: number;
wasQueuedOffline: boolean;
/** Manually flush the offline form queue (#32 — for a retry affordance). */
retryQueue: () => Promise<void>;
}

/**
Expand Down Expand Up @@ -81,8 +87,24 @@ export const useWeb3Forms = (
loadQueueSize();
}, []);

// #32: on browsers without the Background Sync API (Firefox/Safari), drain the
// form queue via foreground online/visibility listeners — otherwise queued
// submissions never auto-send there. No-op where SyncManager is available.
useEffect(() => {
const stop = startFormQueueFallback();
return stop;
}, []);

const queueCount = formsQueueCount + messagingQueueCount;

// Manual retry affordance (#32): flush the queue on demand, then refresh the
// count so the UI reflects what drained.
const retryQueue = useCallback(async () => {
await processQueue();
const newSize = await getQueueSize();
setFormsQueueCount(newSize);
}, []);

const addToOfflineQueue = useCallback(
async (data: ContactFormData): Promise<{ id: string; queued: boolean }> => {
try {
Expand Down Expand Up @@ -283,5 +305,6 @@ export const useWeb3Forms = (
isOnline,
queueCount,
wasQueuedOffline,
retryQueue,
};
};
94 changes: 94 additions & 0 deletions src/utils/background-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Tests for the #32 foreground form-queue fallback (browsers without the
* Background Sync API). processQueue is exercised via its offline-queue deps,
* which we mock so no IndexedDB is touched.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

vi.mock('@/lib/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));

const getQueuedItems = vi.fn();
vi.mock('./offline-queue', () => ({
getQueuedItems: (...a: unknown[]) => getQueuedItems(...a),
removeFromQueue: vi.fn(),
updateRetryCount: vi.fn(),
}));
vi.mock('./web3forms', () => ({ submitWithRetry: vi.fn() }));

import { startFormQueueFallback } from './background-sync';

function setSyncManager(present: boolean) {
if (present) {
(window as unknown as { SyncManager: unknown }).SyncManager =
function () {};
} else {
delete (window as unknown as { SyncManager?: unknown }).SyncManager;
}
Object.defineProperty(navigator, 'serviceWorker', {
value: {},
configurable: true,
});
}

describe('startFormQueueFallback (#32)', () => {
beforeEach(() => {
vi.clearAllMocks();
getQueuedItems.mockResolvedValue([]);
Object.defineProperty(navigator, 'onLine', {
value: true,
configurable: true,
});
});

afterEach(() => {
delete (window as unknown as { SyncManager?: unknown }).SyncManager;
});

it('is a no-op when the Background Sync API is supported', () => {
setSyncManager(true);
const addSpy = vi.spyOn(window, 'addEventListener');
const stop = startFormQueueFallback();
expect(addSpy).not.toHaveBeenCalledWith('online', expect.any(Function));
stop();
addSpy.mockRestore();
});

it('installs online + visibility listeners when unsupported, and cleans up', () => {
setSyncManager(false);
const addSpy = vi.spyOn(window, 'addEventListener');
const docAddSpy = vi.spyOn(document, 'addEventListener');
const removeSpy = vi.spyOn(window, 'removeEventListener');

const stop = startFormQueueFallback();
expect(addSpy).toHaveBeenCalledWith('online', expect.any(Function));
expect(docAddSpy).toHaveBeenCalledWith(
'visibilitychange',
expect.any(Function)
);

stop();
expect(removeSpy).toHaveBeenCalledWith('online', expect.any(Function));

addSpy.mockRestore();
docAddSpy.mockRestore();
removeSpy.mockRestore();
});

it('flushes the queue on an online event', async () => {
setSyncManager(false);
const stop = startFormQueueFallback();
getQueuedItems.mockClear(); // ignore the initial drain on start

window.dispatchEvent(new Event('online'));
await vi.waitFor(() => expect(getQueuedItems).toHaveBeenCalled());
stop();
});
});
44 changes: 44 additions & 0 deletions src/utils/background-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,50 @@ export function isBackgroundSyncSupported(): boolean {
return 'serviceWorker' in navigator && 'SyncManager' in window;
}

/**
* Fallback queue flush for browsers WITHOUT the Background Sync API
* (Firefox, Safari) — #32. The SyncManager path can't run there, so the queued
* form submissions would never drain on their own. Install foreground listeners
* that processQueue() when the browser comes back online or the tab is
* refocused. On SyncManager-capable browsers this is a no-op (the SW owns
* draining) so we don't double-process.
*
* Mirrors src/lib/payments/connection-listener.ts. Returns a cleanup function;
* call it on unmount.
*/
export function startFormQueueFallback(): () => void {
if (typeof window === 'undefined' || isBackgroundSyncSupported()) {
return () => {};
}

logger.debug('Starting foreground form-queue fallback (no SyncManager)');

const flush = () => {
void processQueue();
};

const handleOnline = () => {
logger.debug('online event — flushing form queue');
flush();
};
const handleVisibility = () => {
if (document.visibilityState === 'visible' && navigator.onLine) {
flush();
}
};

// Drain anything already queued from a previous offline session.
if (navigator.onLine) flush();

window.addEventListener('online', handleOnline);
document.addEventListener('visibilitychange', handleVisibility);

return () => {
window.removeEventListener('online', handleOnline);
document.removeEventListener('visibilitychange', handleVisibility);
};
}

/**
* Get sync status for debugging
*/
Expand Down
Loading