From 84b1ba730fc5b47052f6ee9fda44c51d9dad94ab Mon Sep 17 00:00:00 2001 From: jinidev Date: Fri, 16 Jan 2026 17:04:38 +0200 Subject: [PATCH 1/4] Reapply "WS-1837: Service worker changes to render offline page" This reverts commit 33874607c7bc9f9a4ea3d0793f383dce7b65306f. --- 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, 1202 insertions(+), 104 deletions(-) create mode 100644 src/app/hooks/useOfflinePageFlag/index.test.tsx create mode 100644 src/app/hooks/useOfflinePageFlag/index.tsx create mode 100644 src/app/hooks/usePWAOfflineTracking/index.test.tsx create mode 100644 src/app/hooks/usePWAOfflineTracking/index.tsx create mode 100644 src/app/hooks/useSendPWAStatus/index.test.tsx create mode 100644 src/app/hooks/useSendPWAStatus/index.tsx create mode 100644 src/app/hooks/useServiceWorkerRegistration/index.test.tsx create mode 100644 src/app/hooks/useServiceWorkerRegistration/index.tsx diff --git a/public/sw.js b/public/sw.js index 4a72ed7a5e5..957a7a1a00a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -3,19 +3,64 @@ /* eslint-disable no-unused-vars */ /* eslint-disable no-undef */ /* eslint-disable no-restricted-globals */ -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 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 CACHEABLE_FILES = [ // Reverb @@ -35,24 +80,60 @@ 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( @@ -73,22 +154,66 @@ const fetchEventHandler = async event => { return response; })(), ); - } else if (hasOfflinePageFunctionality && event.request.mode === 'navigate') { - event.respondWith(async () => { - try { - const preloadResponse = await event.preloadResponse; - if (preloadResponse) { - return preloadResponse; + } 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; } - const networkResponse = await fetch(event.request); - return networkResponse; - } catch (error) { + })(), + ); + } else if (isPWADeviceOffline) { + logger(`[SW v${version}] Serving isPWADeviceOffline ${event.request.url}`); + event.respondWith( + (async () => { const cache = await caches.open(cacheName); - const cachedResponse = await cache.match(OFFLINE_PAGE); - return cachedResponse; - } - }); + const cached = await cache.match(event.request); + if (cached) return cached; + return fetch(event.request); + })(), + ); } + return; }; diff --git a/src/app/components/ATIAnalytics/canonical/index.tsx b/src/app/components/ATIAnalytics/canonical/index.tsx index c3185f3311f..db040c549ca 100644 --- a/src/app/components/ATIAnalytics/canonical/index.tsx +++ b/src/app/components/ATIAnalytics/canonical/index.tsx @@ -13,6 +13,7 @@ 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'; @@ -47,6 +48,7 @@ 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 1b3f325039a..267775f34ca 100644 --- a/src/app/components/ServiceWorker/index.test.tsx +++ b/src/app/components/ServiceWorker/index.test.tsx @@ -1,79 +1,38 @@ -import onClient from '#app/lib/utilities/onClient'; +import useServiceWorkerRegistration from '#app/hooks/useServiceWorkerRegistration'; +import useSendPWAStatus from '#app/hooks/useSendPWAStatus'; +import useIsPWA from '#app/hooks/useIsPWA'; import isLocal from '#app/lib/utilities/isLocal'; -import { render } from '../react-testing-library-with-providers'; -import { ServiceContext } from '../../contexts/ServiceContext'; 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()); const contextStub = { - swPath: '/articles/sw.js', + swPath: '/news/sw.js', service: 'news', }; -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('ServiceWorkerContainer', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useIsPWA as jest.Mock).mockReturnValue(false); + (isLocal as jest.Mock).mockReturnValue(true); }); describe('Canonical', () => { - 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); - + it('calls service worker registration hook with service', () => { 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; - } - (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(); - }, - ); + expect(useServiceWorkerRegistration).toHaveBeenCalledWith('news'); }); }); @@ -120,4 +79,32 @@ describe('Service Worker', () => { ); }); }); + + 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 33e26b216f1..29608b8e391 100644 --- a/src/app/components/ServiceWorker/index.tsx +++ b/src/app/components/ServiceWorker/index.tsx @@ -1,6 +1,8 @@ -import { use, useEffect } from 'react'; +import { use } from 'react'; +import useIsPWA from '#app/hooks/useIsPWA'; +import useSendPWAStatus from '#app/hooks/useSendPWAStatus'; +import useServiceWorkerRegistration from '#app/hooks/useServiceWorkerRegistration'; 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'; @@ -35,15 +37,11 @@ export default () => { const { swPath, service } = use(ServiceContext); const { isAmp, canonicalLink } = use(RequestContext); const swSrc = `${getEnvConfig().SIMORGH_BASE_URL}/${service}${swPath}`; + const isPWA = useIsPWA(); - useEffect(() => { - const shouldInstallServiceWorker = - swPath && onClient() && 'serviceWorker' in navigator; - - if (shouldInstallServiceWorker) { - navigator.serviceWorker.register(`/${service}${swPath}`); - } - }, [swPath, service]); + useServiceWorkerRegistration(service); + // Send PWA status to service worker + useSendPWAStatus(isPWA); return isAmp && swPath ? ( <> diff --git a/src/app/hooks/useOfflinePageFlag/index.test.tsx b/src/app/hooks/useOfflinePageFlag/index.test.tsx new file mode 100644 index 00000000000..5160c61df49 --- /dev/null +++ b/src/app/hooks/useOfflinePageFlag/index.test.tsx @@ -0,0 +1,41 @@ +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 new file mode 100644 index 00000000000..0a79b5dca6d --- /dev/null +++ b/src/app/hooks/useOfflinePageFlag/index.tsx @@ -0,0 +1,22 @@ +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 new file mode 100644 index 00000000000..6bffb85cc94 --- /dev/null +++ b/src/app/hooks/usePWAOfflineTracking/index.test.tsx @@ -0,0 +1,247 @@ +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 new file mode 100644 index 00000000000..050793c4b2b --- /dev/null +++ b/src/app/hooks/usePWAOfflineTracking/index.tsx @@ -0,0 +1,44 @@ +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 new file mode 100644 index 00000000000..e88c893eab6 --- /dev/null +++ b/src/app/hooks/useSendPWAStatus/index.test.tsx @@ -0,0 +1,184 @@ +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 new file mode 100644 index 00000000000..540d889ed25 --- /dev/null +++ b/src/app/hooks/useSendPWAStatus/index.tsx @@ -0,0 +1,38 @@ +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 new file mode 100644 index 00000000000..956e69db7ef --- /dev/null +++ b/src/app/hooks/useServiceWorkerRegistration/index.test.tsx @@ -0,0 +1,189 @@ +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 new file mode 100644 index 00000000000..2d4c0b0f6a5 --- /dev/null +++ b/src/app/hooks/useServiceWorkerRegistration/index.tsx @@ -0,0 +1,38 @@ +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 fec58521761..d64d96b2b7b 100644 --- a/src/sw.test.js +++ b/src/sw.test.js @@ -19,6 +19,19 @@ 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(); @@ -273,10 +286,159 @@ 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.0', - fileContentHash: '6150d6daf3d64a226a47e17b39dfc084', + number: 'v0.3.2', + fileContentHash: '1d1d60cfe48f1f2df1916daff4434193', }; 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 b75124de3b9..5e00e578188 100644 --- a/ws-nextjs-app/next.config.js +++ b/ws-nextjs-app/next.config.js @@ -24,6 +24,22 @@ 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 e24431f1d65..c2a55189f8b 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( - "It seems you don't have an internet connection at the moment. Please check your connection and reload the page.", + 'Looks like you’re not online right now. Please check your network and reconnect. Once you’re back, just refresh the page to continue.', ), ).toBeInTheDocument(); }); @@ -48,7 +48,7 @@ describe('OfflinePage', () => { expect(screen.getByText('You are offline')).toBeInTheDocument(); expect( screen.getByText( - "It seems you don't have an internet connection at the moment. Please check your connection and reload the page.", + 'Looks like you’re not online right now. Please check your network and reconnect. Once you’re back, just refresh the page to continue.', ), ).toBeInTheDocument(); }); diff --git a/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx b/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx index bab0d21abd1..b87e4062f1b 100644 --- a/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx +++ b/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx @@ -2,13 +2,17 @@ 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 = - "It seems you don't have an internet connection at the moment. Please check your connection and reload the page."; + 'Looks like you’re not online right now. Please check your network and reconnect. Once you’re back, just refresh the page to continue.'; const solutions = [ 'Check your internet connection', 'Refresh the page when your connection is restored', @@ -18,6 +22,7 @@ const OfflinePage = () => { <> {title} + Date: Fri, 16 Jan 2026 18:47:20 +0200 Subject: [PATCH 2/4] add self.addEventListener('fetch', fetchEventHandler) in sw.js --- public/sw.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/sw.js b/public/sw.js index 957a7a1a00a..bafc08b0fcb 100644 --- a/public/sw.js +++ b/public/sw.js @@ -217,4 +217,4 @@ const fetchEventHandler = async event => { return; }; -onfetch = fetchEventHandler; +self.addEventListener('fetch', fetchEventHandler); From 9b7dc72cc12b7e311457b50866ad4b91b68a16f9 Mon Sep 17 00:00:00 2001 From: jinidev Date: Mon, 19 Jan 2026 13:37:49 +0200 Subject: [PATCH 3/4] exlude sw in webpackclient , ignore sw,js webpackserver --- src/sw.test.js | 2 +- webpack.config.client.js | 1 + webpack.config.server.js | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/sw.test.js b/src/sw.test.js index d64d96b2b7b..021bf1f5803 100644 --- a/src/sw.test.js +++ b/src/sw.test.js @@ -438,7 +438,7 @@ describe('Service Worker', () => { describe('version', () => { const CURRENT_VERSION = { number: 'v0.3.2', - fileContentHash: '1d1d60cfe48f1f2df1916daff4434193', + fileContentHash: '72928b1c91a749fcdc275590f8ec0edc', }; it(`version number should be ${CURRENT_VERSION.number}`, async () => { diff --git a/webpack.config.client.js b/webpack.config.client.js index 9bee3a009e7..9c9f091021d 100644 --- a/webpack.config.client.js +++ b/webpack.config.client.js @@ -96,6 +96,7 @@ module.exports = ({ moduleIds: 'deterministic', minimizer: [ new TerserPlugin({ + exclude: /sw\.js$/, // we don't want to minify the service worker file terserOptions: { // These options are enabled in production profile builds only and // prevent the discarding or mangling of class and function names. diff --git a/webpack.config.server.js b/webpack.config.server.js index 1e0a628ac0d..3c966cccc0f 100644 --- a/webpack.config.server.js +++ b/webpack.config.server.js @@ -52,7 +52,7 @@ module.exports = ({ resolvePath, START_DEV_SERVER }) => { { from: 'public/**/*', globOptions: { - ignore: ['**/images/**'], + ignore: ['**/images/**', '**/sw.js'], }, }, ], From d03b14f6da82547a304edd99d2b5820a7d0e4929 Mon Sep 17 00:00:00 2001 From: jinidev Date: Mon, 19 Jan 2026 19:19:04 +0200 Subject: [PATCH 4/4] CopyWebpackPlugin - modifed for modern browse --- webpack.config.client.js | 11 +++++++---- webpack.config.server.js | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/webpack.config.client.js b/webpack.config.client.js index 9c9f091021d..c4538e740d0 100644 --- a/webpack.config.client.js +++ b/webpack.config.client.js @@ -96,7 +96,6 @@ module.exports = ({ moduleIds: 'deterministic', minimizer: [ new TerserPlugin({ - exclude: /sw\.js$/, // we don't want to minify the service worker file terserOptions: { // These options are enabled in production profile builds only and // prevent the discarding or mangling of class and function names. @@ -192,9 +191,13 @@ module.exports = ({ }, plugins: [ // copy static files otherwise untouched by Webpack, e.g. favicon - new CopyWebpackPlugin({ - patterns: [{ from: 'public' }], - }), + ...(BUNDLE_TYPE === 'modern' + ? [ + new CopyWebpackPlugin({ + patterns: [{ from: 'public' }], + }), + ] + : []), new DuplicatePackageCheckerPlugin({ // Emit compilation warning or error? (Default: `false`) emitError: true, diff --git a/webpack.config.server.js b/webpack.config.server.js index 3c966cccc0f..1e0a628ac0d 100644 --- a/webpack.config.server.js +++ b/webpack.config.server.js @@ -52,7 +52,7 @@ module.exports = ({ resolvePath, START_DEV_SERVER }) => { { from: 'public/**/*', globOptions: { - ignore: ['**/images/**', '**/sw.js'], + ignore: ['**/images/**'], }, }, ],