diff --git a/src/components/forms/ContactForm/ContactForm.accessibility.test.tsx b/src/components/forms/ContactForm/ContactForm.accessibility.test.tsx index eff7da80..355e0acc 100644 --- a/src/components/forms/ContactForm/ContactForm.accessibility.test.tsx +++ b/src/components/forms/ContactForm/ContactForm.accessibility.test.tsx @@ -28,6 +28,7 @@ describe('ContactForm Accessibility', () => { successMessage: null, isOnline: true, wasQueuedOffline: false, + retryQueue: vi.fn(), }; beforeEach(() => { diff --git a/src/components/forms/ContactForm/ContactForm.test.tsx b/src/components/forms/ContactForm/ContactForm.test.tsx index 39d0b523..966e36bc 100644 --- a/src/components/forms/ContactForm/ContactForm.test.tsx +++ b/src/components/forms/ContactForm/ContactForm.test.tsx @@ -26,6 +26,7 @@ describe('ContactForm', () => { successMessage: null, isOnline: true, wasQueuedOffline: false, + retryQueue: vi.fn(), }; beforeEach(() => { diff --git a/src/hooks/useWeb3Forms.test.ts b/src/hooks/useWeb3Forms.test.ts index 33694f93..8edbf4e4 100644 --- a/src/hooks/useWeb3Forms.test.ts +++ b/src/hooks/useWeb3Forms.test.ts @@ -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, diff --git a/src/hooks/useWeb3Forms.ts b/src/hooks/useWeb3Forms.ts index e6727671..a97077e3 100644 --- a/src/hooks/useWeb3Forms.ts +++ b/src/hooks/useWeb3Forms.ts @@ -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'); @@ -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; } /** @@ -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 { @@ -283,5 +305,6 @@ export const useWeb3Forms = ( isOnline, queueCount, wasQueuedOffline, + retryQueue, }; }; diff --git a/src/utils/background-sync.test.ts b/src/utils/background-sync.test.ts new file mode 100644 index 00000000..9dd87c70 --- /dev/null +++ b/src/utils/background-sync.test.ts @@ -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(); + }); +}); diff --git a/src/utils/background-sync.ts b/src/utils/background-sync.ts index cbb1a932..507d747d 100644 --- a/src/utils/background-sync.ts +++ b/src/utils/background-sync.ts @@ -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 */