From 1f060ad0942579abb36c7c305e96c9c55dfec7ae Mon Sep 17 00:00:00 2001 From: Bhuvanesh S Date: Sat, 20 Jun 2026 22:47:43 +0530 Subject: [PATCH] feat(pwa): implement offline fallback page and improve service worker navigation handling --- app/offline/page.tsx | 15 ++++++++++ app/sw.ts | 31 ++++++++++++++++++-- components/pwa/OfflineFallback.tsx | 45 ++++++++++++++++++++++++++++++ tests/pwa/offline-page.test.tsx | 40 ++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 app/offline/page.tsx create mode 100644 components/pwa/OfflineFallback.tsx create mode 100644 tests/pwa/offline-page.test.tsx diff --git a/app/offline/page.tsx b/app/offline/page.tsx new file mode 100644 index 000000000..8ddf9d934 --- /dev/null +++ b/app/offline/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; +import OfflineFallback from '@/components/pwa/OfflineFallback'; + +export const metadata: Metadata = { + title: 'Offline | CommitPulse', + description: 'Connection lost. Please check your internet connection.', + robots: { + index: false, + follow: false, + }, +}; + +export default function OfflinePage() { + return ; +} diff --git a/app/sw.ts b/app/sw.ts index 34c5c77fd..ebf8a60fc 100644 --- a/app/sw.ts +++ b/app/sw.ts @@ -1,6 +1,6 @@ import { defaultCache } from '@serwist/next/worker'; import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'; -import { Serwist } from 'serwist'; +import { Serwist, NetworkFirst } from 'serwist'; // Extend the ServiceWorkerGlobalScope with Serwist's injected manifest declare global { @@ -22,7 +22,34 @@ const serwist = new Serwist({ // Prefetch responses while the browser handles navigation navigationPreload: true, - runtimeCaching: defaultCache, + runtimeCaching: [ + { + matcher({ request }) { + return request.mode === 'navigate'; + }, + handler: new NetworkFirst({ + cacheName: 'pages', + plugins: [ + { + async handlerDidError() { + return caches.match('/offline'); + }, + }, + ], + }), + }, + ...defaultCache, + ], + fallbacks: { + entries: [ + { + url: '/offline', + matcher({ request }) { + return request.mode === 'navigate'; + }, + }, + ], + }, }); serwist.addEventListeners(); diff --git a/components/pwa/OfflineFallback.tsx b/components/pwa/OfflineFallback.tsx new file mode 100644 index 000000000..881d23dd5 --- /dev/null +++ b/components/pwa/OfflineFallback.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useState } from 'react'; +import { WifiOff, RefreshCw } from 'lucide-react'; +import { useTranslation } from '@/context/TranslationContext'; + +export default function OfflineFallback() { + const { t } = useTranslation(); + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRetry = () => { + setIsRefreshing(true); + // Attempt reload + window.location.reload(); + }; + + return ( +
+
+
+ +
+ +

+ {t('offline.title', { defaultValue: 'Connection Lost' })} +

+ +

+ {t('offline.description', { + defaultValue: + 'You are currently offline. Check your internet connection and try refreshing the page.', + })} +

+ + +
+ ); +} diff --git a/tests/pwa/offline-page.test.tsx b/tests/pwa/offline-page.test.tsx new file mode 100644 index 000000000..1773a2560 --- /dev/null +++ b/tests/pwa/offline-page.test.tsx @@ -0,0 +1,40 @@ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import OfflineFallback from '@/components/pwa/OfflineFallback'; + +// Mock location.reload +const reloadMock = vi.fn(); +Object.defineProperty(window, 'location', { + value: { reload: reloadMock }, + writable: true, +}); + +describe('OfflineFallback Component', () => { + beforeEach(() => { + reloadMock.mockClear(); + }); + + it('renders connection lost header, description, and button', () => { + render(); + + expect(screen.getByText('Connection Lost')).toBeInTheDocument(); + expect( + screen.getByText( + 'You are currently offline. Check your internet connection and try refreshing the page.' + ) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + it('triggers page reload when try again button is clicked', () => { + render(); + + const button = screen.getByRole('button', { name: /try again/i }); + fireEvent.click(button); + + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(button).toBeDisabled(); + }); +});