From 33874607c7bc9f9a4ea3d0793f383dce7b65306f Mon Sep 17 00:00:00 2001 From: jinidev Date: Fri, 16 Jan 2026 11:10:07 +0200 Subject: [PATCH] Revert "WS-1837: Service worker changes to render offline page" --- public/sw.js | 183 ++----------- .../ATIAnalytics/canonical/index.tsx | 2 - .../components/ServiceWorker/index.test.tsx | 107 ++++---- src/app/components/ServiceWorker/index.tsx | 18 +- .../hooks/useOfflinePageFlag/index.test.tsx | 41 --- src/app/hooks/useOfflinePageFlag/index.tsx | 22 -- .../usePWAOfflineTracking/index.test.tsx | 247 ------------------ src/app/hooks/usePWAOfflineTracking/index.tsx | 44 ---- src/app/hooks/useSendPWAStatus/index.test.tsx | 184 ------------- src/app/hooks/useSendPWAStatus/index.tsx | 38 --- .../index.test.tsx | 189 -------------- .../useServiceWorkerRegistration/index.tsx | 38 --- src/sw.test.js | 166 +----------- ws-nextjs-app/next.config.js | 16 -- .../[service]/offline/OfflinePage.test.tsx | 4 +- .../pages/[service]/offline/OfflinePage.tsx | 7 +- 16 files changed, 104 insertions(+), 1202 deletions(-) delete mode 100644 src/app/hooks/useOfflinePageFlag/index.test.tsx delete mode 100644 src/app/hooks/useOfflinePageFlag/index.tsx delete mode 100644 src/app/hooks/usePWAOfflineTracking/index.test.tsx delete mode 100644 src/app/hooks/usePWAOfflineTracking/index.tsx delete mode 100644 src/app/hooks/useSendPWAStatus/index.test.tsx delete mode 100644 src/app/hooks/useSendPWAStatus/index.tsx delete mode 100644 src/app/hooks/useServiceWorkerRegistration/index.test.tsx delete mode 100644 src/app/hooks/useServiceWorkerRegistration/index.tsx diff --git a/public/sw.js b/public/sw.js index 957a7a1a00a..4a72ed7a5e5 100644 --- a/public/sw.js +++ b/public/sw.js @@ -3,64 +3,19 @@ /* eslint-disable no-unused-vars */ /* eslint-disable no-undef */ /* eslint-disable no-restricted-globals */ - -const version = 'v0.3.2'; -// Update cache name when changing caching logic / changes in offlinepage.tsx -const cacheName = 'simorghCache_v2'; -const pwaClients = new Map(); -let isPWADeviceOffline = false; - -// -------------------- -// Helper Functions -// -------------------- -const loggerEnabled = true; -const logger = (...args) => { - if (!loggerEnabled) return; - // eslint-disable-next-line no-console - console.log(`[SW ${version}]`, ...args); -}; - -const getServiceFromUrl = url => new URL(url).pathname.split('/')[1]; -const getOfflinePageUrl = service => `/${service}/offline`; - -const cacheResource = async (cache, url) => { - try { - const response = await fetch(url); - if (response.ok) await cache.put(url, response.clone()); - return response; - } catch (err) { - logger(`Failed to cache ${url}:`, err); - return null; - } -}; - -const cacheOfflinePageAndResources = async service => { - const cache = await caches.open(cacheName); - const offlinePageUrl = new URL( - getOfflinePageUrl(service), - self.location.origin, - ).href; - - if (await cache.match(offlinePageUrl)) return; - - const resp = await cacheResource(cache, offlinePageUrl); - if (!resp || !resp.ok) return; - - logger(`Cached offline page for ${service}`); - - const html = await resp.text(); - const scriptSrcs = [ - ...html.matchAll(/]+src=["']([^"']+)["']/g), - ].map(m => m[1]); - const linkHrefs = [...html.matchAll(/]+href=["']([^"']+)["']/g)].map( - m => m[1], - ); - - const resources = [...scriptSrcs, ...linkHrefs].filter(Boolean); - - logger(`Caching final offline resources for ${service}:`, resources); - await Promise.allSettled(resources.map(url => cacheResource(cache, url))); -}; +const version = 'v0.3.0'; +const cacheName = 'simorghCache_v1'; + +const service = self.location.pathname.split('/')[1]; +const hasOfflinePageFunctionality = false; +const OFFLINE_PAGE = `/${service}/offline`; + +self.addEventListener('install', event => { + event.waitUntil(async () => { + const cache = await caches.open(cacheName); + if (hasOfflinePageFunctionality) await cache.add(OFFLINE_PAGE); + }); +}); const CACHEABLE_FILES = [ // Reverb @@ -80,60 +35,24 @@ const CACHEABLE_FILES = [ const WEBP_IMAGE = /^https:\/\/ichef(\.test)?\.bbci\.co\.uk\/(news|images|ace\/(standard|ws))\/.+.webp$/; -// -------------Install event ------- -self.addEventListener('install', () => { - logger(`Installing...`); - self.skipWaiting(); -}); - -// -------Activate Handler------------- -self.addEventListener('activate', event => { - logger(`Activating...`); - event.waitUntil( - (async () => { - const keys = await caches.keys(); - await Promise.all( - keys.map(key => key !== cacheName && caches.delete(key)), - ); - await self.clients.claim(); - })(), - ); -}); - -// -------Message Event------------- -self.addEventListener('message', async event => { - logger('Message received:', event.data); - - if (event.data?.type === 'PWA_STATUS') { - const clientId = event.source.id; - const { isPWA } = event.data; - - if (isPWA) { - pwaClients.set(clientId, true); - const service = getServiceFromUrl(event.source.url); - await cacheOfflinePageAndResources(service); - } - } -}); - -// -------Fetch Handler------------- const fetchEventHandler = async event => { - logger(`[SW FETCH] Request: ${event.request.url}`); const isRequestForCacheableFile = CACHEABLE_FILES.some(cacheableFile => new RegExp(cacheableFile).test(event.request.url), ); + const isRequestForWebpImage = WEBP_IMAGE.test(event.request.url); - const isNavigationMode = event.request.mode === 'navigate'; if (isRequestForWebpImage) { const req = event.request.clone(); // Inspect the accept header for WebP support + const supportsWebp = req.headers.has('accept') && req.headers.get('accept').includes('webp'); // if supports webp is false in request header then don't use it // if accept header doesn't indicate support for webp remove .webp extension + if (!supportsWebp) { const imageUrlWithoutWebp = req.url.replace('.webp', ''); event.respondWith( @@ -154,66 +73,22 @@ const fetchEventHandler = async event => { return response; })(), ); - } else if (isNavigationMode) { - const { url } = event.request; - - event.respondWith( - (async () => { - const client = await self.clients.get(event.clientId); - const isPWA = client && pwaClients.get(client.id); - const cache = await caches.open(cacheName); - - // TODO: Used for testing - to be removed - logger('📌 [SW FETCH] Navigation', { - url: event.request.url, - clientId: event.clientId, - isPWA, - client, - event, - pwaClients, - isPWADeviceOffline, - }); - try { - // Use preload if available - const preloadResp = await event.preloadResponse; - if (preloadResp) return preloadResp; - - const networkResp = await fetch(event.request); - isPWADeviceOffline = false; - return networkResp; - } catch (err) { - // Only show offline page for installed PWA - if (isPWA) { - const service = getServiceFromUrl(url); - const offlineUrl = new URL( - getOfflinePageUrl(service), - self.location.origin, - ).href; - - const cachedOffline = await cache.match(offlineUrl); - if (cachedOffline) { - isPWADeviceOffline = true; - logger('[SW] Serving cached offline page'); - return cachedOffline; - } - } - // fallback to browser default behavior - throw err; + } else if (hasOfflinePageFunctionality && event.request.mode === 'navigate') { + event.respondWith(async () => { + try { + const preloadResponse = await event.preloadResponse; + if (preloadResponse) { + return preloadResponse; } - })(), - ); - } else if (isPWADeviceOffline) { - logger(`[SW v${version}] Serving isPWADeviceOffline ${event.request.url}`); - event.respondWith( - (async () => { + const networkResponse = await fetch(event.request); + return networkResponse; + } catch (error) { const cache = await caches.open(cacheName); - const cached = await cache.match(event.request); - if (cached) return cached; - return fetch(event.request); - })(), - ); + const cachedResponse = await cache.match(OFFLINE_PAGE); + return cachedResponse; + } + }); } - return; }; diff --git a/src/app/components/ATIAnalytics/canonical/index.tsx b/src/app/components/ATIAnalytics/canonical/index.tsx index db040c549ca..c3185f3311f 100644 --- a/src/app/components/ATIAnalytics/canonical/index.tsx +++ b/src/app/components/ATIAnalytics/canonical/index.tsx @@ -13,7 +13,6 @@ import usePWAInstallTracker from '#app/hooks/usePWAInstallTracker'; import { reverbUrlHelper } from '@bbc/reverb-url-helper'; import useConnectionBackOnlineTracker from '#app/hooks/useConnectionBackOnlineTracker'; import useConnectionTypeTracker from '#app/hooks/useConnectionTypeTracker'; -import usePWAOfflineTracking from '#app/hooks/usePWAOfflineTracking'; import { ATIAnalyticsProps } from '../types'; import getNoScriptTrackingPixelUrl from './getNoScriptTrackingPixelUrl'; import sendPageViewBeaconOperaMini from './sendPageViewBeaconOperaMini'; @@ -48,7 +47,6 @@ const CanonicalATIAnalytics = ({ reverbParams }: ATIAnalyticsProps) => { useConnectionTypeTracker(); useConnectionBackOnlineTracker(); - usePWAOfflineTracking(); const [reverbBeaconConfig] = useState(reverbParams); diff --git a/src/app/components/ServiceWorker/index.test.tsx b/src/app/components/ServiceWorker/index.test.tsx index 267775f34ca..1b3f325039a 100644 --- a/src/app/components/ServiceWorker/index.test.tsx +++ b/src/app/components/ServiceWorker/index.test.tsx @@ -1,38 +1,79 @@ -import useServiceWorkerRegistration from '#app/hooks/useServiceWorkerRegistration'; -import useSendPWAStatus from '#app/hooks/useSendPWAStatus'; -import useIsPWA from '#app/hooks/useIsPWA'; +import onClient from '#app/lib/utilities/onClient'; import isLocal from '#app/lib/utilities/isLocal'; -import ServiceWorkerContainer from './index'; -import { ServiceContext } from '../../contexts/ServiceContext'; import { render } from '../react-testing-library-with-providers'; - -jest.mock('#app/hooks/useServiceWorkerRegistration', () => jest.fn()); -jest.mock('#app/hooks/useSendPWAStatus', () => jest.fn()); -jest.mock('#app/hooks/useIsPWA', () => jest.fn()); -jest.mock('#app/lib/utilities/isLocal', () => jest.fn()); +import { ServiceContext } from '../../contexts/ServiceContext'; +import ServiceWorkerContainer from './index'; const contextStub = { - swPath: '/news/sw.js', + swPath: '/articles/sw.js', service: 'news', }; -describe('ServiceWorkerContainer', () => { - beforeEach(() => { - jest.clearAllMocks(); - (useIsPWA as jest.Mock).mockReturnValue(false); - (isLocal as jest.Mock).mockReturnValue(true); +const mockServiceWorker = { + register: jest.fn(), +}; + +jest.mock('#app/lib/utilities/onClient', () => + jest.fn().mockImplementation(() => true), +); + +jest.mock('#app/lib/utilities/isLocal', () => + jest.fn().mockImplementation(() => true), +); + +describe('Service Worker', () => { + const originalNavigator = global.navigator; + + afterEach(() => { + jest.resetAllMocks(); + + global.navigator ??= originalNavigator; }); describe('Canonical', () => { - it('calls service worker registration hook with service', () => { + it('is registered when swPath, serviceWorker have values and onClient is true', () => { + // @ts-expect-error need to override the navigator.serviceWorker for testing purposes + global.navigator.serviceWorker = mockServiceWorker; + (onClient as jest.Mock).mockImplementationOnce(() => true); + render( // @ts-expect-error only require a subset of properties on service context for testing purposes - + , ); + expect(navigator.serviceWorker.register).toHaveBeenCalledWith( + `/news/articles/sw.js`, + ); + }); + + describe('is not registered', () => { + it.each` + swPath | serviceWorker | isOnClient + ${undefined} | ${undefined} | ${true} + ${undefined} | ${undefined} | ${false} + ${undefined} | ${mockServiceWorker} | ${true} + ${undefined} | ${mockServiceWorker} | ${false} + ${contextStub.swPath} | ${mockServiceWorker} | ${false} + `( + 'when swPath is $swPath, serviceWorker is $serviceWorker and isOnClient is $isOnClient', + ({ swPath, serviceWorker, isOnClient }) => { + if (serviceWorker) { + // @ts-expect-error need to override the navigator.serviceWorker for testing purposes + global.navigator.serviceWorker = serviceWorker; + } - expect(useServiceWorkerRegistration).toHaveBeenCalledWith('news'); + (onClient as jest.Mock).mockImplementationOnce(() => isOnClient); + + render( + // @ts-expect-error only require a subset of properties on service context for testing purposes + + + , + ); + expect(navigator.serviceWorker.register).not.toHaveBeenCalled(); + }, + ); }); }); @@ -79,32 +120,4 @@ describe('ServiceWorkerContainer', () => { ); }); }); - - describe('PWA', () => { - it('calls useSendPWAStatus with true when PWA is installed', () => { - (useIsPWA as jest.Mock).mockReturnValue(true); - - render( - // @ts-expect-error only require a subset of properties on service context for testing purposes - - - , - ); - - expect(useSendPWAStatus).toHaveBeenCalledWith(true); - }); - - it('calls useSendPWAStatus with false when PWA is not installed', () => { - (useIsPWA as jest.Mock).mockReturnValue(false); - - render( - // @ts-expect-error only require a subset of properties on service context for testing purposes - - - , - ); - - expect(useSendPWAStatus).toHaveBeenCalledWith(false); - }); - }); }); diff --git a/src/app/components/ServiceWorker/index.tsx b/src/app/components/ServiceWorker/index.tsx index 29608b8e391..33e26b216f1 100644 --- a/src/app/components/ServiceWorker/index.tsx +++ b/src/app/components/ServiceWorker/index.tsx @@ -1,8 +1,6 @@ -import { use } from 'react'; -import useIsPWA from '#app/hooks/useIsPWA'; -import useSendPWAStatus from '#app/hooks/useSendPWAStatus'; -import useServiceWorkerRegistration from '#app/hooks/useServiceWorkerRegistration'; +import { use, useEffect } from 'react'; import { Helmet } from 'react-helmet'; +import onClient from '#lib/utilities/onClient'; import { RequestContext } from '#contexts/RequestContext'; import { getEnvConfig } from '#app/lib/utilities/getEnvConfig'; import { ServiceContext } from '../../contexts/ServiceContext'; @@ -37,11 +35,15 @@ export default () => { const { swPath, service } = use(ServiceContext); const { isAmp, canonicalLink } = use(RequestContext); const swSrc = `${getEnvConfig().SIMORGH_BASE_URL}/${service}${swPath}`; - const isPWA = useIsPWA(); - useServiceWorkerRegistration(service); - // Send PWA status to service worker - useSendPWAStatus(isPWA); + useEffect(() => { + const shouldInstallServiceWorker = + swPath && onClient() && 'serviceWorker' in navigator; + + if (shouldInstallServiceWorker) { + navigator.serviceWorker.register(`/${service}${swPath}`); + } + }, [swPath, service]); return isAmp && swPath ? ( <> diff --git a/src/app/hooks/useOfflinePageFlag/index.test.tsx b/src/app/hooks/useOfflinePageFlag/index.test.tsx deleted file mode 100644 index 5160c61df49..00000000000 --- a/src/app/hooks/useOfflinePageFlag/index.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { renderHook as renderSSRHook } from '@testing-library/react-hooks/server'; -import { useOfflinePageFlag, OFFLINE_VISIT_FLAG } from './index'; - -describe('useOfflinePageFlag', () => { - beforeEach(() => { - Storage.prototype.setItem = jest.fn(); - Storage.prototype.getItem = jest.fn(); - Storage.prototype.removeItem = jest.fn(); - jest.clearAllMocks(); - }); - - it('should set offline flag when rendered', () => { - renderHook(() => useOfflinePageFlag()); - - expect(localStorage.setItem).toHaveBeenCalledWith( - OFFLINE_VISIT_FLAG, - 'true', - ); - }); - - it('should handle localStorage errors gracefully', () => { - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); - - Storage.prototype.setItem = jest.fn().mockImplementation(() => { - throw new Error('localStorage is full'); - }); - - expect(() => renderHook(() => useOfflinePageFlag())).not.toThrow(); - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'useOfflinePageFlag', - expect.any(Error), - ); - }); - - it('should not set flag on server side', () => { - renderSSRHook(() => useOfflinePageFlag()); - - expect(localStorage.setItem).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/hooks/useOfflinePageFlag/index.tsx b/src/app/hooks/useOfflinePageFlag/index.tsx deleted file mode 100644 index 0a79b5dca6d..00000000000 --- a/src/app/hooks/useOfflinePageFlag/index.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect } from 'react'; - -const OFFLINE_VISIT_FLAG = 'offline_page_visit'; - -/** - * Sets a flag in localStorage when user visits the offline page. - * Note: Offline page is only accessible in PWA mode (via service worker), - * so no need to check isPWA - if this hook runs, we're already in PWA. - */ -const useOfflinePageFlag = () => { - useEffect(() => { - if (typeof window === 'undefined') return; - try { - localStorage.setItem(OFFLINE_VISIT_FLAG, 'true'); - } catch (error) { - // eslint-disable-next-line no-console - console.warn('useOfflinePageFlag', error); - } - }, []); -}; - -export { useOfflinePageFlag, OFFLINE_VISIT_FLAG }; diff --git a/src/app/hooks/usePWAOfflineTracking/index.test.tsx b/src/app/hooks/usePWAOfflineTracking/index.test.tsx deleted file mode 100644 index 6bffb85cc94..00000000000 --- a/src/app/hooks/usePWAOfflineTracking/index.test.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { renderHook as renderSSRHook } from '@testing-library/react-hooks/server'; -import { EffectiveNetworkType } from '#app/models/types/global'; -import usePWAOfflineTracking from './index'; -import useNetworkStatusTracker from '../useNetworkStatusTracker'; -import useCustomEventTracker from '../useCustomEventTracker'; - -jest.mock('../useNetworkStatusTracker'); -jest.mock('../useCustomEventTracker'); - -describe('usePWAOfflineTracking', () => { - const mockTrackOfflinePageViewEvent = jest.fn(); - const mockUseNetworkStatusTracker = - useNetworkStatusTracker as jest.MockedFunction< - typeof useNetworkStatusTracker - >; - const mockUseCustomEventTracker = - useCustomEventTracker as jest.MockedFunction; - - beforeEach(() => { - jest.clearAllMocks(); - Storage.prototype.getItem = jest.fn(); - Storage.prototype.setItem = jest.fn(); - Storage.prototype.removeItem = jest.fn(); - - mockUseCustomEventTracker.mockReturnValue(mockTrackOfflinePageViewEvent); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should not fire event when offline flag is not set', () => { - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: true, - networkType: '4g', - }); - Storage.prototype.getItem = jest.fn().mockReturnValue(null); - - renderHook(() => usePWAOfflineTracking()); - - expect(mockTrackOfflinePageViewEvent).not.toHaveBeenCalled(); - }); - - it('should fire event when online and flag is set', () => { - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: true, - networkType: '4g', - }); - Storage.prototype.getItem = jest.fn().mockReturnValue('true'); - - renderHook(() => usePWAOfflineTracking()); - - expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); - expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledWith('4g'); - expect(localStorage.removeItem).toHaveBeenCalledWith('offline_page_visit'); - }); - - it('should fire event on offline→online transition', () => { - Storage.prototype.getItem = jest.fn().mockReturnValue('true'); - - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: false, - networkType: 'unknown', - }); - - const { rerender } = renderHook(() => usePWAOfflineTracking()); - - expect(mockTrackOfflinePageViewEvent).not.toHaveBeenCalled(); - - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: true, - networkType: '4g', - }); - - rerender(); - - expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); - expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledWith('4g'); - expect(localStorage.removeItem).toHaveBeenCalledWith('offline_page_visit'); - }); - - it('should not fire event again without flag being set', () => { - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: true, - networkType: '4g', - }); - const mockGetItem = jest - .fn() - .mockReturnValueOnce('true') - .mockReturnValue(null); - Storage.prototype.getItem = mockGetItem; - - const { rerender } = renderHook(() => usePWAOfflineTracking()); - - expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); - - rerender(); - - expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); - }); - - it('should fire event again after flag is set again on next offline visit', () => { - const mockGetItem = jest - .fn() - .mockReturnValueOnce('true') - .mockReturnValueOnce('true'); - Storage.prototype.getItem = mockGetItem; - - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: false, - networkType: 'unknown', - }); - - const { rerender } = renderHook(() => usePWAOfflineTracking()); - - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: true, - networkType: '4g', - }); - - rerender(); - - expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); - expect(localStorage.removeItem).toHaveBeenCalledWith('offline_page_visit'); - - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: false, - networkType: 'unknown', - }); - - rerender(); - - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: true, - networkType: '5g', - }); - - rerender(); - - expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(2); - expect(mockTrackOfflinePageViewEvent).toHaveBeenLastCalledWith('5g'); - expect(localStorage.removeItem).toHaveBeenCalledTimes(2); - }); - - it('should remove flag after firing event', () => { - Storage.prototype.getItem = jest.fn().mockReturnValue('true'); - - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: false, - networkType: 'unknown', - }); - - const { rerender } = renderHook(() => usePWAOfflineTracking()); - - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: true, - networkType: '4g', - }); - - rerender(); - - expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); - expect(localStorage.removeItem).toHaveBeenCalledWith('offline_page_visit'); - }); - - it('should not fire when offline even if flag is set', () => { - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: false, - networkType: 'unknown', - }); - Storage.prototype.getItem = jest.fn().mockReturnValue('true'); - - renderHook(() => usePWAOfflineTracking()); - - expect(mockTrackOfflinePageViewEvent).not.toHaveBeenCalled(); - }); - - it('should handle localStorage errors gracefully', () => { - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: true, - networkType: '4g', - }); - - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - Storage.prototype.getItem = jest.fn().mockImplementation(() => { - throw new Error('localStorage access denied'); - }); - - expect(() => renderHook(() => usePWAOfflineTracking())).not.toThrow(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'usePWAOfflineTracking', - expect.any(Error), - ); - expect(mockTrackOfflinePageViewEvent).not.toHaveBeenCalled(); - }); - - it('should not track on server side', () => { - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: true, - networkType: '4g', - }); - Storage.prototype.getItem = jest.fn().mockReturnValue('true'); - - renderSSRHook(() => usePWAOfflineTracking()); - - expect(mockTrackOfflinePageViewEvent).not.toHaveBeenCalled(); - }); - - it.each(['slow-2g', '2g', '3g', '4g', '5g', 'unknown'])( - 'should pass correct network type to tracking function: %s', - networkType => { - Storage.prototype.getItem = jest.fn().mockReturnValue('true'); - - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: true, - networkType: networkType as EffectiveNetworkType, - }); - - renderHook(() => usePWAOfflineTracking()); - - expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledWith(networkType); - }, - ); - - it('should only fire on actual offline→online transition, not online→offline', () => { - Storage.prototype.getItem = jest.fn().mockReturnValue('true'); - - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: true, - networkType: '4g', - }); - - const { rerender } = renderHook(() => usePWAOfflineTracking()); - - expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); - - mockUseNetworkStatusTracker.mockReturnValue({ - isOnline: false, - networkType: 'unknown', - }); - - rerender(); - - expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/app/hooks/usePWAOfflineTracking/index.tsx b/src/app/hooks/usePWAOfflineTracking/index.tsx deleted file mode 100644 index 050793c4b2b..00000000000 --- a/src/app/hooks/usePWAOfflineTracking/index.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useEffect } from 'react'; -import useNetworkStatusTracker from '../useNetworkStatusTracker'; -import useCustomEventTracker from '../useCustomEventTracker'; -import { OFFLINE_VISIT_FLAG } from '../useOfflinePageFlag'; - -const OFFLINE_PAGE_VIEW_EVENT_NAME = 'pwa-offline-page-view'; - -/** - * Tracks offline→online transitions after user has visited offline page. - * Fires when network comes back online while flag is set. - * - * Flag can only be set in PWA mode (offline page requires service worker). - * By not checking isPWA at dispatch time, we track each offline session separately - * even if user switches between PWA/browser modes during reconnection. - * This prevents data loss and ensures accurate analytics. - */ -const usePWAOfflineTracking = () => { - const { isOnline, networkType } = useNetworkStatusTracker(); - - const trackOfflinePageViewEvent = useCustomEventTracker({ - eventName: OFFLINE_PAGE_VIEW_EVENT_NAME, - }); - - useEffect(() => { - if (typeof window === 'undefined' || !isOnline) { - return; - } - try { - const offlineVisitFlag = localStorage.getItem(OFFLINE_VISIT_FLAG); - - if (offlineVisitFlag !== 'true') { - return; - } - - trackOfflinePageViewEvent(networkType); - localStorage.removeItem(OFFLINE_VISIT_FLAG); - } catch (error) { - // eslint-disable-next-line no-console - console.error('usePWAOfflineTracking', error); - } - }, [isOnline, networkType, trackOfflinePageViewEvent]); -}; - -export default usePWAOfflineTracking; diff --git a/src/app/hooks/useSendPWAStatus/index.test.tsx b/src/app/hooks/useSendPWAStatus/index.test.tsx deleted file mode 100644 index e88c893eab6..00000000000 --- a/src/app/hooks/useSendPWAStatus/index.test.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { renderHook as renderSSRHook } from '@testing-library/react-hooks/server'; -import useSendPWAStatus from './index'; - -describe('useSendPWAStatus', () => { - const mockPostMessage = jest.fn(); - const mockAddEventListener = jest.fn(); - const mockRemoveEventListener = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - - Object.defineProperty(global, 'navigator', { - value: { - serviceWorker: { - controller: { - postMessage: mockPostMessage, - state: 'activated', - }, - ready: Promise.resolve(), - addEventListener: mockAddEventListener, - removeEventListener: mockRemoveEventListener, - }, - }, - writable: true, - configurable: true, - }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should send PWA status message when SW is ready and isPWA is true', async () => { - renderHook(() => useSendPWAStatus(true)); - - await Promise.resolve(); - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'PWA_STATUS', - isPWA: true, - }); - }); - - it('should send PWA status message when SW is ready and isPWA is false', async () => { - renderHook(() => useSendPWAStatus(false)); - - await Promise.resolve(); - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'PWA_STATUS', - isPWA: false, - }); - }); - - it('should add controllerchange event listener', () => { - renderHook(() => useSendPWAStatus(true)); - - expect(mockAddEventListener).toHaveBeenCalledWith( - 'controllerchange', - expect.any(Function), - ); - }); - - it('should remove controllerchange event listener on unmount', () => { - const { unmount } = renderHook(() => useSendPWAStatus(true)); - - unmount(); - - expect(mockRemoveEventListener).toHaveBeenCalledWith( - 'controllerchange', - expect.any(Function), - ); - }); - - it('should not send message when serviceWorker is not available', () => { - Object.defineProperty(global, 'navigator', { - value: {}, - writable: true, - configurable: true, - }); - - renderHook(() => useSendPWAStatus(true)); - - expect(mockPostMessage).not.toHaveBeenCalled(); - }); - - it('should not send message when controller is not activated', async () => { - Object.defineProperty(global, 'navigator', { - value: { - serviceWorker: { - controller: { - postMessage: mockPostMessage, - state: 'installing', - }, - ready: Promise.resolve(), - addEventListener: mockAddEventListener, - removeEventListener: mockRemoveEventListener, - }, - }, - writable: true, - configurable: true, - }); - - renderHook(() => useSendPWAStatus(true)); - - await Promise.resolve(); - - expect(mockPostMessage).not.toHaveBeenCalled(); - }); - - it('should not send message when controller is null', async () => { - Object.defineProperty(global, 'navigator', { - value: { - serviceWorker: { - controller: null, - ready: Promise.resolve(), - addEventListener: mockAddEventListener, - removeEventListener: mockRemoveEventListener, - }, - }, - writable: true, - configurable: true, - }); - - renderHook(() => useSendPWAStatus(true)); - - await Promise.resolve(); - - expect(mockPostMessage).not.toHaveBeenCalled(); - }); - - it('should send message on controllerchange event', async () => { - let controllerChangeHandler: (() => void) | undefined; - - mockAddEventListener.mockImplementation( - (event: string, handler: () => void) => { - if (event === 'controllerchange') { - controllerChangeHandler = handler; - } - }, - ); - - renderHook(() => useSendPWAStatus(true)); - - await Promise.resolve(); - mockPostMessage.mockClear(); - - expect(controllerChangeHandler).toBeDefined(); - (controllerChangeHandler as () => void)(); - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'PWA_STATUS', - isPWA: true, - }); - }); - - it('should update message when isPWA prop changes', async () => { - const { rerender } = renderHook(({ isPWA }) => useSendPWAStatus(isPWA), { - initialProps: { isPWA: false }, - }); - - await Promise.resolve(); - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'PWA_STATUS', - isPWA: false, - }); - - mockPostMessage.mockClear(); - rerender({ isPWA: true }); - - await Promise.resolve(); - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'PWA_STATUS', - isPWA: true, - }); - }); - - it('should handle server-side rendering gracefully', () => { - renderSSRHook(() => useSendPWAStatus(true)); - - expect(mockPostMessage).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/hooks/useSendPWAStatus/index.tsx b/src/app/hooks/useSendPWAStatus/index.tsx deleted file mode 100644 index 540d889ed25..00000000000 --- a/src/app/hooks/useSendPWAStatus/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useEffect } from 'react'; - -const useSendPWAStatus = (isPWA: boolean) => { - useEffect(() => { - // Service workers not available - exit . - if (typeof window === 'undefined' || !navigator.serviceWorker) { - return; - } - - const sendPWAStatus = () => { - const sw = navigator.serviceWorker; - - if (sw.controller && sw.controller.state === 'activated') { - sw.controller.postMessage({ - type: 'PWA_STATUS', - isPWA, - }); - } - }; - - const sw = navigator.serviceWorker; - - // if SW ready - if (sw.ready && typeof sw.ready.then === 'function') { - sw.ready.then(sendPWAStatus); - } - - // Listen for SW taking control - sw.addEventListener('controllerchange', sendPWAStatus); - - // eslint-disable-next-line consistent-return - return () => { - sw.removeEventListener('controllerchange', sendPWAStatus); - }; - }, [isPWA]); -}; - -export default useSendPWAStatus; diff --git a/src/app/hooks/useServiceWorkerRegistration/index.test.tsx b/src/app/hooks/useServiceWorkerRegistration/index.test.tsx deleted file mode 100644 index 956e69db7ef..00000000000 --- a/src/app/hooks/useServiceWorkerRegistration/index.test.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { renderHook as renderSSRHook } from '@testing-library/react-hooks/server'; -import useServiceWorkerRegistration from './index'; - -describe('useServiceWorkerRegistration', () => { - const mockRegister = jest.fn(); - let consoleWarnSpy: jest.SpyInstance; - let consoleErrorSpy: jest.SpyInstance; - - beforeEach(() => { - jest.clearAllMocks(); - consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); - consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - - Object.defineProperty(global, 'navigator', { - value: { - serviceWorker: { - register: mockRegister, - }, - }, - writable: true, - configurable: true, - }); - - mockRegister.mockResolvedValue({}); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should register service worker with correct path when service is provided', () => { - renderHook(() => useServiceWorkerRegistration('mundo')); - - expect(mockRegister).toHaveBeenCalledWith('/mundo/sw.js'); - }); - - it('should register service worker for different services', () => { - const { rerender } = renderHook( - ({ service }) => useServiceWorkerRegistration(service), - { - initialProps: { service: 'news' }, - }, - ); - - expect(mockRegister).toHaveBeenCalledWith('/news/sw.js'); - - mockRegister.mockClear(); - rerender({ service: 'sport' }); - - expect(mockRegister).toHaveBeenCalledWith('/sport/sw.js'); - }); - - it('should not register service worker when service is undefined', () => { - renderHook(() => useServiceWorkerRegistration(undefined)); - - expect(mockRegister).not.toHaveBeenCalled(); - }); - - it('should not register service worker when service is empty string', () => { - renderHook(() => useServiceWorkerRegistration('')); - - expect(mockRegister).not.toHaveBeenCalled(); - }); - - it('should not register when serviceWorker is not supported', () => { - Object.defineProperty(global, 'navigator', { - value: {}, - writable: true, - configurable: true, - }); - - renderHook(() => useServiceWorkerRegistration('mundo')); - - expect(mockRegister).not.toHaveBeenCalled(); - }); - - it('should warn and not register when register is not a function', () => { - Object.defineProperty(global, 'navigator', { - value: { - serviceWorker: { - register: null, - }, - }, - writable: true, - configurable: true, - }); - - renderHook(() => useServiceWorkerRegistration('mundo')); - - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'ServiceWorker API exists but register() is not available.', - ); - expect(mockRegister).not.toHaveBeenCalled(); - }); - - it('should handle registration errors gracefully', async () => { - const error = new Error('Registration failed'); - mockRegister.mockRejectedValue(error); - - renderHook(() => useServiceWorkerRegistration('mundo')); - - await Promise.resolve(); - await Promise.resolve(); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Service worker registration failed:', - error, - ); - }); - - it('should handle server-side rendering gracefully', () => { - renderSSRHook(() => useServiceWorkerRegistration('mundo')); - - expect(mockRegister).not.toHaveBeenCalled(); - }); - - it('should register for new service when service prop changes', () => { - const { rerender } = renderHook( - ({ service }) => useServiceWorkerRegistration(service), - { - initialProps: { service: 'mundo' }, - }, - ); - - expect(mockRegister).toHaveBeenCalledTimes(1); - expect(mockRegister).toHaveBeenCalledWith('/mundo/sw.js'); - - mockRegister.mockClear(); - rerender({ service: 'news' }); - - expect(mockRegister).toHaveBeenCalledTimes(1); - expect(mockRegister).toHaveBeenCalledWith('/news/sw.js'); - }); - - it('should not register again when service prop stays the same', () => { - const { rerender } = renderHook( - ({ service }) => useServiceWorkerRegistration(service), - { - initialProps: { service: 'mundo' }, - }, - ); - - expect(mockRegister).toHaveBeenCalledTimes(1); - - mockRegister.mockClear(); - rerender({ service: 'mundo' }); - - expect(mockRegister).not.toHaveBeenCalled(); - }); - - it('should handle registration promise that resolves with registration object', async () => { - const mockRegistration = { - installing: null, - waiting: null, - active: { state: 'activated' }, - }; - mockRegister.mockResolvedValue(mockRegistration); - - renderHook(() => useServiceWorkerRegistration('mundo')); - - expect(mockRegister).toHaveBeenCalledWith('/mundo/sw.js'); - - await Promise.resolve(); - - expect(consoleErrorSpy).not.toHaveBeenCalled(); - }); - - it('should handle synchronous registration error', async () => { - const error = new Error('Sync registration failed'); - mockRegister.mockImplementation(() => { - const rejection = Promise.reject(error); - rejection.catch(() => { - // Suppress unhandled rejection - }); - return rejection; - }); - - renderHook(() => useServiceWorkerRegistration('mundo')); - - await Promise.resolve(); - await Promise.resolve(); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Service worker registration failed:', - error, - ); - }); -}); diff --git a/src/app/hooks/useServiceWorkerRegistration/index.tsx b/src/app/hooks/useServiceWorkerRegistration/index.tsx deleted file mode 100644 index 2d4c0b0f6a5..00000000000 --- a/src/app/hooks/useServiceWorkerRegistration/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useEffect } from 'react'; -import onClient from '#lib/utilities/onClient'; - -const useServiceWorkerRegistration = (service?: string) => { - useEffect(() => { - // Exit if SW API is not available or service is missing - if (!onClient() || !('serviceWorker' in navigator) || !service) { - return; - } - - const sw = navigator.serviceWorker; - - // If register is not a function, skip - if (typeof sw.register !== 'function') { - // eslint-disable-next-line no-console - console.warn('ServiceWorker API exists but register() is not available.'); - return; - } - const shouldInstallServiceWorker = - onClient() && 'serviceWorker' in navigator; - - if (shouldInstallServiceWorker) { - // TODO: scope option to be used once Service-Worker-Allowed header is whitelisted - // const result = sw.register(`/${service}/sw.js`, { - // scope: `/${service}`, - // }); - - const result = sw.register(`/${service}/sw.js`); - - Promise.resolve(result).catch(err => { - // eslint-disable-next-line no-console - console.error('Service worker registration failed:', err); - }); - } - }, [service]); -}; - -export default useServiceWorkerRegistration; diff --git a/src/sw.test.js b/src/sw.test.js index d64d96b2b7b..fec58521761 100644 --- a/src/sw.test.js +++ b/src/sw.test.js @@ -19,19 +19,6 @@ fs.writeFileSync( describe('Service Worker', () => { let fetchEventHandler; - beforeAll(() => { - Object.defineProperty(navigator, 'serviceWorker', { - value: { - ready: Promise.resolve({}), - addEventListener: jest.fn(), - controller: null, - register: jest.fn().mockResolvedValue({}), - location: { origin: 'https://bbc.com' }, - }, - configurable: true, - }); - }); - afterEach(() => { jest.clearAllMocks(); fetchMock.resetMocks(); @@ -286,159 +273,10 @@ describe('Service Worker', () => { }); }); - describe('PWA offline page caching (message event)', () => { - let messageHandler; - let cachePut; - - beforeEach(async () => { - jest.resetModules(); - cachePut = jest.fn(); - - global.self = { - addEventListener: jest.fn(), - location: { origin: 'https://bbc.com' }, - }; - - global.caches = { - open: jest.fn(() => - Promise.resolve({ - match: jest.fn().mockResolvedValue(null), - put: cachePut, - delete: jest.fn(), - }), - ), - }; - - await import('./service-worker-test'); - - // extract the message handler registered by sw.js - // eslint-disable-next-line prefer-destructuring - messageHandler = self.addEventListener.mock.calls.find( - ([eventName]) => eventName === 'message', - )[1]; - }); - - it('caches offline page when PWA is installed', async () => { - fetchMock.mockResolvedValueOnce( - new Response('', { status: 200 }), - ); - - const event = { - data: { type: 'PWA_STATUS', isPWA: true }, - source: { - id: 'client-1', - url: 'https://bbc.com/mundo', - }, - }; - - await messageHandler(event); - - expect(cachePut).toHaveBeenCalledWith( - expect.stringContaining('/offline'), - expect.any(Response), - ); - expect(fetchMock).toHaveBeenCalledWith('https://bbc.com/mundo/offline'); - }); - - it('does not cache offline page when PWA is not installed', async () => { - await import('./service-worker-test'); - - await messageHandler({ - data: { type: 'PWA_STATUS', isPWA: false }, - source: { - id: 'client-1', - url: 'https://bbc.com/mundo', - }, - }); - - expect(cachePut).not.toHaveBeenCalledWith( - expect.stringContaining('/offline'), - expect.any(Response), - ); - }); - }); - - describe('Offline navigation handling in PWA mode', () => { - let messageHandler; - - beforeEach(async () => { - jest.resetModules(); - fetchMock.resetMocks(); - - global.self = { - addEventListener: jest.fn(), - location: { origin: 'https://bbc.com' }, - clients: { - get: jest.fn(() => - Promise.resolve({ - id: 'client-1', - url: 'https://bbc.com/mundo', - }), - ), - }, - }; - - // Mock cache with offline page - const offlineResponse = new Response('offline page'); - const mockCache = { - match: jest.fn(url => - url.includes('/mundo/offline') - ? Promise.resolve(offlineResponse) - : Promise.resolve(null), - ), - put: jest.fn(), - delete: jest.fn(), - }; - global.caches = { - open: jest.fn(() => Promise.resolve(mockCache)), - }; - - ({ fetchEventHandler } = await import('./service-worker-test')); - - // eslint-disable-next-line prefer-destructuring - messageHandler = self.addEventListener.mock.calls.find( - ([eventName]) => eventName === 'message', - )[1]; - }); - - it('returns cached offline page when navigation fails and PWA is installed', async () => { - await messageHandler({ - data: { type: 'PWA_STATUS', isPWA: true }, - source: { - id: 'client-1', - url: 'https://bbc.com/mundo', - }, - }); - - // Network failure - fetchMock.mockRejectedValueOnce(new Error('Network error')); - - const request = new Request('https://bbc.com/mundo'); - Object.defineProperty(request, 'mode', { value: 'navigate' }); - - let respondWithPromise; - - const event = { - request, - clientId: 'client-1', - preloadResponse: Promise.resolve(undefined), - respondWith: jest.fn(p => { - respondWithPromise = p; - }), - }; - - await fetchEventHandler(event); - - expect(event.respondWith).toHaveBeenCalled(); - const response = await respondWithPromise; - expect(await response.text()).toBe('offline page'); - }); - }); - describe('version', () => { const CURRENT_VERSION = { - number: 'v0.3.2', - fileContentHash: '1d1d60cfe48f1f2df1916daff4434193', + number: 'v0.3.0', + fileContentHash: '6150d6daf3d64a226a47e17b39dfc084', }; it(`version number should be ${CURRENT_VERSION.number}`, async () => { diff --git a/ws-nextjs-app/next.config.js b/ws-nextjs-app/next.config.js index 5e00e578188..b75124de3b9 100644 --- a/ws-nextjs-app/next.config.js +++ b/ws-nextjs-app/next.config.js @@ -24,22 +24,6 @@ module.exports = { }, ], }, - //* *TODO: */ Service worker headers — commented out, as this will be handled as part of future work in this ticket https://bbc.atlassian.net/browse/WS-2004. - - // { - // source: '/:service/sw.js', - // headers: [ - // { - // key: 'Service-Worker-Allowed', - // value: '/:service', - // }, - // { - // key: 'Cache-Control', - // value: 'public, max-age=0, must-revalidate', - // }, - // { key: 'Content-Type', value: 'application/javascript' }, - // ], - // }, ]; }, async rewrites() { diff --git a/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx b/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx index c2a55189f8b..e24431f1d65 100644 --- a/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx +++ b/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx @@ -22,7 +22,7 @@ describe('OfflinePage', () => { expect( screen.getByText( - 'Looks like you’re not online right now. Please check your network and reconnect. Once you’re back, just refresh the page to continue.', + "It seems you don't have an internet connection at the moment. Please check your connection and reload the page.", ), ).toBeInTheDocument(); }); @@ -48,7 +48,7 @@ describe('OfflinePage', () => { expect(screen.getByText('You are offline')).toBeInTheDocument(); expect( screen.getByText( - 'Looks like you’re not online right now. Please check your network and reconnect. Once you’re back, just refresh the page to continue.', + "It seems you don't have an internet connection at the moment. Please check your connection and reload the page.", ), ).toBeInTheDocument(); }); diff --git a/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx b/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx index b87e4062f1b..bab0d21abd1 100644 --- a/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx +++ b/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx @@ -2,17 +2,13 @@ import { use } from 'react'; import Helmet from 'react-helmet'; import { ServiceContext } from '#contexts/ServiceContext'; import ErrorMain from '#app/legacy/components/ErrorMain'; -import { useOfflinePageFlag } from '#app/hooks/useOfflinePageFlag'; const OfflinePage = () => { const { service, dir, script } = use(ServiceContext); - // Track offline page visit (sets flag in localStorage, PWA only) - useOfflinePageFlag(); - const title = 'You are offline'; const message = - 'Looks like you’re not online right now. Please check your network and reconnect. Once you’re back, just refresh the page to continue.'; + "It seems you don't have an internet connection at the moment. Please check your connection and reload the page."; const solutions = [ 'Check your internet connection', 'Refresh the page when your connection is restored', @@ -22,7 +18,6 @@ const OfflinePage = () => { <> {title} -