From 7f929cfdcb546eee387f4e7bf9124861956a1e4a Mon Sep 17 00:00:00 2001 From: skumid01 Date: Thu, 17 Jul 2025 15:38:12 +0300 Subject: [PATCH 01/95] working offline component --- public/sw.js | 114 +++++++++++++----- .../contexts/EventTrackingContext/index.tsx | 2 + src/app/pages/OfflinePage/OfflinePage.tsx | 16 +++ src/app/pages/OfflinePage/index.tsx | 10 ++ src/app/pages/index.js | 1 + src/app/routes/index.js | 2 + .../routes/offline/getInitialData/index.js | 35 ++++++ src/app/routes/offline/index.js | 12 ++ .../utils/constructPageFetchUrl/index.ts | 4 + src/app/routes/utils/pageTypes.ts | 1 + src/app/routes/utils/regex/index.js | 3 + src/app/routes/utils/regex/utils/index.js | 5 + 12 files changed, 174 insertions(+), 31 deletions(-) create mode 100644 src/app/pages/OfflinePage/OfflinePage.tsx create mode 100644 src/app/pages/OfflinePage/index.tsx create mode 100644 src/app/routes/offline/getInitialData/index.js create mode 100644 src/app/routes/offline/index.js diff --git a/public/sw.js b/public/sw.js index c2b51b4e722..90f76660ca9 100644 --- a/public/sw.js +++ b/public/sw.js @@ -5,18 +5,35 @@ /* 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 hasOfflinePageFunctionality = true; 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); - }); + console.log(`Service worker installing with version ${version}`); + event.waitUntil( + (async () => { + const cache = await caches.open(cacheName); + if (hasOfflinePageFunctionality) { + const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin).href; + console.log(`Attempting to cache offline page at: ${offlinePageUrl}`); + try { + const response = await fetch(offlinePageUrl); + if (!response || !response.ok) { + throw new Error(`Failed to fetch offline page: ${response.status} ${response.statusText}`); + } + await cache.put(offlinePageUrl, response); + console.log(`Offline page ${offlinePageUrl} cached successfully with status: ${response.status}`); + } catch (error) { + console.error(`Failed to cache offline page: ${error.message}`); + } + } + })() + ); + self.skipWaiting(); }); - + const CACHEABLE_FILES = [ // Reverb /^https:\/\/static(?:\.test)?\.files\.bbci\.co\.uk\/ws\/(?:simorgh-assets|simorgh1-preview-assets|simorgh2-preview-assets)\/public\/static\/js\/reverb\/reverb-3.10.1.js$/, @@ -31,28 +48,28 @@ const CACHEABLE_FILES = [ // PWA Icons /\/images\/icons\/icon-.*?\.png\??v?=?\d*$/, ]; - + const WEBP_IMAGE = /^https:\/\/ichef(\.test)?\.bbci\.co\.uk\/(news|images|ace\/(standard|ws))\/.+.webp$/; - + const fetchEventHandler = async event => { const isRequestForCacheableFile = CACHEABLE_FILES.some(cacheableFile => new RegExp(cacheableFile).test(event.request.url), ); - + const isRequestForWebpImage = WEBP_IMAGE.test(event.request.url); - + 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( @@ -74,22 +91,57 @@ const fetchEventHandler = async event => { })(), ); } else if (hasOfflinePageFunctionality && event.request.mode === 'navigate') { - event.respondWith(async () => { - try { - const preloadResponse = await event.preloadResponse; - if (preloadResponse) { - return preloadResponse; + event.respondWith( + (async () => { + try { + console.log(`Attempting navigation to: ${event.request.url}`); + const preloadResponse = await event.preloadResponse; + if (preloadResponse) { + console.log('Using preloaded response'); + return preloadResponse; + } + const networkResponse = await fetch(event.request); + console.log(`Network response status: ${networkResponse.status}`); + return networkResponse; + } catch (error) { + console.log(`Network request failed: ${error.message}, serving offline page`); + const cache = await caches.open(cacheName); + const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin).href; + console.log(`Looking for offline page in cache: ${offlinePageUrl}`); + + const cachedResponse = await cache.match(offlinePageUrl); + if (cachedResponse) { + console.log(`Found cached offline page with status: ${cachedResponse.status}`); + return cachedResponse; + } else { + console.log('Offline page not found in cache, attempting to fetch it now'); + try { + const freshOfflineResponse = await fetch(offlinePageUrl); + if (freshOfflineResponse && freshOfflineResponse.ok) { + console.log(`Successfully fetched offline page with status: ${freshOfflineResponse.status}`); + const clonedResponse = freshOfflineResponse.clone(); + cache.put(offlinePageUrl, freshOfflineResponse); + return clonedResponse; + } else { + console.error(`Failed to fetch offline page, status: ${freshOfflineResponse ? freshOfflineResponse.status : 'unknown'}`); + return new Response('You are offline and the offline page could not be retrieved.', { + status: 503, + headers: { 'Content-Type': 'text/plain' } + }); + } + } catch (offlineError) { + console.error(`Error fetching offline page: ${offlineError.message}`); + return new Response('You are offline and the offline page could not be retrieved.', { + status: 503, + headers: { 'Content-Type': 'text/plain' } + }); + } + } } - const networkResponse = await fetch(event.request); - return networkResponse; - } catch (error) { - const cache = await caches.open(cacheName); - const cachedResponse = await cache.match(OFFLINE_PAGE); - return cachedResponse; - } - }); + })() + ); } return; }; - -onfetch = fetchEventHandler; + +onfetch = fetchEventHandler; \ No newline at end of file diff --git a/src/app/contexts/EventTrackingContext/index.tsx b/src/app/contexts/EventTrackingContext/index.tsx index f061bafc5fa..a7d6485e0a4 100644 --- a/src/app/contexts/EventTrackingContext/index.tsx +++ b/src/app/contexts/EventTrackingContext/index.tsx @@ -21,6 +21,7 @@ import { LIVE_RADIO_PAGE, TV_PAGE, AUDIO_PAGE, + OFFLINE_PAGE, } from '../../routes/utils/pageTypes'; import { PageTypes, Platforms } from '../../models/types/global'; import { buildATIEventTrackingParams } from '../../components/ATIAnalytics/params'; @@ -49,6 +50,7 @@ type CampaignPageTypes = Exclude; const getCampaignID = (pageType: CampaignPageTypes) => { const campaignID = { + [OFFLINE_PAGE]: 'offline', [ARTICLE_PAGE]: 'article', [MEDIA_ARTICLE_PAGE]: 'article-sfv', [MOST_READ_PAGE]: 'list-datadriven-read', diff --git a/src/app/pages/OfflinePage/OfflinePage.tsx b/src/app/pages/OfflinePage/OfflinePage.tsx new file mode 100644 index 00000000000..2329fa140c4 --- /dev/null +++ b/src/app/pages/OfflinePage/OfflinePage.tsx @@ -0,0 +1,16 @@ +import { RequestContext } from '#app/contexts/RequestContext'; +import React, { useContext } from 'react'; + +const OfflinePage = () => { + const { service } = useContext(RequestContext); + + return ( +
+

Offline Page

+ {service} +

This page is displayed when the user is offline.

+
+ ); +}; + +export default OfflinePage; diff --git a/src/app/pages/OfflinePage/index.tsx b/src/app/pages/OfflinePage/index.tsx new file mode 100644 index 00000000000..1ad56e31ed7 --- /dev/null +++ b/src/app/pages/OfflinePage/index.tsx @@ -0,0 +1,10 @@ +import withOptimizelyProvider from '#app/legacy/containers/PageHandlers/withOptimizelyProvider'; +import OfflinePageComponent from './OfflinePage'; +import applyBasicPageHandlers from '../utils/applyBasicPageHandlers'; + +const EnhancedOfflinePage = applyBasicPageHandlers(OfflinePageComponent, { + handlerBeforeContexts: withOptimizelyProvider, +}); + +export const OfflinePage = EnhancedOfflinePage; +export default EnhancedOfflinePage; diff --git a/src/app/pages/index.js b/src/app/pages/index.js index 3a1e3e16818..95aa6dd4dd5 100644 --- a/src/app/pages/index.js +++ b/src/app/pages/index.js @@ -9,3 +9,4 @@ export const LiveRadioPage = loadable(() => import('./LiveRadioPage')); export const OnDemandAudioPage = loadable(() => import('./OnDemandAudioPage')); export const OnDemandTvPage = loadable(() => import('./OnDemandTvPage')); export const TopicPage = loadable(() => import('./TopicPage')); +export const OfflinePage = loadable(() => import('./OfflinePage')); diff --git a/src/app/routes/index.js b/src/app/routes/index.js index 1f40420575f..30ec405e46c 100644 --- a/src/app/routes/index.js +++ b/src/app/routes/index.js @@ -8,8 +8,10 @@ import onDemandTV from './onDemandTV'; import topic from './topic'; import error from './error'; import errorNoRouteMatch from './errorNoRouteMatch'; +import offline from './offline'; export default [ + offline, homePage, liveRadio, mostRead, diff --git a/src/app/routes/offline/getInitialData/index.js b/src/app/routes/offline/getInitialData/index.js new file mode 100644 index 00000000000..a5666de6686 --- /dev/null +++ b/src/app/routes/offline/getInitialData/index.js @@ -0,0 +1,35 @@ +import fetchPageData from '#app/routes/utils/fetchPageData'; +import getConfig from '#app/routes/utils/getConfig'; + +export default async ({ service, variant, pathname }) => { + const config = await getConfig(service, variant); + + try { + const { status } = pathname + ? await fetchPageData({ + path: pathname, + service, + }) + : { status: 200 }; + + return { + status, + pageData: { + metadata: { + type: 'offline', + serviceConfig: config, + }, + }, + }; + } catch (error) { + return { + status: 200, + pageData: { + metadata: { + type: 'offline', + serviceConfig: config, + }, + }, + }; + } +}; diff --git a/src/app/routes/offline/index.js b/src/app/routes/offline/index.js new file mode 100644 index 00000000000..242457bf4f8 --- /dev/null +++ b/src/app/routes/offline/index.js @@ -0,0 +1,12 @@ +import { OfflinePage } from '#pages'; +import { offlinePagePath } from '#app/routes/utils/regex'; +import { OFFLINE_PAGE } from '#app/routes/utils/pageTypes'; +import getInitialData from './getInitialData'; + +export default { + path: offlinePagePath, + exact: true, + component: OfflinePage, + getInitialData, + pageType: OFFLINE_PAGE, +}; diff --git a/src/app/routes/utils/constructPageFetchUrl/index.ts b/src/app/routes/utils/constructPageFetchUrl/index.ts index af94a034027..15150e523e2 100644 --- a/src/app/routes/utils/constructPageFetchUrl/index.ts +++ b/src/app/routes/utils/constructPageFetchUrl/index.ts @@ -27,6 +27,7 @@ import { TOPIC_PAGE, TV_PAGE, UGC_PAGE, + OFFLINE_PAGE, } from '../pageTypes'; import parseAvRoute from '../parseAvRoute'; @@ -65,6 +66,9 @@ const getId = ({ pageType, service, variant, env }: GetIdProps) => { return removeLeadingSlash(path); }; break; + case OFFLINE_PAGE: + getIdFunction = () => 'offline'; + break; case CPS_ASSET: getIdFunction = (path: string) => getCpsId(path); break; diff --git a/src/app/routes/utils/pageTypes.ts b/src/app/routes/utils/pageTypes.ts index bb5feccb803..306e439b1c1 100644 --- a/src/app/routes/utils/pageTypes.ts +++ b/src/app/routes/utils/pageTypes.ts @@ -17,3 +17,4 @@ export const DOWNLOADS_PAGE = 'downloads' as const; export const LIVE_RADIO_PAGE = 'liveRadio' as const; export const AUDIO_PAGE = 'audio' as const; export const TV_PAGE = 'tv' as const; +export const OFFLINE_PAGE = 'offline' as const; diff --git a/src/app/routes/utils/regex/index.js b/src/app/routes/utils/regex/index.js index 4f238289e22..7c76bb9f438 100644 --- a/src/app/routes/utils/regex/index.js +++ b/src/app/routes/utils/regex/index.js @@ -17,6 +17,7 @@ import { getMostReadDataRegex, getSecondaryColumnDataRegex, getAfricaEyeTVPageRegex, + getOfflinePageRegex, } from './utils'; const allServices = Object.keys(services); @@ -24,6 +25,8 @@ const allServices = Object.keys(services); export const articlePath = getArticleRegex(allServices); export const articleDataPath = `${articlePath}.json`; +export const offlinePagePath = getOfflinePageRegex(allServices); + export const homePageSwPath = getSwRegex(allServices); export const homePageManifestPath = getManifestRegex(allServices); export const homePagePath = getHomePageRegex(allServices); diff --git a/src/app/routes/utils/regex/utils/index.js b/src/app/routes/utils/regex/utils/index.js index e7856216f5d..60ee3545b78 100644 --- a/src/app/routes/utils/regex/utils/index.js +++ b/src/app/routes/utils/regex/utils/index.js @@ -36,6 +36,11 @@ const getWorldServices = services => { return services.filter(service => !publicServices.includes(service)); }; +export const getOfflinePageRegex = services => { + const serviceRegex = getServiceRegex(services); + return `/:service(${serviceRegex})/offline`; +}; + export const getHomePageRegex = services => { const homePageServiceRegex = getServiceRegex(services); return `/:service(${homePageServiceRegex}):variant(${variantRegex})?:lite(${liteRegex})?`; From 2cb8b5275424b6e308ecf78235a40285a57612b8 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Mon, 21 Jul 2025 20:07:32 +0300 Subject: [PATCH 02/95] update offline page --- src/app/pages/OfflinePage/OfflinePage.tsx | 46 +++++++++++++++---- .../routes/offline/{index.js => index.tsx} | 2 +- 2 files changed, 39 insertions(+), 9 deletions(-) rename src/app/routes/offline/{index.js => index.tsx} (87%) diff --git a/src/app/pages/OfflinePage/OfflinePage.tsx b/src/app/pages/OfflinePage/OfflinePage.tsx index 2329fa140c4..29a14794431 100644 --- a/src/app/pages/OfflinePage/OfflinePage.tsx +++ b/src/app/pages/OfflinePage/OfflinePage.tsx @@ -1,15 +1,45 @@ -import { RequestContext } from '#app/contexts/RequestContext'; -import React, { useContext } from 'react'; +import React, { use } from 'react'; +import path from 'ramda/src/path'; +import Helmet from 'react-helmet'; +import { ServiceContext } from '#contexts/ServiceContext'; +import ErrorMain from '#components/ErrorMain'; const OfflinePage = () => { - const { service } = useContext(RequestContext); + // Get service information from context or props + const { + service: contextService, + brandName, + dir, + script, + translations, + } = use(ServiceContext); + const message = + "Seems like you don't have an internet connection at the moment. Please check your connection and reload the page."; + + const service = contextService; + + const title = path(['offline', 'title'], translations) || 'You are offline.'; return ( -
-

Offline Page

- {service} -

This page is displayed when the user is offline.

-
+ <> + + {`${title} - ${brandName}`} + + + ); }; diff --git a/src/app/routes/offline/index.js b/src/app/routes/offline/index.tsx similarity index 87% rename from src/app/routes/offline/index.js rename to src/app/routes/offline/index.tsx index 242457bf4f8..b997f9469fa 100644 --- a/src/app/routes/offline/index.js +++ b/src/app/routes/offline/index.tsx @@ -1,6 +1,6 @@ -import { OfflinePage } from '#pages'; import { offlinePagePath } from '#app/routes/utils/regex'; import { OFFLINE_PAGE } from '#app/routes/utils/pageTypes'; +import { OfflinePage } from '#app/pages'; import getInitialData from './getInitialData'; export default { From d334d989fc74d6e1876ac7ed160e8d242b54393d Mon Sep 17 00:00:00 2001 From: skumid01 Date: Mon, 21 Jul 2025 20:23:53 +0300 Subject: [PATCH 03/95] update --- src/app/pages/OfflinePage/OfflinePage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/pages/OfflinePage/OfflinePage.tsx b/src/app/pages/OfflinePage/OfflinePage.tsx index 29a14794431..58c59674046 100644 --- a/src/app/pages/OfflinePage/OfflinePage.tsx +++ b/src/app/pages/OfflinePage/OfflinePage.tsx @@ -5,7 +5,6 @@ import { ServiceContext } from '#contexts/ServiceContext'; import ErrorMain from '#components/ErrorMain'; const OfflinePage = () => { - // Get service information from context or props const { service: contextService, brandName, From a886733e046d3c5c71905775ab1f90ef11aeccdd Mon Sep 17 00:00:00 2001 From: skumid01 Date: Tue, 22 Jul 2025 16:01:52 +0300 Subject: [PATCH 04/95] updating bundle size --- scripts/bundleSize/bundleSizeConfig.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/bundleSize/bundleSizeConfig.js b/scripts/bundleSize/bundleSizeConfig.js index a177d154d75..0ceb17a3b4a 100644 --- a/scripts/bundleSize/bundleSizeConfig.js +++ b/scripts/bundleSize/bundleSizeConfig.js @@ -8,8 +8,8 @@ */ const MIN = 954; -const MAX = 1250; +const MAX = 1265; const VARIANCE = 5; export const MIN_SIZE = MIN - VARIANCE; -export const MAX_SIZE = MAX + VARIANCE; \ No newline at end of file +export const MAX_SIZE = MAX + VARIANCE; From 9be198bb5765d6a806de99c46d1cc3281b3df75b Mon Sep 17 00:00:00 2001 From: skumid01 Date: Tue, 22 Jul 2025 18:48:03 +0300 Subject: [PATCH 05/95] update tests --- src/app/routes/getInitialData.test.jsx | 133 ++++++++++++------ .../utils/__snapshots__/index.test.js.snap | 2 + src/sw.test.js | 2 +- 3 files changed, 90 insertions(+), 47 deletions(-) diff --git a/src/app/routes/getInitialData.test.jsx b/src/app/routes/getInitialData.test.jsx index 19cd26f730c..ac7bd2590c7 100644 --- a/src/app/routes/getInitialData.test.jsx +++ b/src/app/routes/getInitialData.test.jsx @@ -34,65 +34,106 @@ describe('getInitialData', () => { pageType === 'liveRadio' ? '/korean/bbc_korean_radio/liveradio' : MOCK_PATH; - it(`${pageType} - should handle Ares 404`, async () => { - fetch.mockResponseOnce(JSON.stringify({}), { status: 404 }); - - const actual = await getInitialData({ - path, - pageType, - toggles, + if (pageType !== 'offline') { + it(`${pageType} - should handle Ares 404`, async () => { + fetch.mockResponseOnce(JSON.stringify({}), { status: 404 }); + + const actual = await getInitialData({ + path, + pageType, + toggles, + }); + const expected = { + error: 'data_response_404', + status: 404, + }; + + expect(actual).toEqual(expected); }); - const expected = { - error: 'data_response_404', - status: 404, - }; - expect(actual).toEqual(expected); - }); + it(`${pageType} - should handle Ares 202`, async () => { + fetch.mockResponseOnce(JSON.stringify({}), { status: 202 }); - it(`${pageType} - should handle Ares 202`, async () => { - fetch.mockResponseOnce(JSON.stringify({}), { status: 202 }); + const actual = await getInitialData({ + path, + pageType, + toggles, + }); - const actual = await getInitialData({ - path, - pageType, - toggles, + expect(actual.status).toEqual(502); + expect(actual.error).toMatch( + 'Unexpected upstream response (HTTP status code 202) when requesting', + ); }); - expect(actual.status).toEqual(502); - expect(actual.error).toMatch( - 'Unexpected upstream response (HTTP status code 202) when requesting', - ); - }); + it(`${pageType} - should handle Ares 500`, async () => { + fetch.mockResponseOnce(JSON.stringify({}), { status: 500 }); - it(`${pageType} - should handle Ares 500`, async () => { - fetch.mockResponseOnce(JSON.stringify({}), { status: 500 }); + const actual = await getInitialData({ + path, + pageType, + toggles, + }); - const actual = await getInitialData({ - path, - pageType, - toggles, + expect(actual.status).toEqual(502); + expect(actual.error).toMatch( + 'Unexpected upstream response (HTTP status code 500) when requesting', + ); }); - expect(actual.status).toEqual(502); - expect(actual.error).toMatch( - 'Unexpected upstream response (HTTP status code 500) when requesting', - ); - }); + it(`${pageType} - should handle Ares returning unexpected data`, async () => { + fetch.mockResponseOnce('dataIsNotAsExpected'); - it(`${pageType} - should handle Ares returning unexpected data`, async () => { - fetch.mockResponseOnce('dataIsNotAsExpected'); + const actual = await getInitialData({ + path, + pageType, + toggles, + }); - const actual = await getInitialData({ - path, - pageType, - toggles, + expect(actual.status).toEqual(502); + expect(actual.error).toEqual( + 'invalid json response body at reason: Unexpected token \'d\', "dataIsNotAsExpected" is not valid JSON', + ); + }); + } else { + it(`${pageType} - should handle Ares 200`, async () => { + fetch.mockResponseOnce(JSON.stringify({}), { status: 404 }); + + const actual = await getInitialData({ + path, + pageType, + toggles, + }); + const expected = { + pageData: { + metadata: { + serviceConfig: {}, + type: 'offline', + }, + }, + status: 200, + }; + + expect(actual).toEqual(expected); }); - expect(actual.status).toEqual(502); - expect(actual.error).toEqual( - 'invalid json response body at reason: Unexpected token \'d\', "dataIsNotAsExpected" is not valid JSON', - ); - }); + it(`${pageType} - should handle Ares returning unexpected data`, async () => { + fetch.mockResponseOnce('dataIsNotAsExpected'); + + const actual = await getInitialData({ + path, + pageType, + toggles, + }); + + expect(actual.status).toEqual(200); + expect(actual.pageData).toEqual({ + metadata: { + serviceConfig: {}, + type: 'offline', + }, + }); + }); + } }); }); diff --git a/src/app/routes/utils/regex/utils/__snapshots__/index.test.js.snap b/src/app/routes/utils/regex/utils/__snapshots__/index.test.js.snap index d754ff7ff8f..99afd6181b2 100644 --- a/src/app/routes/utils/regex/utils/__snapshots__/index.test.js.snap +++ b/src/app/routes/utils/regex/utils/__snapshots__/index.test.js.snap @@ -20,6 +20,8 @@ exports[`regex utils snapshots should create expected regex from getMostReadData exports[`regex utils snapshots should create expected regex from getMostReadPageRegex 1`] = `"/:service(afaanoromoo|afrique|amharic|arabic|archive|azeri|bengali|burmese|cymrufyw|gahuza|gujarati|hausa|hindi|igbo|indonesia|japanese|korean|kyrgyz|marathi|mundo|naidheachdan|nepali|news|newsround|pashto|persian|pidgin|polska|portuguese|punjabi|russian|scotland|serbian|sinhala|somali|sport|swahili|tamil|telugu|thai|tigrinya|turkce|ukchina|ukrainian|urdu|uzbek|vietnamese|ws|yoruba|zhongwen):variant(/simp|/trad|/cyr|/lat)?/popular/read:amp(.amp)?:lite(.lite)?"`; +exports[`regex utils snapshots should create expected regex from getOfflinePageRegex 1`] = `"/:service(afaanoromoo|afrique|amharic|arabic|archive|azeri|bengali|burmese|cymrufyw|gahuza|gujarati|hausa|hindi|igbo|indonesia|japanese|korean|kyrgyz|marathi|mundo|naidheachdan|nepali|news|newsround|pashto|persian|pidgin|polska|portuguese|punjabi|russian|scotland|serbian|sinhala|somali|sport|swahili|tamil|telugu|thai|tigrinya|turkce|ukchina|ukrainian|urdu|uzbek|vietnamese|ws|yoruba|zhongwen)/offline"`; + exports[`regex utils snapshots should create expected regex from getOnDemandRadioRegex 1`] = `"/:service(afaanoromoo|afrique|amharic|arabic|archive|azeri|bengali|burmese|cymrufyw|gahuza|gujarati|hausa|hindi|igbo|indonesia|japanese|korean|kyrgyz|marathi|mundo|naidheachdan|nepali|news|newsround|pashto|persian|pidgin|polska|portuguese|punjabi|russian|scotland|serbian|sinhala|somali|sport|swahili|tamil|telugu|thai|tigrinya|turkce|ukchina|ukrainian|urdu|uzbek|vietnamese|ws|yoruba|zhongwen):variant(/simp|/trad|/cyr|/lat)?/:serviceId(bbc_[a-z]+_radio)(/programmes)?/:mediaId([a-z0-9]+):lite(.lite)?"`; exports[`regex utils snapshots should create expected regex from getOnDemandTvRegex 1`] = `"/:service(afaanoromoo|afrique|amharic|arabic|archive|azeri|bengali|burmese|cymrufyw|gahuza|gujarati|hausa|hindi|igbo|indonesia|japanese|korean|kyrgyz|marathi|mundo|naidheachdan|nepali|news|newsround|pashto|persian|pidgin|polska|portuguese|punjabi|russian|scotland|serbian|sinhala|somali|sport|swahili|tamil|telugu|thai|tigrinya|turkce|ukchina|ukrainian|urdu|uzbek|vietnamese|ws|yoruba|zhongwen)/:serviceId(bbc_[a-z]+_tv)/:brandEpisode(tv|tv_programmes)/:mediaId([a-z0-9]+):lite(.lite)?"`; diff --git a/src/sw.test.js b/src/sw.test.js index 0fcbc9457ab..cce645fe5c9 100644 --- a/src/sw.test.js +++ b/src/sw.test.js @@ -292,7 +292,7 @@ describe('Service Worker', () => { describe('version', () => { const CURRENT_VERSION = { number: 'v0.3.0', - fileContentHash: 'c2f44f5d446a24e1fcabacae38f033bd', + fileContentHash: 'dd9480d41ee91fb903627ec5bd0e33bc', }; it(`version number should be ${CURRENT_VERSION.number}`, async () => { From 624f4b4d562dac7a673d79e7ff52bf2af76c92f3 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Tue, 22 Jul 2025 19:52:57 +0300 Subject: [PATCH 06/95] update tests --- src/app/pages/OfflinePage/OfflinePage.tsx | 2 +- .../__snapshots__/index.test.jsx.snap | 619 ++++++++++++++++++ src/app/pages/OfflinePage/index.test.jsx | 35 + 3 files changed, 655 insertions(+), 1 deletion(-) create mode 100644 src/app/pages/OfflinePage/__snapshots__/index.test.jsx.snap create mode 100644 src/app/pages/OfflinePage/index.test.jsx diff --git a/src/app/pages/OfflinePage/OfflinePage.tsx b/src/app/pages/OfflinePage/OfflinePage.tsx index 58c59674046..4e7cc115b6d 100644 --- a/src/app/pages/OfflinePage/OfflinePage.tsx +++ b/src/app/pages/OfflinePage/OfflinePage.tsx @@ -22,7 +22,7 @@ const OfflinePage = () => { return ( <> - {`${title} - ${brandName}`} + {`${title}`} +
+
+ +

+ You are offline. +

+

+ Seems like you don't have an internet connection at the moment. Please check your connection and reload the page. +

+
+
+ +`; + +exports[`OfflinePage should render correctly for mundo service 1`] = ` +.emotion-1 { + width: 100%; + padding-bottom: 4rem; +} + +@supports (display: grid) { + .emotion-1 { + display: grid; + position: initial; + width: initial; + margin: 0; + } + + @media (max-width: 14.9375rem) { + .emotion-1 { + grid-template-columns: repeat(6, 1fr); + grid-column-end: span 6; + grid-column-gap: 0.5rem; + } + } + + @media (min-width: 15rem) and (max-width: 24.9375rem) { + .emotion-1 { + grid-template-columns: repeat(6, 1fr); + grid-column-end: span 6; + grid-column-gap: 0.5rem; + } + } + + @media (min-width: 25rem) and (max-width: 37.4375rem) { + .emotion-1 { + grid-template-columns: repeat(6, 1fr); + grid-column-end: span 6; + grid-column-gap: 0.5rem; + } + } + + @media (min-width: 37.5rem) and (max-width: 62.9375rem) { + .emotion-1 { + grid-template-columns: repeat(6, 1fr); + grid-column-end: span 6; + grid-column-gap: 1rem; + } + } + + @media (min-width: 63rem) and (max-width: 79.9375rem) { + .emotion-1 { + grid-template-columns: repeat(8, 1fr); + grid-column-end: span 8; + grid-column-gap: 1rem; + } + } + + @media (min-width: 80rem) { + .emotion-1 { + grid-template-columns: repeat(20, 1fr); + grid-column-end: span 20; + grid-column-gap: 1rem; + } + } +} + +@media (min-width: 63rem) and (max-width: 79.9375rem) { + .emotion-1 { + margin: 0 auto; + max-width: 63rem; + } +} + +@media (min-width: 80rem) { + .emotion-1 { + margin: 0 auto; + max-width: 80rem; + } +} + +@media (max-width: 14.9375rem) { + .emotion-3 { + padding: 0 0.5rem; + margin-left: 0%; + } +} + +@media (min-width: 15rem) and (max-width: 24.9375rem) { + .emotion-3 { + padding: 0 0.5rem; + margin-left: 0%; + } +} + +@media (min-width: 25rem) and (max-width: 37.4375rem) { + .emotion-3 { + padding: 0 1rem; + margin-left: 0%; + } +} + +@media (min-width: 37.5rem) and (max-width: 62.9375rem) { + .emotion-3 { + padding: 0 1rem; + margin-left: 0%; + } +} + +@media (min-width: 63rem) and (max-width: 79.9375rem) { + .emotion-3 { + margin-left: 16.666666666666668%; + } +} + +@media (min-width: 80rem) { + .emotion-3 { + margin-left: 33.333333333333336%; + } +} + +@supports (display: grid) { + .emotion-3 { + display: block; + width: initial; + margin: 0; + } + + @media (max-width: 14.9375rem) { + .emotion-3 { + grid-template-columns: repeat(6, 1fr); + grid-column-end: span 6; + padding: 0 0.5rem; + grid-column-start: 1; + } + } + + @media (min-width: 15rem) and (max-width: 24.9375rem) { + .emotion-3 { + grid-template-columns: repeat(6, 1fr); + grid-column-end: span 6; + padding: 0 0.5rem; + grid-column-start: 1; + } + } + + @media (min-width: 25rem) and (max-width: 37.4375rem) { + .emotion-3 { + grid-template-columns: repeat(6, 1fr); + grid-column-end: span 6; + padding: 0 1rem; + grid-column-start: 1; + } + } + + @media (min-width: 37.5rem) and (max-width: 62.9375rem) { + .emotion-3 { + grid-template-columns: repeat(6, 1fr); + grid-column-end: span 6; + padding: 0 1rem; + grid-column-start: 1; + } + } + + @media (min-width: 63rem) and (max-width: 79.9375rem) { + .emotion-3 { + grid-template-columns: repeat(6, 1fr); + grid-column-end: span 6; + grid-column-start: 2; + } + } + + @media (min-width: 80rem) { + .emotion-3 { + grid-template-columns: repeat(12, 1fr); + grid-column-end: span 12; + grid-column-start: 5; + } + } +} + +.emotion-5 { + font-size: 1.25rem; + line-height: 1.625rem; + color: #B80000; + display: block; + font-family: ReithSans,Helvetica,Arial,sans-serif; + font-weight: 600; + padding: 2.5rem 0 0.5rem 0; +} + +@media (min-width: 20rem) and (max-width: 37.4375rem) { + .emotion-5 { + font-size: 1.375rem; + line-height: 1.875rem; + } +} + +@media (min-width: 37.5rem) { + .emotion-5 { + font-size: 1.75rem; + line-height: 2.375rem; + } +} + +.emotion-7 { + font-size: 1.75rem; + line-height: 2.25rem; + font-family: ReithSerif,Helvetica,Arial,sans-serif; + font-weight: 500; + font-style: normal; + color: #3F3F42; + margin-top: 0; +} + +@media (min-width: 20rem) and (max-width: 37.4375rem) { + .emotion-7 { + font-size: 2rem; + line-height: 2.625rem; + } +} + +@media (min-width: 37.5rem) { + .emotion-7 { + font-size: 2.75rem; + line-height: 3.625rem; + } +} + +.emotion-9 { + font-size: 0.9375rem; + line-height: 1.25rem; + font-family: ReithSans,Helvetica,Arial,sans-serif; + font-weight: 400; + font-style: normal; + color: #141414; + padding-bottom: 1.5rem; + margin: 0; + padding-top: 0.2rem; +} + +@media (min-width: 20rem) and (max-width: 37.4375rem) { + .emotion-9 { + font-size: 1rem; + line-height: 1.375rem; + } +} + +@media (min-width: 37.5rem) { + .emotion-9 { + font-size: 1rem; + line-height: 1.375rem; + } +} + +.emotion-13 { + color: #222222; + border-bottom: 1px solid #B80000; + -webkit-text-decoration: none; + text-decoration: none; +} + +.emotion-13:visited { + color: #6E6E73; + border-bottom: 1px solid #6E6E73; +} + +.emotion-13:focus, +.emotion-13:hover { + border-bottom: 2px solid #B80000; + color: #B80000; +} + + +`; diff --git a/src/app/pages/OfflinePage/index.test.jsx b/src/app/pages/OfflinePage/index.test.jsx new file mode 100644 index 00000000000..c7477e8ae38 --- /dev/null +++ b/src/app/pages/OfflinePage/index.test.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import OfflinePage from './OfflinePage'; +import { + render, + screen, +} from '../../components/react-testing-library-with-providers'; + +describe('OfflinePage', () => { + it('should render correctly', () => { + const { container } = render(, { + service: 'news', + }); + expect(container).toMatchSnapshot(); + }); + + it('should render correctly for mundo service', () => { + const { container } = render(, { + service: 'mundo', + }); + expect(container).toMatchSnapshot(); + }); + + it('should use fallback values when translations are missing', () => { + render(, { + service: 'news', + translations: {}, + }); + + expect(screen.getByText('You are offline.')).toBeInTheDocument(); + + expect( + screen.getByText(/Seems like you don't have an internet connection/), + ).toBeInTheDocument(); + }); +}); From 9c59cbbf123010a0460f42fdf5aee7a658a03f4f Mon Sep 17 00:00:00 2001 From: skumid01 Date: Tue, 22 Jul 2025 20:50:15 +0300 Subject: [PATCH 07/95] update tests --- src/app/pages/OfflinePage/OfflinePage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/pages/OfflinePage/OfflinePage.tsx b/src/app/pages/OfflinePage/OfflinePage.tsx index 4e7cc115b6d..3bf05572022 100644 --- a/src/app/pages/OfflinePage/OfflinePage.tsx +++ b/src/app/pages/OfflinePage/OfflinePage.tsx @@ -7,7 +7,6 @@ import ErrorMain from '#components/ErrorMain'; const OfflinePage = () => { const { service: contextService, - brandName, dir, script, translations, From 447450bbd4879e6ca8c78b74145c9a4c4efbcdc6 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Wed, 23 Jul 2025 14:11:13 +0300 Subject: [PATCH 08/95] update tests --- cypress/e2e/specialFeatures/serviceWorker/assertions/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/cypress/e2e/specialFeatures/serviceWorker/assertions/index.ts b/cypress/e2e/specialFeatures/serviceWorker/assertions/index.ts index 5e9ac5a7a49..b4f0ba54e90 100644 --- a/cypress/e2e/specialFeatures/serviceWorker/assertions/index.ts +++ b/cypress/e2e/specialFeatures/serviceWorker/assertions/index.ts @@ -65,6 +65,7 @@ export const serviceWorkerCaching = () => { 'https://static.test.files.bbci.co.uk/ws/simorgh1-preview-assets/public/static/js/reverb/reverb-3.10.1.js', 'https://static.test.files.bbci.co.uk/ws/simorgh2-preview-assets/public/static/js/reverb/reverb-3.10.1.js', 'https://mybbc-analytics.files.bbci.co.uk/reverb-client-js/smarttag-5.29.4.min.js', + '/offline', ]; it(`simorgh cache contains cached responses for cacheable items - ${JSON.stringify(cacheableItems)}`, () => { From 1fb71dccf44f98432150716e0a1999bbeac825f9 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Wed, 23 Jul 2025 17:02:04 +0300 Subject: [PATCH 09/95] update tests --- public/sw.js | 104 ++++++++++++---------- src/app/pages/OfflinePage/OfflinePage.tsx | 9 +- 2 files changed, 56 insertions(+), 57 deletions(-) diff --git a/public/sw.js b/public/sw.js index 90f76660ca9..8d8282982be 100644 --- a/public/sw.js +++ b/public/sw.js @@ -5,35 +5,35 @@ /* eslint-disable no-restricted-globals */ const version = 'v0.3.0'; const cacheName = 'simorghCache_v1'; - + const service = self.location.pathname.split('/')[1]; const hasOfflinePageFunctionality = true; const OFFLINE_PAGE = `/${service}/offline`; - + self.addEventListener('install', event => { - console.log(`Service worker installing with version ${version}`); event.waitUntil( (async () => { const cache = await caches.open(cacheName); if (hasOfflinePageFunctionality) { const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin).href; - console.log(`Attempting to cache offline page at: ${offlinePageUrl}`); try { const response = await fetch(offlinePageUrl); if (!response || !response.ok) { - throw new Error(`Failed to fetch offline page: ${response.status} ${response.statusText}`); + throw new Error( + `Failed to fetch offline page: ${response.status} ${response.statusText}`, + ); } await cache.put(offlinePageUrl, response); - console.log(`Offline page ${offlinePageUrl} cached successfully with status: ${response.status}`); } catch (error) { + // eslint-disable-next-line no-console console.error(`Failed to cache offline page: ${error.message}`); } } - })() + })(), ); self.skipWaiting(); }); - + const CACHEABLE_FILES = [ // Reverb /^https:\/\/static(?:\.test)?\.files\.bbci\.co\.uk\/ws\/(?:simorgh-assets|simorgh1-preview-assets|simorgh2-preview-assets)\/public\/static\/js\/reverb\/reverb-3.10.1.js$/, @@ -48,28 +48,28 @@ const CACHEABLE_FILES = [ // PWA Icons /\/images\/icons\/icon-.*?\.png\??v?=?\d*$/, ]; - + const WEBP_IMAGE = /^https:\/\/ichef(\.test)?\.bbci\.co\.uk\/(news|images|ace\/(standard|ws))\/.+.webp$/; - + const fetchEventHandler = async event => { const isRequestForCacheableFile = CACHEABLE_FILES.some(cacheableFile => new RegExp(cacheableFile).test(event.request.url), ); - + const isRequestForWebpImage = WEBP_IMAGE.test(event.request.url); - + 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( @@ -94,54 +94,60 @@ const fetchEventHandler = async event => { event.respondWith( (async () => { try { - console.log(`Attempting navigation to: ${event.request.url}`); const preloadResponse = await event.preloadResponse; if (preloadResponse) { - console.log('Using preloaded response'); return preloadResponse; } const networkResponse = await fetch(event.request); - console.log(`Network response status: ${networkResponse.status}`); return networkResponse; } catch (error) { - console.log(`Network request failed: ${error.message}, serving offline page`); + // eslint-disable-next-line no-console + console.error( + `Network request failed: ${error.message}, serving offline page`, + ); const cache = await caches.open(cacheName); - const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin).href; - console.log(`Looking for offline page in cache: ${offlinePageUrl}`); - + const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin) + .href; const cachedResponse = await cache.match(offlinePageUrl); if (cachedResponse) { - console.log(`Found cached offline page with status: ${cachedResponse.status}`); return cachedResponse; - } else { - console.log('Offline page not found in cache, attempting to fetch it now'); - try { - const freshOfflineResponse = await fetch(offlinePageUrl); - if (freshOfflineResponse && freshOfflineResponse.ok) { - console.log(`Successfully fetched offline page with status: ${freshOfflineResponse.status}`); - const clonedResponse = freshOfflineResponse.clone(); - cache.put(offlinePageUrl, freshOfflineResponse); - return clonedResponse; - } else { - console.error(`Failed to fetch offline page, status: ${freshOfflineResponse ? freshOfflineResponse.status : 'unknown'}`); - return new Response('You are offline and the offline page could not be retrieved.', { - status: 503, - headers: { 'Content-Type': 'text/plain' } - }); - } - } catch (offlineError) { - console.error(`Error fetching offline page: ${offlineError.message}`); - return new Response('You are offline and the offline page could not be retrieved.', { - status: 503, - headers: { 'Content-Type': 'text/plain' } - }); + } + try { + const freshOfflineResponse = await fetch(offlinePageUrl); + if (freshOfflineResponse && freshOfflineResponse.ok) { + const clonedResponse = freshOfflineResponse.clone(); + cache.put(offlinePageUrl, freshOfflineResponse); + return clonedResponse; } + // eslint-disable-next-line no-console + console.error( + `Failed to fetch offline page, status: ${freshOfflineResponse ? freshOfflineResponse.status : 'unknown'}`, + ); + return new Response( + 'You are offline and the offline page could not be retrieved.', + { + status: 503, + headers: { 'Content-Type': 'text/plain' }, + }, + ); + } catch (offlineError) { + // eslint-disable-next-line no-console + console.error( + `Error fetching offline page: ${offlineError.message}`, + ); + return new Response( + 'You are offline and the offline page could not be retrieved.', + { + status: 503, + headers: { 'Content-Type': 'text/plain' }, + }, + ); } } - })() + })(), ); } return; }; - -onfetch = fetchEventHandler; \ No newline at end of file + +onfetch = fetchEventHandler; diff --git a/src/app/pages/OfflinePage/OfflinePage.tsx b/src/app/pages/OfflinePage/OfflinePage.tsx index 3bf05572022..7da044f9697 100644 --- a/src/app/pages/OfflinePage/OfflinePage.tsx +++ b/src/app/pages/OfflinePage/OfflinePage.tsx @@ -5,17 +5,10 @@ import { ServiceContext } from '#contexts/ServiceContext'; import ErrorMain from '#components/ErrorMain'; const OfflinePage = () => { - const { - service: contextService, - dir, - script, - translations, - } = use(ServiceContext); + const { service, dir, script, translations } = use(ServiceContext); const message = "Seems like you don't have an internet connection at the moment. Please check your connection and reload the page."; - const service = contextService; - const title = path(['offline', 'title'], translations) || 'You are offline.'; return ( From 1dffbf58502dc233436d564a7e54b4e40288df28 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Wed, 23 Jul 2025 20:16:01 +0300 Subject: [PATCH 10/95] update tests --- src/sw.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sw.test.js b/src/sw.test.js index cce645fe5c9..ae50c08dc6d 100644 --- a/src/sw.test.js +++ b/src/sw.test.js @@ -292,7 +292,7 @@ describe('Service Worker', () => { describe('version', () => { const CURRENT_VERSION = { number: 'v0.3.0', - fileContentHash: 'dd9480d41ee91fb903627ec5bd0e33bc', + fileContentHash: '495e2b94311fc81db9a20c253ffd2f4f', }; it(`version number should be ${CURRENT_VERSION.number}`, async () => { From 049e8940cad8655e42094b089885fe3088431fd7 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Fri, 28 Nov 2025 15:25:21 +0200 Subject: [PATCH 11/95] resolving a conflicts --- src/app/contexts/EventTrackingContext/index.tsx | 1 + src/app/routes/offline/index.js | 12 ++++++++++++ src/app/routes/utils/constructPageFetchUrl/index.ts | 1 + src/app/routes/utils/pageTypes.ts | 1 + src/app/routes/utils/regex/index.js | 8 ++++---- 5 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 src/app/routes/offline/index.js diff --git a/src/app/contexts/EventTrackingContext/index.tsx b/src/app/contexts/EventTrackingContext/index.tsx index 020ced633b6..dc8b55883d6 100644 --- a/src/app/contexts/EventTrackingContext/index.tsx +++ b/src/app/contexts/EventTrackingContext/index.tsx @@ -23,6 +23,7 @@ import { AUDIO_PAGE, OFFLINE_PAGE, LIVE_TV_PAGE, + OFFLINE_PAGE, } from '../../routes/utils/pageTypes'; import { PageTypes } from '../../models/types/global'; import { EventTrackingContextProps } from '../../models/types/eventTracking'; diff --git a/src/app/routes/offline/index.js b/src/app/routes/offline/index.js new file mode 100644 index 00000000000..242457bf4f8 --- /dev/null +++ b/src/app/routes/offline/index.js @@ -0,0 +1,12 @@ +import { OfflinePage } from '#pages'; +import { offlinePagePath } from '#app/routes/utils/regex'; +import { OFFLINE_PAGE } from '#app/routes/utils/pageTypes'; +import getInitialData from './getInitialData'; + +export default { + path: offlinePagePath, + exact: true, + component: OfflinePage, + getInitialData, + pageType: OFFLINE_PAGE, +}; diff --git a/src/app/routes/utils/constructPageFetchUrl/index.ts b/src/app/routes/utils/constructPageFetchUrl/index.ts index e67f0d34406..aa3ffb2fd18 100644 --- a/src/app/routes/utils/constructPageFetchUrl/index.ts +++ b/src/app/routes/utils/constructPageFetchUrl/index.ts @@ -24,6 +24,7 @@ import { UGC_PAGE, OFFLINE_PAGE, LIVE_TV_PAGE, + OFFLINE_PAGE, } from '../pageTypes'; import parseRoute from '../parseRoute'; diff --git a/src/app/routes/utils/pageTypes.ts b/src/app/routes/utils/pageTypes.ts index a2ebeb637d9..5f4318e06fa 100644 --- a/src/app/routes/utils/pageTypes.ts +++ b/src/app/routes/utils/pageTypes.ts @@ -19,3 +19,4 @@ export const AUDIO_PAGE = 'audio' as const; export const TV_PAGE = 'tv' as const; export const OFFLINE_PAGE = 'offline' as const; export const LIVE_TV_PAGE = 'liveTV' as const; +export const OFFLINE_PAGE = 'offline' as const; diff --git a/src/app/routes/utils/regex/index.js b/src/app/routes/utils/regex/index.js index 190cd0ce889..8c8e77a3355 100644 --- a/src/app/routes/utils/regex/index.js +++ b/src/app/routes/utils/regex/index.js @@ -23,11 +23,11 @@ import { export const articlePath = getArticleRegex(SERVICES); export const articleDataPath = `${articlePath}.json`; -export const offlinePagePath = getOfflinePageRegex(allServices); +export const offlinePagePath = getOfflinePageRegex(SERVICES); -export const homePageSwPath = getSwRegex(allServices); -export const homePageManifestPath = getManifestRegex(allServices); -export const homePagePath = getHomePageRegex(allServices); +export const homePageSwPath = getSwRegex(SERVICES); +export const homePageManifestPath = getManifestRegex(SERVICES); +export const homePagePath = getHomePageRegex(SERVICES); export const homePageDataPath = `${homePagePath}.json`; export const cpsAssetPagePath = getCpsAssetRegex(SERVICES); From 7b7209008f90426bd6f16ecf11cc6cecfb87258f Mon Sep 17 00:00:00 2001 From: skumid01 Date: Fri, 28 Nov 2025 15:26:07 +0200 Subject: [PATCH 12/95] resolving a conflicts --- scripts/bundleSize/bundleSizeConfig.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/bundleSize/bundleSizeConfig.js b/scripts/bundleSize/bundleSizeConfig.js index e67234d0039..0ceb17a3b4a 100644 --- a/scripts/bundleSize/bundleSizeConfig.js +++ b/scripts/bundleSize/bundleSizeConfig.js @@ -7,7 +7,9 @@ * We are allowing a variance of -5 on `MIN_SIZE` and +5 on `MAX_SIZE` to avoid the need for frequent changes, as bundle sizes can fluctuate */ -export const VARIANCE = 5; +const MIN = 954; +const MAX = 1265; -export const MIN_SIZE = 906; -export const MAX_SIZE = 1253; +const VARIANCE = 5; +export const MIN_SIZE = MIN - VARIANCE; +export const MAX_SIZE = MAX + VARIANCE; From 9e749a404abbc9aa999cf7242f52b573185a7afe Mon Sep 17 00:00:00 2001 From: skumid01 Date: Fri, 28 Nov 2025 15:27:12 +0200 Subject: [PATCH 13/95] resolving a conflicts --- src/app/routes/utils/pageTypes.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/routes/utils/pageTypes.ts b/src/app/routes/utils/pageTypes.ts index 5f4318e06fa..a2ebeb637d9 100644 --- a/src/app/routes/utils/pageTypes.ts +++ b/src/app/routes/utils/pageTypes.ts @@ -19,4 +19,3 @@ export const AUDIO_PAGE = 'audio' as const; export const TV_PAGE = 'tv' as const; export const OFFLINE_PAGE = 'offline' as const; export const LIVE_TV_PAGE = 'liveTV' as const; -export const OFFLINE_PAGE = 'offline' as const; From 1c7d92afde5c87ea1b416e6f5a69188eb9fa56dc Mon Sep 17 00:00:00 2001 From: skumid01 Date: Fri, 28 Nov 2025 15:27:52 +0200 Subject: [PATCH 14/95] resolving a conflicts --- src/app/contexts/EventTrackingContext/index.tsx | 1 - src/app/routes/utils/constructPageFetchUrl/index.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/app/contexts/EventTrackingContext/index.tsx b/src/app/contexts/EventTrackingContext/index.tsx index dc8b55883d6..020ced633b6 100644 --- a/src/app/contexts/EventTrackingContext/index.tsx +++ b/src/app/contexts/EventTrackingContext/index.tsx @@ -23,7 +23,6 @@ import { AUDIO_PAGE, OFFLINE_PAGE, LIVE_TV_PAGE, - OFFLINE_PAGE, } from '../../routes/utils/pageTypes'; import { PageTypes } from '../../models/types/global'; import { EventTrackingContextProps } from '../../models/types/eventTracking'; diff --git a/src/app/routes/utils/constructPageFetchUrl/index.ts b/src/app/routes/utils/constructPageFetchUrl/index.ts index aa3ffb2fd18..e67f0d34406 100644 --- a/src/app/routes/utils/constructPageFetchUrl/index.ts +++ b/src/app/routes/utils/constructPageFetchUrl/index.ts @@ -24,7 +24,6 @@ import { UGC_PAGE, OFFLINE_PAGE, LIVE_TV_PAGE, - OFFLINE_PAGE, } from '../pageTypes'; import parseRoute from '../parseRoute'; From 7b90ce46bab538d5dd73b2844a064b2e1c679ea2 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Mon, 1 Dec 2025 23:24:17 +0200 Subject: [PATCH 15/95] WS-1840 updating offline page for nextjs --- public/sw.js | 142 +++- src/app/lib/config/services/ws.ts | 2 + src/app/pages/OfflinePage/OfflinePage.tsx | 37 -- .../__snapshots__/index.test.jsx.snap | 619 ------------------ src/app/pages/OfflinePage/index.tsx | 10 - src/app/pages/index.js | 1 - src/app/routes/index.js | 2 - .../routes/offline/getInitialData/index.js | 35 - src/app/routes/offline/index.js | 12 - src/app/routes/offline/index.tsx | 12 - src/app/routes/utils/regex/index.js | 3 - src/app/routes/utils/regex/utils/index.js | 5 - .../pages/[service]/offline.page.tsx | 45 ++ .../[service]/offline/OfflinePage.test.tsx | 13 +- .../pages/[service]/offline/OfflinePage.tsx | 37 ++ .../utilities/derivePageType/index.test.ts | 37 +- .../utilities/derivePageType/index.ts | 2 + 17 files changed, 255 insertions(+), 759 deletions(-) delete mode 100644 src/app/pages/OfflinePage/OfflinePage.tsx delete mode 100644 src/app/pages/OfflinePage/__snapshots__/index.test.jsx.snap delete mode 100644 src/app/pages/OfflinePage/index.tsx delete mode 100644 src/app/routes/offline/getInitialData/index.js delete mode 100644 src/app/routes/offline/index.js delete mode 100644 src/app/routes/offline/index.tsx create mode 100644 ws-nextjs-app/pages/[service]/offline.page.tsx rename src/app/pages/OfflinePage/index.test.jsx => ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx (62%) create mode 100644 ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx diff --git a/public/sw.js b/public/sw.js index f34b9055861..0c3f681820e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -3,19 +3,24 @@ /* eslint-disable no-unused-vars */ /* eslint-disable no-undef */ /* eslint-disable no-restricted-globals */ -const version = 'v0.3.0'; -const cacheName = 'simorghCache_v1'; +const version = 'v1.0.0'; +const cacheName = 'simorghCache_v3'; const service = self.location.pathname.split('/')[1]; const hasOfflinePageFunctionality = true; const OFFLINE_PAGE = `/${service}/offline`; self.addEventListener('install', event => { + console.log( + '[SW] Installing service worker, caching offline page:', + OFFLINE_PAGE, + ); event.waitUntil( (async () => { const cache = await caches.open(cacheName); if (hasOfflinePageFunctionality) { const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin).href; + console.log('[SW] Fetching offline page for cache:', offlinePageUrl); try { const response = await fetch(offlinePageUrl); if (!response || !response.ok) { @@ -23,10 +28,52 @@ self.addEventListener('install', event => { `Failed to fetch offline page: ${response.status} ${response.statusText}`, ); } - await cache.put(offlinePageUrl, response); + // Cache the offline page HTML + await cache.put(offlinePageUrl, response.clone()); + + // Parse HTML to extract and cache all script/link resources + const html = await response.text(); + const scriptMatches = html.matchAll( + /]+src=["']([^"']+)["']/g, + ); + const linkMatches = html.matchAll(/]+href=["']([^"']+)["']/g); + + const resourcesToCache = [ + ...Array.from(scriptMatches, m => m[1]), + ...Array.from(linkMatches, m => m[1]), + ].filter(r => r.startsWith('/') || r.startsWith('http://localhost')); + + console.log( + '[SW] Caching', + resourcesToCache.length, + 'offline page resources', + ); + + // Cache all resources in parallel + await Promise.allSettled( + resourcesToCache.map(async resource => { + try { + const resourceUrl = new URL(resource, self.location.origin) + .href; + const resourceResponse = await fetch(resourceUrl); + if (resourceResponse && resourceResponse.ok) { + await cache.put(resourceUrl, resourceResponse); + } + } catch (err) { + console.log('[SW] Failed to cache:', resource); + } + }), + ); + + console.log( + '[SW] ✅ Offline page cached successfully:', + offlinePageUrl, + ); } catch (error) { // eslint-disable-next-line no-console - console.error(`Failed to cache offline page: ${error.message}`); + console.error( + `[SW] ❌ Failed to cache offline page: ${error.message}`, + ); } } })(), @@ -34,6 +81,26 @@ self.addEventListener('install', event => { self.skipWaiting(); }); +self.addEventListener('activate', event => { + console.log('[SW] Activating new service worker, cleaning old caches'); + event.waitUntil( + caches + .keys() + .then(cacheNames => { + return Promise.all( + cacheNames.map(cache => { + if (cache !== cacheName) { + console.log('[SW] Deleting old cache:', cache); + return caches.delete(cache); + } + return null; + }), + ); + }) + .then(() => self.clients.claim()), + ); +}); + const CACHEABLE_FILES = [ // Reverb /^https:\/\/static(?:\.test)?\.files\.bbci\.co\.uk\/ws\/(?:simorgh-assets|simorgh1-preview-assets|simorgh2-preview-assets)\/public\/static\/js\/reverb\/reverb-3.10.2.js$/, @@ -90,28 +157,73 @@ const fetchEventHandler = async event => { return response; })(), ); - } else if (hasOfflinePageFunctionality && event.request.mode === 'navigate') { + } else if ( + event.request.mode === 'navigate' || + event.request.destination === 'script' || + event.request.destination === 'style' + ) { + // Handle navigation requests and resource requests (JS, CSS) + console.log( + '[SW v0.5.0] Request intercepted:', + event.request.mode, + event.request.url, + ); event.respondWith( (async () => { + const cache = await caches.open(cacheName); + console.log('[SW v0.5.0] Using cache:', cacheName); + + // Check cache first + const cachedResponse = await cache.match(event.request); + if (cachedResponse) { + console.log( + '[SW v0.5.0] ✅ Found in cache, returning:', + event.request.url, + ); + return cachedResponse; + } + + console.log( + '[SW v0.5.0] Not in cache, trying network:', + event.request.url, + ); + try { - const preloadResponse = await event.preloadResponse; - if (preloadResponse) { - return preloadResponse; - } + // Try network const networkResponse = await fetch(event.request); + console.log( + '[SW v0.5.0] Network fetch successful, caching:', + event.request.url, + ); + cache.put(event.request, networkResponse.clone()); return networkResponse; } catch (error) { - // eslint-disable-next-line no-console console.error( - `Network request failed: ${error.message}, serving offline page`, + `[SW v0.5.0] ❌ Network failed: ${error.message} for:`, + event.request.url, ); - const cache = await caches.open(cacheName); + const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin) .href; - const cachedResponse = await cache.match(offlinePageUrl); - if (cachedResponse) { - return cachedResponse; + + // For navigation requests, serve offline page + if (event.request.mode === 'navigate') { + console.log( + '[SW v0.5.0] Looking for offline page:', + offlinePageUrl, + ); + const offlineResponse = await cache.match(offlinePageUrl); + if (offlineResponse) { + console.log('[SW v0.5.0] ✅ Serving offline page'); + return offlineResponse; + } + console.log('[SW v0.5.0] ⚠️ No offline page in cache!'); } + + console.log( + '[SW v0.5.0] ⚠️ No cache available for:', + event.request.url, + ); try { const freshOfflineResponse = await fetch(offlinePageUrl); if (freshOfflineResponse && freshOfflineResponse.ok) { diff --git a/src/app/lib/config/services/ws.ts b/src/app/lib/config/services/ws.ts index 209608ede6b..96e82ae24de 100644 --- a/src/app/lib/config/services/ws.ts +++ b/src/app/lib/config/services/ws.ts @@ -39,6 +39,8 @@ export const service: DefaultServiceConfig = { publishingPrinciples: 'https://www.bbc.com/news/help-41670342', isTrustProjectParticipant: true, script: latin, + manifestPath: '/ws/manifest.json', + swPath: '/sw.js', homePageTitle: 'Home', showAdPlaceholder: false, showRelatedTopics: true, diff --git a/src/app/pages/OfflinePage/OfflinePage.tsx b/src/app/pages/OfflinePage/OfflinePage.tsx deleted file mode 100644 index 7da044f9697..00000000000 --- a/src/app/pages/OfflinePage/OfflinePage.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { use } from 'react'; -import path from 'ramda/src/path'; -import Helmet from 'react-helmet'; -import { ServiceContext } from '#contexts/ServiceContext'; -import ErrorMain from '#components/ErrorMain'; - -const OfflinePage = () => { - const { service, dir, script, translations } = use(ServiceContext); - const message = - "Seems like you don't have an internet connection at the moment. Please check your connection and reload the page."; - - const title = path(['offline', 'title'], translations) || 'You are offline.'; - - return ( - <> - - {`${title}`} - - - - ); -}; - -export default OfflinePage; diff --git a/src/app/pages/OfflinePage/__snapshots__/index.test.jsx.snap b/src/app/pages/OfflinePage/__snapshots__/index.test.jsx.snap deleted file mode 100644 index 98d0382ebb7..00000000000 --- a/src/app/pages/OfflinePage/__snapshots__/index.test.jsx.snap +++ /dev/null @@ -1,619 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`OfflinePage should render correctly 1`] = ` -.emotion-1 { - width: 100%; - padding-bottom: 4rem; -} - -@supports (display: grid) { - .emotion-1 { - display: grid; - position: initial; - width: initial; - margin: 0; - } - - @media (max-width: 14.9375rem) { - .emotion-1 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - grid-column-gap: 0.5rem; - } - } - - @media (min-width: 15rem) and (max-width: 24.9375rem) { - .emotion-1 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - grid-column-gap: 0.5rem; - } - } - - @media (min-width: 25rem) and (max-width: 37.4375rem) { - .emotion-1 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - grid-column-gap: 0.5rem; - } - } - - @media (min-width: 37.5rem) and (max-width: 62.9375rem) { - .emotion-1 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - grid-column-gap: 1rem; - } - } - - @media (min-width: 63rem) and (max-width: 79.9375rem) { - .emotion-1 { - grid-template-columns: repeat(8, 1fr); - grid-column-end: span 8; - grid-column-gap: 1rem; - } - } - - @media (min-width: 80rem) { - .emotion-1 { - grid-template-columns: repeat(20, 1fr); - grid-column-end: span 20; - grid-column-gap: 1rem; - } - } -} - -@media (min-width: 63rem) and (max-width: 79.9375rem) { - .emotion-1 { - margin: 0 auto; - max-width: 63rem; - } -} - -@media (min-width: 80rem) { - .emotion-1 { - margin: 0 auto; - max-width: 80rem; - } -} - -@media (max-width: 14.9375rem) { - .emotion-3 { - padding: 0 0.5rem; - margin-left: 0%; - } -} - -@media (min-width: 15rem) and (max-width: 24.9375rem) { - .emotion-3 { - padding: 0 0.5rem; - margin-left: 0%; - } -} - -@media (min-width: 25rem) and (max-width: 37.4375rem) { - .emotion-3 { - padding: 0 1rem; - margin-left: 0%; - } -} - -@media (min-width: 37.5rem) and (max-width: 62.9375rem) { - .emotion-3 { - padding: 0 1rem; - margin-left: 0%; - } -} - -@media (min-width: 63rem) and (max-width: 79.9375rem) { - .emotion-3 { - margin-left: 16.666666666666668%; - } -} - -@media (min-width: 80rem) { - .emotion-3 { - margin-left: 33.333333333333336%; - } -} - -@supports (display: grid) { - .emotion-3 { - display: block; - width: initial; - margin: 0; - } - - @media (max-width: 14.9375rem) { - .emotion-3 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - padding: 0 0.5rem; - grid-column-start: 1; - } - } - - @media (min-width: 15rem) and (max-width: 24.9375rem) { - .emotion-3 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - padding: 0 0.5rem; - grid-column-start: 1; - } - } - - @media (min-width: 25rem) and (max-width: 37.4375rem) { - .emotion-3 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - padding: 0 1rem; - grid-column-start: 1; - } - } - - @media (min-width: 37.5rem) and (max-width: 62.9375rem) { - .emotion-3 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - padding: 0 1rem; - grid-column-start: 1; - } - } - - @media (min-width: 63rem) and (max-width: 79.9375rem) { - .emotion-3 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - grid-column-start: 2; - } - } - - @media (min-width: 80rem) { - .emotion-3 { - grid-template-columns: repeat(12, 1fr); - grid-column-end: span 12; - grid-column-start: 5; - } - } -} - -.emotion-5 { - font-size: 1.25rem; - line-height: 1.5rem; - color: #B80000; - display: block; - font-family: ReithSans,Helvetica,Arial,sans-serif; - font-weight: 600; - padding: 2.5rem 0 0.5rem 0; -} - -@media (min-width: 20rem) and (max-width: 37.4375rem) { - .emotion-5 { - font-size: 1.375rem; - line-height: 1.625rem; - } -} - -@media (min-width: 37.5rem) { - .emotion-5 { - font-size: 1.75rem; - line-height: 2rem; - } -} - -.emotion-7 { - font-size: 1.75rem; - line-height: 2rem; - font-family: ReithSerif,Helvetica,Arial,sans-serif; - font-weight: 500; - font-style: normal; - color: #3F3F42; - margin-top: 0; -} - -@media (min-width: 20rem) and (max-width: 37.4375rem) { - .emotion-7 { - font-size: 2rem; - line-height: 2.25rem; - } -} - -@media (min-width: 37.5rem) { - .emotion-7 { - font-size: 2.75rem; - line-height: 3rem; - } -} - -.emotion-9 { - font-size: 0.9375rem; - line-height: 1.25rem; - font-family: ReithSans,Helvetica,Arial,sans-serif; - font-weight: 400; - font-style: normal; - color: #141414; - padding-bottom: 1.5rem; - margin: 0; - padding-top: 0.2rem; -} - -@media (min-width: 20rem) and (max-width: 37.4375rem) { - .emotion-9 { - font-size: 1rem; - line-height: 1.375rem; - } -} - -@media (min-width: 37.5rem) { - .emotion-9 { - font-size: 1rem; - line-height: 1.375rem; - } -} - -.emotion-13 { - color: #222222; - border-bottom: 1px solid #B80000; - -webkit-text-decoration: none; - text-decoration: none; -} - -.emotion-13:visited { - color: #6E6E73; - border-bottom: 1px solid #6E6E73; -} - -.emotion-13:focus, -.emotion-13:hover { - border-bottom: 2px solid #B80000; - color: #B80000; -} - - -`; - -exports[`OfflinePage should render correctly for mundo service 1`] = ` -.emotion-1 { - width: 100%; - padding-bottom: 4rem; -} - -@supports (display: grid) { - .emotion-1 { - display: grid; - position: initial; - width: initial; - margin: 0; - } - - @media (max-width: 14.9375rem) { - .emotion-1 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - grid-column-gap: 0.5rem; - } - } - - @media (min-width: 15rem) and (max-width: 24.9375rem) { - .emotion-1 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - grid-column-gap: 0.5rem; - } - } - - @media (min-width: 25rem) and (max-width: 37.4375rem) { - .emotion-1 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - grid-column-gap: 0.5rem; - } - } - - @media (min-width: 37.5rem) and (max-width: 62.9375rem) { - .emotion-1 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - grid-column-gap: 1rem; - } - } - - @media (min-width: 63rem) and (max-width: 79.9375rem) { - .emotion-1 { - grid-template-columns: repeat(8, 1fr); - grid-column-end: span 8; - grid-column-gap: 1rem; - } - } - - @media (min-width: 80rem) { - .emotion-1 { - grid-template-columns: repeat(20, 1fr); - grid-column-end: span 20; - grid-column-gap: 1rem; - } - } -} - -@media (min-width: 63rem) and (max-width: 79.9375rem) { - .emotion-1 { - margin: 0 auto; - max-width: 63rem; - } -} - -@media (min-width: 80rem) { - .emotion-1 { - margin: 0 auto; - max-width: 80rem; - } -} - -@media (max-width: 14.9375rem) { - .emotion-3 { - padding: 0 0.5rem; - margin-left: 0%; - } -} - -@media (min-width: 15rem) and (max-width: 24.9375rem) { - .emotion-3 { - padding: 0 0.5rem; - margin-left: 0%; - } -} - -@media (min-width: 25rem) and (max-width: 37.4375rem) { - .emotion-3 { - padding: 0 1rem; - margin-left: 0%; - } -} - -@media (min-width: 37.5rem) and (max-width: 62.9375rem) { - .emotion-3 { - padding: 0 1rem; - margin-left: 0%; - } -} - -@media (min-width: 63rem) and (max-width: 79.9375rem) { - .emotion-3 { - margin-left: 16.666666666666668%; - } -} - -@media (min-width: 80rem) { - .emotion-3 { - margin-left: 33.333333333333336%; - } -} - -@supports (display: grid) { - .emotion-3 { - display: block; - width: initial; - margin: 0; - } - - @media (max-width: 14.9375rem) { - .emotion-3 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - padding: 0 0.5rem; - grid-column-start: 1; - } - } - - @media (min-width: 15rem) and (max-width: 24.9375rem) { - .emotion-3 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - padding: 0 0.5rem; - grid-column-start: 1; - } - } - - @media (min-width: 25rem) and (max-width: 37.4375rem) { - .emotion-3 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - padding: 0 1rem; - grid-column-start: 1; - } - } - - @media (min-width: 37.5rem) and (max-width: 62.9375rem) { - .emotion-3 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - padding: 0 1rem; - grid-column-start: 1; - } - } - - @media (min-width: 63rem) and (max-width: 79.9375rem) { - .emotion-3 { - grid-template-columns: repeat(6, 1fr); - grid-column-end: span 6; - grid-column-start: 2; - } - } - - @media (min-width: 80rem) { - .emotion-3 { - grid-template-columns: repeat(12, 1fr); - grid-column-end: span 12; - grid-column-start: 5; - } - } -} - -.emotion-5 { - font-size: 1.25rem; - line-height: 1.625rem; - color: #B80000; - display: block; - font-family: ReithSans,Helvetica,Arial,sans-serif; - font-weight: 600; - padding: 2.5rem 0 0.5rem 0; -} - -@media (min-width: 20rem) and (max-width: 37.4375rem) { - .emotion-5 { - font-size: 1.375rem; - line-height: 1.875rem; - } -} - -@media (min-width: 37.5rem) { - .emotion-5 { - font-size: 1.75rem; - line-height: 2.375rem; - } -} - -.emotion-7 { - font-size: 1.75rem; - line-height: 2.25rem; - font-family: ReithSerif,Helvetica,Arial,sans-serif; - font-weight: 500; - font-style: normal; - color: #3F3F42; - margin-top: 0; -} - -@media (min-width: 20rem) and (max-width: 37.4375rem) { - .emotion-7 { - font-size: 2rem; - line-height: 2.625rem; - } -} - -@media (min-width: 37.5rem) { - .emotion-7 { - font-size: 2.75rem; - line-height: 3.625rem; - } -} - -.emotion-9 { - font-size: 0.9375rem; - line-height: 1.25rem; - font-family: ReithSans,Helvetica,Arial,sans-serif; - font-weight: 400; - font-style: normal; - color: #141414; - padding-bottom: 1.5rem; - margin: 0; - padding-top: 0.2rem; -} - -@media (min-width: 20rem) and (max-width: 37.4375rem) { - .emotion-9 { - font-size: 1rem; - line-height: 1.375rem; - } -} - -@media (min-width: 37.5rem) { - .emotion-9 { - font-size: 1rem; - line-height: 1.375rem; - } -} - -.emotion-13 { - color: #222222; - border-bottom: 1px solid #B80000; - -webkit-text-decoration: none; - text-decoration: none; -} - -.emotion-13:visited { - color: #6E6E73; - border-bottom: 1px solid #6E6E73; -} - -.emotion-13:focus, -.emotion-13:hover { - border-bottom: 2px solid #B80000; - color: #B80000; -} - - -`; diff --git a/src/app/pages/OfflinePage/index.tsx b/src/app/pages/OfflinePage/index.tsx deleted file mode 100644 index 1ad56e31ed7..00000000000 --- a/src/app/pages/OfflinePage/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import withOptimizelyProvider from '#app/legacy/containers/PageHandlers/withOptimizelyProvider'; -import OfflinePageComponent from './OfflinePage'; -import applyBasicPageHandlers from '../utils/applyBasicPageHandlers'; - -const EnhancedOfflinePage = applyBasicPageHandlers(OfflinePageComponent, { - handlerBeforeContexts: withOptimizelyProvider, -}); - -export const OfflinePage = EnhancedOfflinePage; -export default EnhancedOfflinePage; diff --git a/src/app/pages/index.js b/src/app/pages/index.js index 95aa6dd4dd5..3a1e3e16818 100644 --- a/src/app/pages/index.js +++ b/src/app/pages/index.js @@ -9,4 +9,3 @@ export const LiveRadioPage = loadable(() => import('./LiveRadioPage')); export const OnDemandAudioPage = loadable(() => import('./OnDemandAudioPage')); export const OnDemandTvPage = loadable(() => import('./OnDemandTvPage')); export const TopicPage = loadable(() => import('./TopicPage')); -export const OfflinePage = loadable(() => import('./OfflinePage')); diff --git a/src/app/routes/index.js b/src/app/routes/index.js index 30ec405e46c..1f40420575f 100644 --- a/src/app/routes/index.js +++ b/src/app/routes/index.js @@ -8,10 +8,8 @@ import onDemandTV from './onDemandTV'; import topic from './topic'; import error from './error'; import errorNoRouteMatch from './errorNoRouteMatch'; -import offline from './offline'; export default [ - offline, homePage, liveRadio, mostRead, diff --git a/src/app/routes/offline/getInitialData/index.js b/src/app/routes/offline/getInitialData/index.js deleted file mode 100644 index a5666de6686..00000000000 --- a/src/app/routes/offline/getInitialData/index.js +++ /dev/null @@ -1,35 +0,0 @@ -import fetchPageData from '#app/routes/utils/fetchPageData'; -import getConfig from '#app/routes/utils/getConfig'; - -export default async ({ service, variant, pathname }) => { - const config = await getConfig(service, variant); - - try { - const { status } = pathname - ? await fetchPageData({ - path: pathname, - service, - }) - : { status: 200 }; - - return { - status, - pageData: { - metadata: { - type: 'offline', - serviceConfig: config, - }, - }, - }; - } catch (error) { - return { - status: 200, - pageData: { - metadata: { - type: 'offline', - serviceConfig: config, - }, - }, - }; - } -}; diff --git a/src/app/routes/offline/index.js b/src/app/routes/offline/index.js deleted file mode 100644 index 242457bf4f8..00000000000 --- a/src/app/routes/offline/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import { OfflinePage } from '#pages'; -import { offlinePagePath } from '#app/routes/utils/regex'; -import { OFFLINE_PAGE } from '#app/routes/utils/pageTypes'; -import getInitialData from './getInitialData'; - -export default { - path: offlinePagePath, - exact: true, - component: OfflinePage, - getInitialData, - pageType: OFFLINE_PAGE, -}; diff --git a/src/app/routes/offline/index.tsx b/src/app/routes/offline/index.tsx deleted file mode 100644 index b997f9469fa..00000000000 --- a/src/app/routes/offline/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { offlinePagePath } from '#app/routes/utils/regex'; -import { OFFLINE_PAGE } from '#app/routes/utils/pageTypes'; -import { OfflinePage } from '#app/pages'; -import getInitialData from './getInitialData'; - -export default { - path: offlinePagePath, - exact: true, - component: OfflinePage, - getInitialData, - pageType: OFFLINE_PAGE, -}; diff --git a/src/app/routes/utils/regex/index.js b/src/app/routes/utils/regex/index.js index 8c8e77a3355..73be7edb310 100644 --- a/src/app/routes/utils/regex/index.js +++ b/src/app/routes/utils/regex/index.js @@ -17,14 +17,11 @@ import { getMostReadDataRegex, getSecondaryColumnDataRegex, getAfricaEyeTVPageRegex, - getOfflinePageRegex, } from './utils'; export const articlePath = getArticleRegex(SERVICES); export const articleDataPath = `${articlePath}.json`; -export const offlinePagePath = getOfflinePageRegex(SERVICES); - export const homePageSwPath = getSwRegex(SERVICES); export const homePageManifestPath = getManifestRegex(SERVICES); export const homePagePath = getHomePageRegex(SERVICES); diff --git a/src/app/routes/utils/regex/utils/index.js b/src/app/routes/utils/regex/utils/index.js index 60ee3545b78..e7856216f5d 100644 --- a/src/app/routes/utils/regex/utils/index.js +++ b/src/app/routes/utils/regex/utils/index.js @@ -36,11 +36,6 @@ const getWorldServices = services => { return services.filter(service => !publicServices.includes(service)); }; -export const getOfflinePageRegex = services => { - const serviceRegex = getServiceRegex(services); - return `/:service(${serviceRegex})/offline`; -}; - export const getHomePageRegex = services => { const homePageServiceRegex = getServiceRegex(services); return `/:service(${homePageServiceRegex}):variant(${variantRegex})?:lite(${liteRegex})?`; diff --git a/ws-nextjs-app/pages/[service]/offline.page.tsx b/ws-nextjs-app/pages/[service]/offline.page.tsx new file mode 100644 index 00000000000..32bede33c24 --- /dev/null +++ b/ws-nextjs-app/pages/[service]/offline.page.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import dynamic from 'next/dynamic'; +import { GetServerSideProps } from 'next'; +import { OFFLINE_PAGE } from '#app/routes/utils/pageTypes'; +import PageDataParams from '#app/models/types/pageDataParams'; +import deriveVariant from '#nextjs/utilities/deriveVariant'; +import extractHeaders from '#server/utilities/extractHeaders'; +import logResponseTime from '#server/utilities/logResponseTime'; +import getToggles from '#app/lib/utilities/getToggles/withCache'; + +const OfflinePage = dynamic(() => import('./offline/OfflinePage')); + +export const getServerSideProps: GetServerSideProps = async context => { + const { service, variant: variantFromUrl } = context.query as PageDataParams; + const variant = deriveVariant(variantFromUrl); + + logResponseTime({ path: context.resolvedUrl }, context.res, () => null); + + // Set cache headers for offline page - cache aggressively since content is static + context.res.setHeader( + 'Cache-Control', + 'public, max-age=300, stale-while-revalidate=600, stale-if-error=3600', + ); + + const toggles = await getToggles(service); + + return { + props: { + service, + variant, + toggles, + pageType: OFFLINE_PAGE, + isNextJs: true, + isAmp: false, + status: 200, + timeOnServer: Date.now(), + pathname: `/${service}/offline`, + ...extractHeaders(context.req.headers), + }, + }; +}; + +export default function OfflinePageRoute() { + return ; +} diff --git a/src/app/pages/OfflinePage/index.test.jsx b/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx similarity index 62% rename from src/app/pages/OfflinePage/index.test.jsx rename to ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx index c7477e8ae38..442130cc27b 100644 --- a/src/app/pages/OfflinePage/index.test.jsx +++ b/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx @@ -3,7 +3,7 @@ import OfflinePage from './OfflinePage'; import { render, screen, -} from '../../components/react-testing-library-with-providers'; +} from '#app/components/react-testing-library-with-providers'; describe('OfflinePage', () => { it('should render correctly', () => { @@ -20,16 +20,19 @@ describe('OfflinePage', () => { expect(container).toMatchSnapshot(); }); - it('should use fallback values when translations are missing', () => { + it('should display offline message and solutions', () => { render(, { service: 'news', - translations: {}, }); - expect(screen.getByText('You are offline.')).toBeInTheDocument(); + expect(screen.getByText('You are offline')).toBeInTheDocument(); expect( - screen.getByText(/Seems like you don't have an internet connection/), + screen.getByText(/It seems you don't have an internet connection/), + ).toBeInTheDocument(); + + expect( + screen.getByText('Check your internet connection'), ).toBeInTheDocument(); }); }); diff --git a/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx b/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx new file mode 100644 index 00000000000..8761b1abe1f --- /dev/null +++ b/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx @@ -0,0 +1,37 @@ +import React, { use } from 'react'; +import Helmet from 'react-helmet'; +import { ServiceContext } from '#contexts/ServiceContext'; +import ErrorMain from '#app/legacy/components/ErrorMain'; + +const OfflinePage = () => { + const { service, dir, script } = use(ServiceContext); + + // Static offline page content - should not depend on network-loaded translations + 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."; + const solutions = [ + 'Check your internet connection', + 'Refresh the page when your connection is restored', + ]; + + return ( + <> + + {title} + + + + ); +}; + +export default OfflinePage; diff --git a/ws-nextjs-app/utilities/derivePageType/index.test.ts b/ws-nextjs-app/utilities/derivePageType/index.test.ts index 6259b26a06b..3ba0cd34aca 100644 --- a/ws-nextjs-app/utilities/derivePageType/index.test.ts +++ b/ws-nextjs-app/utilities/derivePageType/index.test.ts @@ -1,7 +1,20 @@ -import { LIVE_PAGE, UGC_PAGE } from '#app/routes/utils/pageTypes'; +import { + ARTICLE_PAGE, + AV_EMBEDS, + DOWNLOADS_PAGE, + LIVE_PAGE, + OFFLINE_PAGE, + UGC_PAGE, +} from '#app/routes/utils/pageTypes'; import derivePageType from '.'; describe('derivePageType', () => { + it("should return OFFLINE_PAGE if pathname includes 'offline'", () => { + const pathname = '/news/offline'; + const result = derivePageType(pathname); + expect(result).toEqual(OFFLINE_PAGE); + }); + it("should return UGC_PAGE if pathname includes 'send'", () => { const pathname = '/burmese/send/xxxxxxxxx'; const result = derivePageType(pathname); @@ -14,13 +27,31 @@ describe('derivePageType', () => { expect(result).toEqual(LIVE_PAGE); }); - it('should return Unknown if pathname does not include live or send', () => { + it("should return AV_EMBEDS if pathname includes 'av-embeds'", () => { + const pathname = '/news/av-embeds/xxxxxxxxx'; + const result = derivePageType(pathname); + expect(result).toEqual(AV_EMBEDS); + }); + + it("should return DOWNLOADS_PAGE if pathname includes 'downloads'", () => { + const pathname = '/korean/downloads'; + const result = derivePageType(pathname); + expect(result).toEqual(DOWNLOADS_PAGE); + }); + + it('should return ARTICLE_PAGE for Optimo article IDs', () => { + const pathname = '/news/articles/c0123456789o'; + const result = derivePageType(pathname); + expect(result).toEqual(ARTICLE_PAGE); + }); + + it('should return Unknown if pathname does not match any pattern', () => { const pathname = '/burmese/xxxxxxxxx'; const result = derivePageType(pathname); expect(result).toEqual('Unknown'); }); - it('should strip our query params from the pathname', () => { + it('should strip query params from the pathname', () => { const pathname = '/burmese/live/xxxxxxxxx?foo=bar'; const result = derivePageType(pathname); expect(result).toEqual(LIVE_PAGE); diff --git a/ws-nextjs-app/utilities/derivePageType/index.ts b/ws-nextjs-app/utilities/derivePageType/index.ts index d0441759b37..a13a0364259 100644 --- a/ws-nextjs-app/utilities/derivePageType/index.ts +++ b/ws-nextjs-app/utilities/derivePageType/index.ts @@ -4,6 +4,7 @@ import { AV_EMBEDS, DOWNLOADS_PAGE, LIVE_PAGE, + OFFLINE_PAGE, UGC_PAGE, } from '#app/routes/utils/pageTypes'; import { @@ -20,6 +21,7 @@ export default function derivePageType( 'http://bbc.com', ).pathname; + if (sanitisedPathname.includes('offline')) return OFFLINE_PAGE; if (sanitisedPathname.includes('live')) return LIVE_PAGE; if (sanitisedPathname.includes('send')) return UGC_PAGE; if (sanitisedPathname.includes('av-embeds')) return AV_EMBEDS; From 94efa25ed3ae1d50a6445b462ac18ab40015c6a1 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Tue, 2 Dec 2025 13:25:16 +0200 Subject: [PATCH 16/95] updates --- public/sw.js | 158 +++++++-------------------------------------------- 1 file changed, 22 insertions(+), 136 deletions(-) diff --git a/public/sw.js b/public/sw.js index 0c3f681820e..4c6eb5c1dc6 100644 --- a/public/sw.js +++ b/public/sw.js @@ -3,32 +3,25 @@ /* eslint-disable no-unused-vars */ /* eslint-disable no-undef */ /* eslint-disable no-restricted-globals */ -const version = 'v1.0.0'; -const cacheName = 'simorghCache_v3'; +const version = 'v0.3.0'; +const cacheName = 'simorghCache_v1'; const service = self.location.pathname.split('/')[1]; const hasOfflinePageFunctionality = true; const OFFLINE_PAGE = `/${service}/offline`; self.addEventListener('install', event => { - console.log( - '[SW] Installing service worker, caching offline page:', - OFFLINE_PAGE, - ); event.waitUntil( (async () => { const cache = await caches.open(cacheName); if (hasOfflinePageFunctionality) { - const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin).href; - console.log('[SW] Fetching offline page for cache:', offlinePageUrl); try { + const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin) + .href; const response = await fetch(offlinePageUrl); if (!response || !response.ok) { - throw new Error( - `Failed to fetch offline page: ${response.status} ${response.statusText}`, - ); + throw new Error('Failed to fetch offline page'); } - // Cache the offline page HTML await cache.put(offlinePageUrl, response.clone()); // Parse HTML to extract and cache all script/link resources @@ -41,12 +34,8 @@ self.addEventListener('install', event => { const resourcesToCache = [ ...Array.from(scriptMatches, m => m[1]), ...Array.from(linkMatches, m => m[1]), - ].filter(r => r.startsWith('/') || r.startsWith('http://localhost')); - - console.log( - '[SW] Caching', - resourcesToCache.length, - 'offline page resources', + ].filter( + r => r.startsWith('/') || r.startsWith(self.location.origin), ); // Cache all resources in parallel @@ -60,20 +49,13 @@ self.addEventListener('install', event => { await cache.put(resourceUrl, resourceResponse); } } catch (err) { - console.log('[SW] Failed to cache:', resource); + // Resource failed to cache, continue } }), ); - - console.log( - '[SW] ✅ Offline page cached successfully:', - offlinePageUrl, - ); } catch (error) { // eslint-disable-next-line no-console - console.error( - `[SW] ❌ Failed to cache offline page: ${error.message}`, - ); + console.error('Failed to cache offline page:', error.message); } } })(), @@ -81,26 +63,6 @@ self.addEventListener('install', event => { self.skipWaiting(); }); -self.addEventListener('activate', event => { - console.log('[SW] Activating new service worker, cleaning old caches'); - event.waitUntil( - caches - .keys() - .then(cacheNames => { - return Promise.all( - cacheNames.map(cache => { - if (cache !== cacheName) { - console.log('[SW] Deleting old cache:', cache); - return caches.delete(cache); - } - return null; - }), - ); - }) - .then(() => self.clients.claim()), - ); -}); - const CACHEABLE_FILES = [ // Reverb /^https:\/\/static(?:\.test)?\.files\.bbci\.co\.uk\/ws\/(?:simorgh-assets|simorgh1-preview-assets|simorgh2-preview-assets)\/public\/static\/js\/reverb\/reverb-3.10.2.js$/, @@ -157,104 +119,28 @@ const fetchEventHandler = async event => { return response; })(), ); - } else if ( - event.request.mode === 'navigate' || - event.request.destination === 'script' || - event.request.destination === 'style' - ) { - // Handle navigation requests and resource requests (JS, CSS) - console.log( - '[SW v0.5.0] Request intercepted:', - event.request.mode, - event.request.url, - ); + } else if (hasOfflinePageFunctionality && event.request.mode === 'navigate') { event.respondWith( (async () => { - const cache = await caches.open(cacheName); - console.log('[SW v0.5.0] Using cache:', cacheName); - - // Check cache first - const cachedResponse = await cache.match(event.request); - if (cachedResponse) { - console.log( - '[SW v0.5.0] ✅ Found in cache, returning:', - event.request.url, - ); - return cachedResponse; - } - - console.log( - '[SW v0.5.0] Not in cache, trying network:', - event.request.url, - ); - try { - // Try network + const preloadResponse = await event.preloadResponse; + if (preloadResponse) { + return preloadResponse; + } const networkResponse = await fetch(event.request); - console.log( - '[SW v0.5.0] Network fetch successful, caching:', - event.request.url, - ); - cache.put(event.request, networkResponse.clone()); return networkResponse; } catch (error) { - console.error( - `[SW v0.5.0] ❌ Network failed: ${error.message} for:`, - event.request.url, - ); - + const cache = await caches.open(cacheName); const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin) .href; - - // For navigation requests, serve offline page - if (event.request.mode === 'navigate') { - console.log( - '[SW v0.5.0] Looking for offline page:', - offlinePageUrl, - ); - const offlineResponse = await cache.match(offlinePageUrl); - if (offlineResponse) { - console.log('[SW v0.5.0] ✅ Serving offline page'); - return offlineResponse; - } - console.log('[SW v0.5.0] ⚠️ No offline page in cache!'); - } - - console.log( - '[SW v0.5.0] ⚠️ No cache available for:', - event.request.url, - ); - try { - const freshOfflineResponse = await fetch(offlinePageUrl); - if (freshOfflineResponse && freshOfflineResponse.ok) { - const clonedResponse = freshOfflineResponse.clone(); - cache.put(offlinePageUrl, freshOfflineResponse); - return clonedResponse; - } - // eslint-disable-next-line no-console - console.error( - `Failed to fetch offline page, status: ${freshOfflineResponse ? freshOfflineResponse.status : 'unknown'}`, - ); - return new Response( - 'You are offline and the offline page could not be retrieved.', - { - status: 503, - headers: { 'Content-Type': 'text/plain' }, - }, - ); - } catch (offlineError) { - // eslint-disable-next-line no-console - console.error( - `Error fetching offline page: ${offlineError.message}`, - ); - return new Response( - 'You are offline and the offline page could not be retrieved.', - { - status: 503, - headers: { 'Content-Type': 'text/plain' }, - }, - ); + const cachedResponse = await cache.match(offlinePageUrl); + if (cachedResponse) { + return cachedResponse; } + return new Response('You are offline', { + status: 503, + headers: { 'Content-Type': 'text/plain' }, + }); } })(), ); From 3d1a82bcfae397598a349afe5b2a4f04efdffe71 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Wed, 3 Dec 2025 13:37:48 +0200 Subject: [PATCH 17/95] adding export to VARIANCE --- scripts/bundleSize/bundleSizeConfig.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/bundleSize/bundleSizeConfig.js b/scripts/bundleSize/bundleSizeConfig.js index 0ceb17a3b4a..b2dd6c51d66 100644 --- a/scripts/bundleSize/bundleSizeConfig.js +++ b/scripts/bundleSize/bundleSizeConfig.js @@ -10,6 +10,6 @@ const MIN = 954; const MAX = 1265; -const VARIANCE = 5; +export const VARIANCE = 5; export const MIN_SIZE = MIN - VARIANCE; export const MAX_SIZE = MAX + VARIANCE; From 05b114bc31bb69119650091ae565a8543ef6a892 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Wed, 3 Dec 2025 14:27:09 +0200 Subject: [PATCH 18/95] updating bundle size --- scripts/bundleSize/bundleSizeConfig.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/bundleSize/bundleSizeConfig.js b/scripts/bundleSize/bundleSizeConfig.js index b2dd6c51d66..72b52d8a432 100644 --- a/scripts/bundleSize/bundleSizeConfig.js +++ b/scripts/bundleSize/bundleSizeConfig.js @@ -7,8 +7,8 @@ * We are allowing a variance of -5 on `MIN_SIZE` and +5 on `MAX_SIZE` to avoid the need for frequent changes, as bundle sizes can fluctuate */ -const MIN = 954; -const MAX = 1265; +const MIN = 927; +const MAX = 1297; export const VARIANCE = 5; export const MIN_SIZE = MIN - VARIANCE; From 05530d725ceac9340ff4d7b6ddd52f55617957f4 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Wed, 3 Dec 2025 14:33:47 +0200 Subject: [PATCH 19/95] updating due to lint error --- ws-nextjs-app/pages/[service]/offline.page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ws-nextjs-app/pages/[service]/offline.page.tsx b/ws-nextjs-app/pages/[service]/offline.page.tsx index 32bede33c24..a87ea99d8f5 100644 --- a/ws-nextjs-app/pages/[service]/offline.page.tsx +++ b/ws-nextjs-app/pages/[service]/offline.page.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import dynamic from 'next/dynamic'; import { GetServerSideProps } from 'next'; import { OFFLINE_PAGE } from '#app/routes/utils/pageTypes'; From f3528dfdb18fe9e51b46c8400102af9842e36ca2 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Wed, 3 Dec 2025 14:58:46 +0200 Subject: [PATCH 20/95] updating due to lint error --- ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx b/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx index 8761b1abe1f..df72b9a930a 100644 --- a/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx +++ b/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx @@ -1,4 +1,4 @@ -import React, { use } from 'react'; +import { use } from 'react'; import Helmet from 'react-helmet'; import { ServiceContext } from '#contexts/ServiceContext'; import ErrorMain from '#app/legacy/components/ErrorMain'; From c83b4aa2e915a355c7da67bbf912a4d9950a8a34 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Wed, 3 Dec 2025 15:11:55 +0200 Subject: [PATCH 21/95] updating due to lint error --- .../routes/utils/regex/utils/__snapshots__/index.test.js.snap | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/routes/utils/regex/utils/__snapshots__/index.test.js.snap b/src/app/routes/utils/regex/utils/__snapshots__/index.test.js.snap index 9e984370a58..299352dda6c 100644 --- a/src/app/routes/utils/regex/utils/__snapshots__/index.test.js.snap +++ b/src/app/routes/utils/regex/utils/__snapshots__/index.test.js.snap @@ -20,8 +20,6 @@ exports[`regex utils snapshots should create expected regex from getMostReadData exports[`regex utils snapshots should create expected regex from getMostReadPageRegex 1`] = `"/:service(afaanoromoo|afrique|amharic|arabic|archive|azeri|bengali|burmese|cymrufyw|dari|gahuza|gujarati|hausa|hindi|igbo|indonesia|japanese|korean|kyrgyz|magyarul|marathi|mundo|naidheachdan|nepali|news|newsround|pashto|persian|pidgin|polska|portuguese|punjabi|romania|russian|scotland|serbian|sinhala|somali|sport|swahili|tamil|telugu|thai|tigrinya|turkce|ukchina|ukrainian|urdu|uzbek|vietnamese|ws|yoruba|zhongwen):variant(/simp|/trad|/cyr|/lat)?/popular/read:amp(.amp)?:lite(.lite)?"`; -exports[`regex utils snapshots should create expected regex from getOfflinePageRegex 1`] = `"/:service(afaanoromoo|afrique|amharic|arabic|archive|azeri|bengali|burmese|cymrufyw|gahuza|gujarati|hausa|hindi|igbo|indonesia|japanese|korean|kyrgyz|marathi|mundo|naidheachdan|nepali|news|newsround|pashto|persian|pidgin|polska|portuguese|punjabi|russian|scotland|serbian|sinhala|somali|sport|swahili|tamil|telugu|thai|tigrinya|turkce|ukchina|ukrainian|urdu|uzbek|vietnamese|ws|yoruba|zhongwen)/offline"`; - exports[`regex utils snapshots should create expected regex from getOnDemandRadioRegex 1`] = `"/:service(afaanoromoo|afrique|amharic|arabic|archive|azeri|bengali|burmese|cymrufyw|dari|gahuza|gujarati|hausa|hindi|igbo|indonesia|japanese|korean|kyrgyz|magyarul|marathi|mundo|naidheachdan|nepali|news|newsround|pashto|persian|pidgin|polska|portuguese|punjabi|romania|russian|scotland|serbian|sinhala|somali|sport|swahili|tamil|telugu|thai|tigrinya|turkce|ukchina|ukrainian|urdu|uzbek|vietnamese|ws|yoruba|zhongwen):variant(/simp|/trad|/cyr|/lat)?/:serviceId(bbc_[a-z]+_radio)(/programmes)?/:mediaId([a-z0-9]+):lite(.lite)?"`; exports[`regex utils snapshots should create expected regex from getOnDemandTvRegex 1`] = `"/:service(afaanoromoo|afrique|amharic|arabic|archive|azeri|bengali|burmese|cymrufyw|dari|gahuza|gujarati|hausa|hindi|igbo|indonesia|japanese|korean|kyrgyz|magyarul|marathi|mundo|naidheachdan|nepali|news|newsround|pashto|persian|pidgin|polska|portuguese|punjabi|romania|russian|scotland|serbian|sinhala|somali|sport|swahili|tamil|telugu|thai|tigrinya|turkce|ukchina|ukrainian|urdu|uzbek|vietnamese|ws|yoruba|zhongwen)/:serviceId(bbc_[a-z]+_tv)/:brandEpisode(tv|tv_programmes)/:mediaId([a-z0-9]+):lite(.lite)?"`; From 96408a94743a523730a3366084c637322642ed6d Mon Sep 17 00:00:00 2001 From: skumid01 Date: Wed, 3 Dec 2025 15:33:18 +0200 Subject: [PATCH 22/95] Update SW version to v0.3.1 and hash for offline page changes --- public/sw.js | 2 +- src/sw.test.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/public/sw.js b/public/sw.js index 4c6eb5c1dc6..1b81545b80b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -3,7 +3,7 @@ /* eslint-disable no-unused-vars */ /* eslint-disable no-undef */ /* eslint-disable no-restricted-globals */ -const version = 'v0.3.0'; +const version = 'v0.3.1'; const cacheName = 'simorghCache_v1'; const service = self.location.pathname.split('/')[1]; diff --git a/src/sw.test.js b/src/sw.test.js index fec58521761..10ab05434f4 100644 --- a/src/sw.test.js +++ b/src/sw.test.js @@ -275,8 +275,8 @@ describe('Service Worker', () => { describe('version', () => { const CURRENT_VERSION = { - number: 'v0.3.0', - fileContentHash: '6150d6daf3d64a226a47e17b39dfc084', + number: 'v0.3.1', + fileContentHash: '9b2083d7fb65f0308c17f44a812315fe', }; it(`version number should be ${CURRENT_VERSION.number}`, async () => { From 256ba1a4351c6f40de484da1a0f138f25caac58c Mon Sep 17 00:00:00 2001 From: skumid01 Date: Wed, 3 Dec 2025 16:02:55 +0200 Subject: [PATCH 23/95] Add offline page test snapshots --- .../offline/__snapshots__/OfflinePage.test.tsx.snap | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 ws-nextjs-app/pages/[service]/offline/__snapshots__/OfflinePage.test.tsx.snap diff --git a/ws-nextjs-app/pages/[service]/offline/__snapshots__/OfflinePage.test.tsx.snap b/ws-nextjs-app/pages/[service]/offline/__snapshots__/OfflinePage.test.tsx.snap new file mode 100644 index 00000000000..2e838d9f3ef --- /dev/null +++ b/ws-nextjs-app/pages/[service]/offline/__snapshots__/OfflinePage.test.tsx.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`OfflinePage should render correctly 1`] = `
`; + +exports[`OfflinePage should render correctly for mundo service 1`] = `
`; From 5a8fc1ccda96cf8708e4c900902c070fd3bc87f3 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Wed, 3 Dec 2025 17:51:23 +0200 Subject: [PATCH 24/95] Simplify SW install event - remove unnecessary HTML parsing --- public/sw.js | 38 +------------------------------------- src/sw.test.js | 2 +- 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/public/sw.js b/public/sw.js index 1b81545b80b..ad6c5b522d1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -16,43 +16,7 @@ self.addEventListener('install', event => { const cache = await caches.open(cacheName); if (hasOfflinePageFunctionality) { try { - const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin) - .href; - const response = await fetch(offlinePageUrl); - if (!response || !response.ok) { - throw new Error('Failed to fetch offline page'); - } - await cache.put(offlinePageUrl, response.clone()); - - // Parse HTML to extract and cache all script/link resources - const html = await response.text(); - const scriptMatches = html.matchAll( - /]+src=["']([^"']+)["']/g, - ); - const linkMatches = html.matchAll(/]+href=["']([^"']+)["']/g); - - const resourcesToCache = [ - ...Array.from(scriptMatches, m => m[1]), - ...Array.from(linkMatches, m => m[1]), - ].filter( - r => r.startsWith('/') || r.startsWith(self.location.origin), - ); - - // Cache all resources in parallel - await Promise.allSettled( - resourcesToCache.map(async resource => { - try { - const resourceUrl = new URL(resource, self.location.origin) - .href; - const resourceResponse = await fetch(resourceUrl); - if (resourceResponse && resourceResponse.ok) { - await cache.put(resourceUrl, resourceResponse); - } - } catch (err) { - // Resource failed to cache, continue - } - }), - ); + await cache.add(OFFLINE_PAGE); } catch (error) { // eslint-disable-next-line no-console console.error('Failed to cache offline page:', error.message); diff --git a/src/sw.test.js b/src/sw.test.js index 10ab05434f4..e16f5941e62 100644 --- a/src/sw.test.js +++ b/src/sw.test.js @@ -276,7 +276,7 @@ describe('Service Worker', () => { describe('version', () => { const CURRENT_VERSION = { number: 'v0.3.1', - fileContentHash: '9b2083d7fb65f0308c17f44a812315fe', + fileContentHash: '19cc88163ba75c802cd429a577ad7819', }; it(`version number should be ${CURRENT_VERSION.number}`, async () => { From 8d7da9637c2d6d97b5d6a82ab8575b6e28a2e1c4 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Wed, 3 Dec 2025 18:02:56 +0200 Subject: [PATCH 25/95] Add resource caching to SW install for offline page functionality --- public/sw.js | 32 +++++++++++++++++++++++++++++++- src/sw.test.js | 2 +- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/public/sw.js b/public/sw.js index ad6c5b522d1..dd986cf0f2f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -16,7 +16,37 @@ self.addEventListener('install', event => { const cache = await caches.open(cacheName); if (hasOfflinePageFunctionality) { try { - await cache.add(OFFLINE_PAGE); + // Fetch and cache the offline page HTML + const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin) + .href; + const response = await fetch(offlinePageUrl); + if (response && response.ok) { + await cache.put(offlinePageUrl, response.clone()); + + // Extract and cache JS/CSS resources so page works offline + const html = await response.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( + url => + url.startsWith('/') || url.startsWith(self.location.origin), + ) + .map(url => new URL(url, self.location.origin).href); + + // Cache resources in parallel (ignore individual failures) + await Promise.allSettled( + resources.map(async url => { + const res = await fetch(url); + if (res && res.ok) await cache.put(url, res); + }), + ); + } } catch (error) { // eslint-disable-next-line no-console console.error('Failed to cache offline page:', error.message); diff --git a/src/sw.test.js b/src/sw.test.js index e16f5941e62..3ceaf147346 100644 --- a/src/sw.test.js +++ b/src/sw.test.js @@ -276,7 +276,7 @@ describe('Service Worker', () => { describe('version', () => { const CURRENT_VERSION = { number: 'v0.3.1', - fileContentHash: '19cc88163ba75c802cd429a577ad7819', + fileContentHash: 'c880a61e3cd5c40fcf5ecaf45de6194a', }; it(`version number should be ${CURRENT_VERSION.number}`, async () => { From 39b3bccdb751ddad348f6eda741f59b4c0ceb5a4 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Wed, 3 Dec 2025 18:41:41 +0200 Subject: [PATCH 26/95] Fix offline page: cache-first for scripts/styles + fix fetch event listener --- public/sw.js | 51 ++++++++++++++++++++++++++++++++++++++------------ src/sw.test.js | 2 +- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/public/sw.js b/public/sw.js index dd986cf0f2f..bf91633fe79 100644 --- a/public/sw.js +++ b/public/sw.js @@ -113,28 +113,55 @@ const fetchEventHandler = async event => { return response; })(), ); - } else if (hasOfflinePageFunctionality && event.request.mode === 'navigate') { + } else if ( + hasOfflinePageFunctionality && + (event.request.mode === 'navigate' || + event.request.destination === 'script' || + event.request.destination === 'style') + ) { event.respondWith( (async () => { + const cache = await caches.open(cacheName); + + // Try cache first for scripts/styles + if ( + event.request.destination === 'script' || + event.request.destination === 'style' + ) { + const cachedResponse = await cache.match(event.request); + if (cachedResponse) { + return cachedResponse; + } + } + + // For navigation or if not in cache, try network try { const preloadResponse = await event.preloadResponse; if (preloadResponse) { return preloadResponse; } const networkResponse = await fetch(event.request); + // Cache the response for future offline use + if (networkResponse && networkResponse.ok) { + cache.put(event.request, networkResponse.clone()); + } return networkResponse; } catch (error) { - const cache = await caches.open(cacheName); - const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin) - .href; - const cachedResponse = await cache.match(offlinePageUrl); - if (cachedResponse) { - return cachedResponse; + // Network failed - serve offline page for navigation + if (event.request.mode === 'navigate') { + const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin) + .href; + const cachedResponse = await cache.match(offlinePageUrl); + if (cachedResponse) { + return cachedResponse; + } + return new Response('You are offline', { + status: 503, + headers: { 'Content-Type': 'text/plain' }, + }); } - return new Response('You are offline', { - status: 503, - headers: { 'Content-Type': 'text/plain' }, - }); + // For scripts/styles, return error response + return new Response('Offline', { status: 503 }); } })(), ); @@ -142,4 +169,4 @@ const fetchEventHandler = async event => { return; }; -onfetch = fetchEventHandler; +self.addEventListener('fetch', fetchEventHandler); diff --git a/src/sw.test.js b/src/sw.test.js index 3ceaf147346..7de0644cf08 100644 --- a/src/sw.test.js +++ b/src/sw.test.js @@ -276,7 +276,7 @@ describe('Service Worker', () => { describe('version', () => { const CURRENT_VERSION = { number: 'v0.3.1', - fileContentHash: 'c880a61e3cd5c40fcf5ecaf45de6194a', + fileContentHash: 'bfbff90830604924a7368eb46e23042d', }; it(`version number should be ${CURRENT_VERSION.number}`, async () => { From 27805f5f9849bfe52ef31181112db363d1f2853b Mon Sep 17 00:00:00 2001 From: skumid01 Date: Thu, 4 Dec 2025 11:43:14 +0200 Subject: [PATCH 27/95] Remove auto-caching of network responses to prevent cache bloat --- public/sw.js | 4 ---- src/sw.test.js | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/public/sw.js b/public/sw.js index bf91633fe79..87c053613f9 100644 --- a/public/sw.js +++ b/public/sw.js @@ -141,10 +141,6 @@ const fetchEventHandler = async event => { return preloadResponse; } const networkResponse = await fetch(event.request); - // Cache the response for future offline use - if (networkResponse && networkResponse.ok) { - cache.put(event.request, networkResponse.clone()); - } return networkResponse; } catch (error) { // Network failed - serve offline page for navigation diff --git a/src/sw.test.js b/src/sw.test.js index 7de0644cf08..23f40149aba 100644 --- a/src/sw.test.js +++ b/src/sw.test.js @@ -276,7 +276,7 @@ describe('Service Worker', () => { describe('version', () => { const CURRENT_VERSION = { number: 'v0.3.1', - fileContentHash: 'bfbff90830604924a7368eb46e23042d', + fileContentHash: '7d629e99f16e2ef85984905cf0ca2948', }; it(`version number should be ${CURRENT_VERSION.number}`, async () => { From a5f61c5b63a5e9c40b04718ed0b98a710c4193ce Mon Sep 17 00:00:00 2001 From: skumid01 Date: Thu, 4 Dec 2025 12:50:54 +0200 Subject: [PATCH 28/95] reverting bundle size config --- scripts/bundleSize/bundleSizeConfig.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/scripts/bundleSize/bundleSizeConfig.js b/scripts/bundleSize/bundleSizeConfig.js index 72b52d8a432..8de585bb7ff 100644 --- a/scripts/bundleSize/bundleSizeConfig.js +++ b/scripts/bundleSize/bundleSizeConfig.js @@ -7,9 +7,7 @@ * We are allowing a variance of -5 on `MIN_SIZE` and +5 on `MAX_SIZE` to avoid the need for frequent changes, as bundle sizes can fluctuate */ -const MIN = 927; -const MAX = 1297; - export const VARIANCE = 5; -export const MIN_SIZE = MIN - VARIANCE; -export const MAX_SIZE = MAX + VARIANCE; + +export const MIN_SIZE = 927; +export const MAX_SIZE = 1297; From 68bc9412b50a43835a667f2ab527091373e443e3 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Thu, 4 Dec 2025 13:12:14 +0200 Subject: [PATCH 29/95] Fix PWA offline page: dynamically extract service from URL --- public/sw.js | 30 ++++++++++++++++++++++++------ src/sw.test.js | 2 +- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/public/sw.js b/public/sw.js index 87c053613f9..678605497a1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -6,9 +6,16 @@ const version = 'v0.3.1'; const cacheName = 'simorghCache_v1'; -const service = self.location.pathname.split('/')[1]; const hasOfflinePageFunctionality = true; -const OFFLINE_PAGE = `/${service}/offline`; + +// Helper to get service from URL +const getServiceFromUrl = url => { + const { pathname } = new URL(url); + return pathname.split('/')[1]; +}; + +// Helper to get offline page URL for a service +const getOfflinePageUrl = service => `/${service}/offline`; self.addEventListener('install', event => { event.waitUntil( @@ -16,9 +23,16 @@ self.addEventListener('install', event => { const cache = await caches.open(cacheName); if (hasOfflinePageFunctionality) { try { + // Get service from any client URL or default to 'ws' + const clients = await self.clients.matchAll(); + const service = + clients.length > 0 ? getServiceFromUrl(clients[0].url) : 'ws'; + // Fetch and cache the offline page HTML - const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin) - .href; + const offlinePageUrl = new URL( + getOfflinePageUrl(service), + self.location.origin, + ).href; const response = await fetch(offlinePageUrl); if (response && response.ok) { await cache.put(offlinePageUrl, response.clone()); @@ -145,8 +159,12 @@ const fetchEventHandler = async event => { } catch (error) { // Network failed - serve offline page for navigation if (event.request.mode === 'navigate') { - const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin) - .href; + // Extract service from the request URL + const service = getServiceFromUrl(event.request.url); + const offlinePageUrl = new URL( + getOfflinePageUrl(service), + self.location.origin, + ).href; const cachedResponse = await cache.match(offlinePageUrl); if (cachedResponse) { return cachedResponse; diff --git a/src/sw.test.js b/src/sw.test.js index 23f40149aba..7ca3769af1c 100644 --- a/src/sw.test.js +++ b/src/sw.test.js @@ -276,7 +276,7 @@ describe('Service Worker', () => { describe('version', () => { const CURRENT_VERSION = { number: 'v0.3.1', - fileContentHash: '7d629e99f16e2ef85984905cf0ca2948', + fileContentHash: 'b1d320cba021a489fe459b7e02c0bad0', }; it(`version number should be ${CURRENT_VERSION.number}`, async () => { From 168f75a5051771817a44dcc35eadb72d1ca5fbcf Mon Sep 17 00:00:00 2001 From: jinidev Date: Thu, 4 Dec 2025 17:40:38 +0200 Subject: [PATCH 30/95] Revert "Fix PWA offline page: dynamically extract service from URL" This reverts commit 68bc9412b50a43835a667f2ab527091373e443e3. --- public/sw.js | 30 ++++++------------------------ src/sw.test.js | 2 +- 2 files changed, 7 insertions(+), 25 deletions(-) diff --git a/public/sw.js b/public/sw.js index 678605497a1..87c053613f9 100644 --- a/public/sw.js +++ b/public/sw.js @@ -6,16 +6,9 @@ const version = 'v0.3.1'; const cacheName = 'simorghCache_v1'; +const service = self.location.pathname.split('/')[1]; const hasOfflinePageFunctionality = true; - -// Helper to get service from URL -const getServiceFromUrl = url => { - const { pathname } = new URL(url); - return pathname.split('/')[1]; -}; - -// Helper to get offline page URL for a service -const getOfflinePageUrl = service => `/${service}/offline`; +const OFFLINE_PAGE = `/${service}/offline`; self.addEventListener('install', event => { event.waitUntil( @@ -23,16 +16,9 @@ self.addEventListener('install', event => { const cache = await caches.open(cacheName); if (hasOfflinePageFunctionality) { try { - // Get service from any client URL or default to 'ws' - const clients = await self.clients.matchAll(); - const service = - clients.length > 0 ? getServiceFromUrl(clients[0].url) : 'ws'; - // Fetch and cache the offline page HTML - const offlinePageUrl = new URL( - getOfflinePageUrl(service), - self.location.origin, - ).href; + const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin) + .href; const response = await fetch(offlinePageUrl); if (response && response.ok) { await cache.put(offlinePageUrl, response.clone()); @@ -159,12 +145,8 @@ const fetchEventHandler = async event => { } catch (error) { // Network failed - serve offline page for navigation if (event.request.mode === 'navigate') { - // Extract service from the request URL - const service = getServiceFromUrl(event.request.url); - const offlinePageUrl = new URL( - getOfflinePageUrl(service), - self.location.origin, - ).href; + const offlinePageUrl = new URL(OFFLINE_PAGE, self.location.origin) + .href; const cachedResponse = await cache.match(offlinePageUrl); if (cachedResponse) { return cachedResponse; diff --git a/src/sw.test.js b/src/sw.test.js index 7ca3769af1c..23f40149aba 100644 --- a/src/sw.test.js +++ b/src/sw.test.js @@ -276,7 +276,7 @@ describe('Service Worker', () => { describe('version', () => { const CURRENT_VERSION = { number: 'v0.3.1', - fileContentHash: 'b1d320cba021a489fe459b7e02c0bad0', + fileContentHash: '7d629e99f16e2ef85984905cf0ca2948', }; it(`version number should be ${CURRENT_VERSION.number}`, async () => { From 0d6ff9187243f450985e0447e6bb8c61b48eb328 Mon Sep 17 00:00:00 2001 From: skumid01 Date: Thu, 4 Dec 2025 19:19:42 +0200 Subject: [PATCH 31/95] adding offline page PWA + browser --- public/sw.js | 177 +++++++++++++++--- .../contexts/EventTrackingContext/index.tsx | 2 + src/app/routes/utils/pageTypes.ts | 1 + src/sw.test.js | 4 +- .../pages/[service]/offline.page.tsx | 43 +++++ .../[service]/offline/OfflinePage.test.tsx | 37 ++++ .../pages/[service]/offline/OfflinePage.tsx | 36 ++++ ws-nextjs-app/pages/_app.page.tsx | 13 ++ 8 files changed, 288 insertions(+), 25 deletions(-) create mode 100644 ws-nextjs-app/pages/[service]/offline.page.tsx create mode 100644 ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx create mode 100644 ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx diff --git a/public/sw.js b/public/sw.js index 4a72ed7a5e5..bf79bbed1cc 100644 --- a/public/sw.js +++ b/public/sw.js @@ -3,18 +3,55 @@ /* eslint-disable no-unused-vars */ /* eslint-disable no-undef */ /* eslint-disable no-restricted-globals */ -const version = 'v0.3.0'; +const version = 'v0.3.1'; const cacheName = 'simorghCache_v1'; -const service = self.location.pathname.split('/')[1]; -const hasOfflinePageFunctionality = false; -const OFFLINE_PAGE = `/${service}/offline`; +const hasOfflinePageFunctionality = true; + +// Helper to get service from URL +const getServiceFromUrl = url => { + const { pathname } = new URL(url); + return pathname.split('/')[1]; +}; + +// Helper to get offline page URL for a service +const getOfflinePageUrl = service => `/${service}/offline`; self.addEventListener('install', event => { - event.waitUntil(async () => { - const cache = await caches.open(cacheName); - if (hasOfflinePageFunctionality) await cache.add(OFFLINE_PAGE); - }); + // eslint-disable-next-line no-console + console.log(`[SW v${version}] Installing...`); + // Skip waiting to activate immediately + self.skipWaiting(); + + // Note: We don't pre-cache offline pages here because we don't know which + // service the user will visit. Instead, offline pages are cached on-demand + // when the user navigates to a service while online (see fetch handler). +}); + +self.addEventListener('activate', event => { + // eslint-disable-next-line no-console + console.log(`[SW v${version}] Activating...`); + event.waitUntil( + (async () => { + // Clean up old caches from previous SW versions + const cacheNames = await caches.keys(); + const currentCaches = [cacheName]; + + await Promise.all( + cacheNames.map(cache => { + if (!currentCaches.includes(cache)) { + // eslint-disable-next-line no-console + console.log(`[SW v${version}] Deleting old cache: ${cache}`); + return caches.delete(cache); + } + return null; + }), + ); + + // Take control of all pages immediately + await self.clients.claim(); + })(), + ); }); const CACHEABLE_FILES = [ @@ -73,23 +110,117 @@ 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; - } - const networkResponse = await fetch(event.request); - return networkResponse; - } catch (error) { + } else if ( + hasOfflinePageFunctionality && + (event.request.mode === 'navigate' || + event.request.destination === 'script' || + event.request.destination === 'style') + ) { + event.respondWith( + (async () => { const cache = await caches.open(cacheName); - const cachedResponse = await cache.match(OFFLINE_PAGE); - return cachedResponse; - } - }); + + // Try cache first for scripts/styles + if ( + event.request.destination === 'script' || + event.request.destination === 'style' + ) { + const cachedResponse = await cache.match(event.request); + if (cachedResponse) { + return cachedResponse; + } + } + + // For navigation or if not in cache, try network + try { + const preloadResponse = await event.preloadResponse; + if (preloadResponse) { + return preloadResponse; + } + const networkResponse = await fetch(event.request); + + // Cache offline page for this service when online (for future offline use) + if (event.request.mode === 'navigate' && networkResponse.ok) { + const service = getServiceFromUrl(event.request.url); + const offlinePageUrl = new URL( + getOfflinePageUrl(service), + self.location.origin, + ).href; + + // Only cache if not already cached + const cachedOffline = await cache.match(offlinePageUrl); + if (!cachedOffline) { + // eslint-disable-next-line no-console + console.log(`[SW] Caching offline page for ${service}...`); + // Cache asynchronously, don't block navigation + fetch(offlinePageUrl) + .then(async offlineResponse => { + if (offlineResponse && offlineResponse.ok) { + await cache.put(offlinePageUrl, offlineResponse.clone()); + // eslint-disable-next-line no-console + console.log(`[SW] ✅ Cached ${offlinePageUrl}`); + + // Also cache JS/CSS resources + const html = await offlineResponse.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( + url => + url.startsWith('/') || + url.startsWith(self.location.origin), + ) + .map(url => new URL(url, self.location.origin).href); + + await Promise.allSettled( + resources.map(async url => { + const res = await fetch(url); + if (res && res.ok) await cache.put(url, res); + }), + ); + } + }) + .catch(err => { + // eslint-disable-next-line no-console + console.error( + `[SW] Failed to cache offline page for ${service}:`, + err, + ); + }); + } + } + + return networkResponse; + } catch (error) { + // Network failed - serve offline page for navigation + if (event.request.mode === 'navigate') { + // Extract service from the request URL + const service = getServiceFromUrl(event.request.url); + const offlinePageUrl = new URL( + getOfflinePageUrl(service), + self.location.origin, + ).href; + const cachedResponse = await cache.match(offlinePageUrl); + if (cachedResponse) { + return cachedResponse; + } + return new Response('You are offline', { + status: 503, + headers: { 'Content-Type': 'text/plain' }, + }); + } + // For scripts/styles, return error response + return new Response('Offline', { status: 503 }); + } + })(), + ); } return; }; -onfetch = fetchEventHandler; +self.addEventListener('fetch', fetchEventHandler); diff --git a/src/app/contexts/EventTrackingContext/index.tsx b/src/app/contexts/EventTrackingContext/index.tsx index 706206ba7d8..4c7e36c6b79 100644 --- a/src/app/contexts/EventTrackingContext/index.tsx +++ b/src/app/contexts/EventTrackingContext/index.tsx @@ -21,6 +21,7 @@ import { LIVE_RADIO_PAGE, TV_PAGE, AUDIO_PAGE, + OFFLINE_PAGE, LIVE_TV_PAGE, } from '../../routes/utils/pageTypes'; import { PageTypes } from '../../models/types/global'; @@ -37,6 +38,7 @@ type CampaignPageTypes = Exclude; const getCampaignID = (pageType: CampaignPageTypes) => { const campaignID = { + [OFFLINE_PAGE]: 'offline', [ARTICLE_PAGE]: 'article', [MEDIA_ARTICLE_PAGE]: 'article-sfv', [MOST_READ_PAGE]: 'list-datadriven-read', diff --git a/src/app/routes/utils/pageTypes.ts b/src/app/routes/utils/pageTypes.ts index 4d2f7027431..a2ebeb637d9 100644 --- a/src/app/routes/utils/pageTypes.ts +++ b/src/app/routes/utils/pageTypes.ts @@ -17,4 +17,5 @@ export const DOWNLOADS_PAGE = 'downloads' as const; export const LIVE_RADIO_PAGE = 'liveRadio' as const; export const AUDIO_PAGE = 'audio' as const; export const TV_PAGE = 'tv' as const; +export const OFFLINE_PAGE = 'offline' as const; export const LIVE_TV_PAGE = 'liveTV' as const; diff --git a/src/sw.test.js b/src/sw.test.js index fec58521761..e9446fb928c 100644 --- a/src/sw.test.js +++ b/src/sw.test.js @@ -275,8 +275,8 @@ describe('Service Worker', () => { describe('version', () => { const CURRENT_VERSION = { - number: 'v0.3.0', - fileContentHash: '6150d6daf3d64a226a47e17b39dfc084', + number: 'v0.3.1', + fileContentHash: 'f716a47eabf339786d11e0d2047780c4', }; it(`version number should be ${CURRENT_VERSION.number}`, async () => { diff --git a/ws-nextjs-app/pages/[service]/offline.page.tsx b/ws-nextjs-app/pages/[service]/offline.page.tsx new file mode 100644 index 00000000000..039fe3e7781 --- /dev/null +++ b/ws-nextjs-app/pages/[service]/offline.page.tsx @@ -0,0 +1,43 @@ +import dynamic from 'next/dynamic'; +import { GetServerSideProps } from 'next'; +import { OFFLINE_PAGE } from '#app/routes/utils/pageTypes'; +import PageDataParams from '#app/models/types/pageDataParams'; +import deriveVariant from '#nextjs/utilities/deriveVariant'; +import extractHeaders from '#server/utilities/extractHeaders'; +import logResponseTime from '#server/utilities/logResponseTime'; +import getToggles from '#app/lib/utilities/getToggles/withCache'; + +const OfflinePage = dynamic(() => import('./offline/OfflinePage')); + +export const getServerSideProps: GetServerSideProps = async context => { + const { service, variant: variantFromUrl } = context.query as PageDataParams; + const variant = deriveVariant(variantFromUrl); + + logResponseTime({ path: context.resolvedUrl }, context.res, () => null); + + context.res.setHeader( + 'Cache-Control', + 'public, max-age=300, stale-while-revalidate=600, stale-if-error=3600', + ); + + const toggles = await getToggles(service); + + return { + props: { + service, + variant, + toggles, + pageType: OFFLINE_PAGE, + isNextJs: true, + isAmp: false, + status: 200, + timeOnServer: Date.now(), + pathname: `/${service}/offline`, + ...extractHeaders(context.req.headers), + }, + }; +}; + +export default function OfflinePageRoute() { + return ; +} diff --git a/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx b/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx new file mode 100644 index 00000000000..c993de1b2b7 --- /dev/null +++ b/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx @@ -0,0 +1,37 @@ +import { + render, + screen, +} from '#app/components/react-testing-library-with-providers'; +import OfflinePage from './OfflinePage'; + +describe('OfflinePage', () => { + it('should render correctly', () => { + const { container } = render(, { + service: 'news', + }); + expect(container).toMatchSnapshot(); + }); + + it('should render correctly for mundo service', () => { + const { container } = render(, { + service: 'mundo', + }); + expect(container).toMatchSnapshot(); + }); + + it('should display offline message and solutions', () => { + render(, { + service: 'news', + }); + + expect(screen.getByText('You are offline')).toBeInTheDocument(); + + expect( + screen.getByText(/It seems you don't have an internet connection/), + ).toBeInTheDocument(); + + expect( + screen.getByText('Check your internet connection'), + ).toBeInTheDocument(); + }); +}); diff --git a/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx b/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx new file mode 100644 index 00000000000..bab0d21abd1 --- /dev/null +++ b/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx @@ -0,0 +1,36 @@ +import { use } from 'react'; +import Helmet from 'react-helmet'; +import { ServiceContext } from '#contexts/ServiceContext'; +import ErrorMain from '#app/legacy/components/ErrorMain'; + +const OfflinePage = () => { + const { service, dir, script } = use(ServiceContext); + + 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."; + const solutions = [ + 'Check your internet connection', + 'Refresh the page when your connection is restored', + ]; + + return ( + <> + + {title} + + + + ); +}; + +export default OfflinePage; diff --git a/ws-nextjs-app/pages/_app.page.tsx b/ws-nextjs-app/pages/_app.page.tsx index db03d7fc4f9..b0805da0bec 100644 --- a/ws-nextjs-app/pages/_app.page.tsx +++ b/ws-nextjs-app/pages/_app.page.tsx @@ -1,4 +1,5 @@ import type { AppProps } from 'next/app'; +import { useEffect } from 'react'; import { ATIData } from '#app/components/ATIAnalytics/types'; import ThemeProvider from '#app/components/ThemeProvider'; import { ToggleContextProvider } from '#app/contexts/ToggleContext'; @@ -74,6 +75,18 @@ export default function App({ Component, pageProps }: Props) { const { metadata: { atiAnalytics = undefined } = {} } = pageData ?? {}; + // Register service worker for offline functionality + useEffect(() => { + if ( + typeof window !== 'undefined' && + 'serviceWorker' in navigator && + service + ) { + // Register SW for this service (middleware rewrites to /sw.js in dev) + navigator.serviceWorker.register(`/${service}/sw.js`); + } + }, [service]); + const RenderChildrenOrError = status === 200 ? ( From 893ef9f5454184fb7f0ec2b94d42627e6c0adde2 Mon Sep 17 00:00:00 2001 From: jinidev Date: Fri, 5 Dec 2025 12:30:56 +0200 Subject: [PATCH 32/95] remove emoji --- public/sw.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/sw.js b/public/sw.js index bf79bbed1cc..45c6d9d082f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -158,7 +158,7 @@ const fetchEventHandler = async event => { if (offlineResponse && offlineResponse.ok) { await cache.put(offlinePageUrl, offlineResponse.clone()); // eslint-disable-next-line no-console - console.log(`[SW] ✅ Cached ${offlinePageUrl}`); + console.log(`[SW] Cached ${offlinePageUrl}`); // Also cache JS/CSS resources const html = await offlineResponse.text(); From f6f171a2e161b1c0fb93c75afbb6c0a13998c48b Mon Sep 17 00:00:00 2001 From: skumid01 Date: Fri, 5 Dec 2025 14:16:58 +0200 Subject: [PATCH 33/95] adding offline pwa only --- public/sw.js | 275 ++++++++++++++++-- .../contexts/EventTrackingContext/index.tsx | 2 + src/app/routes/utils/pageTypes.ts | 1 + src/sw.test.js | 4 +- .../pages/[service]/offline.page.tsx | 43 +++ .../[service]/offline/OfflinePage.test.tsx | 37 +++ .../pages/[service]/offline/OfflinePage.tsx | 36 +++ ws-nextjs-app/pages/_app.page.tsx | 26 ++ 8 files changed, 399 insertions(+), 25 deletions(-) create mode 100644 ws-nextjs-app/pages/[service]/offline.page.tsx create mode 100644 ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx create mode 100644 ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx diff --git a/public/sw.js b/public/sw.js index 4a72ed7a5e5..dd727a6d561 100644 --- a/public/sw.js +++ b/public/sw.js @@ -3,18 +3,140 @@ /* eslint-disable no-unused-vars */ /* eslint-disable no-undef */ /* eslint-disable no-restricted-globals */ -const version = 'v0.3.0'; +const version = 'v0.3.1'; const cacheName = 'simorghCache_v1'; -const service = self.location.pathname.split('/')[1]; -const hasOfflinePageFunctionality = false; -const OFFLINE_PAGE = `/${service}/offline`; +const hasOfflinePageFunctionality = true; + +// Helper to get service from URL +const getServiceFromUrl = url => { + const { pathname } = new URL(url); + return pathname.split('/')[1]; +}; + +// Helper to get offline page URL for a service +const getOfflinePageUrl = service => `/${service}/offline`; + +// Track which clients are in PWA mode +const pwaClients = new Map(); + +// Listen for messages from clients about their PWA status +self.addEventListener('message', event => { + if (event.data && event.data.type === 'PWA_STATUS') { + pwaClients.set(event.source.id, event.data.isPWA); + } +}); self.addEventListener('install', event => { - event.waitUntil(async () => { - const cache = await caches.open(cacheName); - if (hasOfflinePageFunctionality) await cache.add(OFFLINE_PAGE); - }); + // eslint-disable-next-line no-console + console.log(`[SW v${version}] Installing...`); + + event.waitUntil( + (async () => { + if (hasOfflinePageFunctionality) { + const cache = await caches.open(cacheName); + const clients = await self.clients.matchAll({ type: 'window' }); + + // Get unique services from PWA clients only + const pwaServices = [ + ...new Set( + clients + .filter(client => pwaClients.get(client.id)) + .map(client => getServiceFromUrl(client.url)) + .filter(Boolean), + ), + ]; + + if (pwaServices.length > 0) { + // eslint-disable-next-line no-console + console.log( + `[SW v${version}] Caching offline pages for PWA:`, + pwaServices, + ); + } + + // Cache offline pages for PWA services only + await Promise.allSettled( + pwaServices.map(async service => { + const offlinePageUrl = new URL( + getOfflinePageUrl(service), + self.location.origin, + ).href; + + try { + const response = await fetch(offlinePageUrl); + if (response && response.ok) { + await cache.put(offlinePageUrl, response.clone()); + + // Cache resources + const html = await response.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( + url => + url.startsWith('/') || + url.startsWith(self.location.origin), + ) + .map(url => new URL(url, self.location.origin).href); + + await Promise.allSettled( + resources.map(async url => { + const res = await fetch(url); + if (res && res.ok) await cache.put(url, res); + }), + ); + + // eslint-disable-next-line no-console + console.log( + `[SW v${version}] ✅ Cached offline page for ${service}`, + ); + } + } catch (err) { + // eslint-disable-next-line no-console + console.error( + `[SW v${version}] Failed to cache ${service}:`, + err.message, + ); + } + }), + ); + } + + self.skipWaiting(); + })(), + ); +}); + +self.addEventListener('activate', event => { + // eslint-disable-next-line no-console + console.log(`[SW v${version}] Activating...`); + event.waitUntil( + (async () => { + // Clean up old caches from previous SW versions + const cacheNames = await caches.keys(); + const currentCaches = [cacheName]; + + await Promise.all( + cacheNames.map(cache => { + if (!currentCaches.includes(cache)) { + // eslint-disable-next-line no-console + console.log(`[SW v${version}] Deleting old cache: ${cache}`); + return caches.delete(cache); + } + return null; + }), + ); + + // Take control of all pages immediately + await self.clients.claim(); + })(), + ); }); const CACHEABLE_FILES = [ @@ -73,23 +195,130 @@ 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; - } - const networkResponse = await fetch(event.request); - return networkResponse; - } catch (error) { + } else if ( + hasOfflinePageFunctionality && + (event.request.mode === 'navigate' || + event.request.destination === 'script' || + event.request.destination === 'style') + ) { + event.respondWith( + (async () => { const cache = await caches.open(cacheName); - const cachedResponse = await cache.match(OFFLINE_PAGE); - return cachedResponse; - } - }); + + // Try cache first for scripts/styles + if ( + event.request.destination === 'script' || + event.request.destination === 'style' + ) { + const cachedResponse = await cache.match(event.request); + if (cachedResponse) { + return cachedResponse; + } + } + + // For navigation or if not in cache, try network + try { + const preloadResponse = await event.preloadResponse; + if (preloadResponse) { + return preloadResponse; + } + const networkResponse = await fetch(event.request); + + // Cache offline page for this service when online (PWA only) + if (event.request.mode === 'navigate' && networkResponse.ok) { + const client = await self.clients.get(event.clientId); + const isPWA = client && pwaClients.get(client.id); + + if (isPWA) { + const service = getServiceFromUrl(event.request.url); + const offlinePageUrl = new URL( + getOfflinePageUrl(service), + self.location.origin, + ).href; + + // Only cache if not already cached + const cachedOffline = await cache.match(offlinePageUrl); + if (!cachedOffline) { + // eslint-disable-next-line no-console + console.log(`[SW] Caching offline page for ${service}...`); + // Cache asynchronously, don't block navigation + fetch(offlinePageUrl) + .then(async offlineResponse => { + if (offlineResponse && offlineResponse.ok) { + await cache.put(offlinePageUrl, offlineResponse.clone()); + // eslint-disable-next-line no-console + console.log(`[SW] ✅ Cached ${offlinePageUrl}`); + + // Also cache JS/CSS resources + const html = await offlineResponse.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( + url => + url.startsWith('/') || + url.startsWith(self.location.origin), + ) + .map(url => new URL(url, self.location.origin).href); + + await Promise.allSettled( + resources.map(async url => { + const res = await fetch(url); + if (res && res.ok) await cache.put(url, res); + }), + ); + } + }) + .catch(err => { + // eslint-disable-next-line no-console + console.error( + `[SW] Failed to cache offline page for ${service}:`, + err, + ); + }); + } + } + } + + return networkResponse; + } catch (error) { + // Network failed - serve offline page for navigation (PWA only) + if (event.request.mode === 'navigate') { + // Check if client is in PWA mode + const client = await self.clients.get(event.clientId); + const isPWA = client && pwaClients.get(client.id); + + if (isPWA) { + // PWA mode - serve custom offline page + const service = getServiceFromUrl(event.request.url); + const offlinePageUrl = new URL( + getOfflinePageUrl(service), + self.location.origin, + ).href; + const cachedResponse = await cache.match(offlinePageUrl); + if (cachedResponse) { + return cachedResponse; + } + } + + // Browser mode or no cached page - let browser handle it + return new Response('You are offline', { + status: 503, + headers: { 'Content-Type': 'text/plain' }, + }); + } + // For scripts/styles, return error response + return new Response('Offline', { status: 503 }); + } + })(), + ); } return; }; -onfetch = fetchEventHandler; +self.addEventListener('fetch', fetchEventHandler); diff --git a/src/app/contexts/EventTrackingContext/index.tsx b/src/app/contexts/EventTrackingContext/index.tsx index 706206ba7d8..4c7e36c6b79 100644 --- a/src/app/contexts/EventTrackingContext/index.tsx +++ b/src/app/contexts/EventTrackingContext/index.tsx @@ -21,6 +21,7 @@ import { LIVE_RADIO_PAGE, TV_PAGE, AUDIO_PAGE, + OFFLINE_PAGE, LIVE_TV_PAGE, } from '../../routes/utils/pageTypes'; import { PageTypes } from '../../models/types/global'; @@ -37,6 +38,7 @@ type CampaignPageTypes = Exclude; const getCampaignID = (pageType: CampaignPageTypes) => { const campaignID = { + [OFFLINE_PAGE]: 'offline', [ARTICLE_PAGE]: 'article', [MEDIA_ARTICLE_PAGE]: 'article-sfv', [MOST_READ_PAGE]: 'list-datadriven-read', diff --git a/src/app/routes/utils/pageTypes.ts b/src/app/routes/utils/pageTypes.ts index 4d2f7027431..a2ebeb637d9 100644 --- a/src/app/routes/utils/pageTypes.ts +++ b/src/app/routes/utils/pageTypes.ts @@ -17,4 +17,5 @@ export const DOWNLOADS_PAGE = 'downloads' as const; export const LIVE_RADIO_PAGE = 'liveRadio' as const; export const AUDIO_PAGE = 'audio' as const; export const TV_PAGE = 'tv' as const; +export const OFFLINE_PAGE = 'offline' as const; export const LIVE_TV_PAGE = 'liveTV' as const; diff --git a/src/sw.test.js b/src/sw.test.js index fec58521761..605ee9dd270 100644 --- a/src/sw.test.js +++ b/src/sw.test.js @@ -275,8 +275,8 @@ describe('Service Worker', () => { describe('version', () => { const CURRENT_VERSION = { - number: 'v0.3.0', - fileContentHash: '6150d6daf3d64a226a47e17b39dfc084', + number: 'v0.3.1', + fileContentHash: '8c4f71688da7deec968539d7ed4bfdbf', }; it(`version number should be ${CURRENT_VERSION.number}`, async () => { diff --git a/ws-nextjs-app/pages/[service]/offline.page.tsx b/ws-nextjs-app/pages/[service]/offline.page.tsx new file mode 100644 index 00000000000..039fe3e7781 --- /dev/null +++ b/ws-nextjs-app/pages/[service]/offline.page.tsx @@ -0,0 +1,43 @@ +import dynamic from 'next/dynamic'; +import { GetServerSideProps } from 'next'; +import { OFFLINE_PAGE } from '#app/routes/utils/pageTypes'; +import PageDataParams from '#app/models/types/pageDataParams'; +import deriveVariant from '#nextjs/utilities/deriveVariant'; +import extractHeaders from '#server/utilities/extractHeaders'; +import logResponseTime from '#server/utilities/logResponseTime'; +import getToggles from '#app/lib/utilities/getToggles/withCache'; + +const OfflinePage = dynamic(() => import('./offline/OfflinePage')); + +export const getServerSideProps: GetServerSideProps = async context => { + const { service, variant: variantFromUrl } = context.query as PageDataParams; + const variant = deriveVariant(variantFromUrl); + + logResponseTime({ path: context.resolvedUrl }, context.res, () => null); + + context.res.setHeader( + 'Cache-Control', + 'public, max-age=300, stale-while-revalidate=600, stale-if-error=3600', + ); + + const toggles = await getToggles(service); + + return { + props: { + service, + variant, + toggles, + pageType: OFFLINE_PAGE, + isNextJs: true, + isAmp: false, + status: 200, + timeOnServer: Date.now(), + pathname: `/${service}/offline`, + ...extractHeaders(context.req.headers), + }, + }; +}; + +export default function OfflinePageRoute() { + return ; +} diff --git a/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx b/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx new file mode 100644 index 00000000000..c993de1b2b7 --- /dev/null +++ b/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx @@ -0,0 +1,37 @@ +import { + render, + screen, +} from '#app/components/react-testing-library-with-providers'; +import OfflinePage from './OfflinePage'; + +describe('OfflinePage', () => { + it('should render correctly', () => { + const { container } = render(, { + service: 'news', + }); + expect(container).toMatchSnapshot(); + }); + + it('should render correctly for mundo service', () => { + const { container } = render(, { + service: 'mundo', + }); + expect(container).toMatchSnapshot(); + }); + + it('should display offline message and solutions', () => { + render(, { + service: 'news', + }); + + expect(screen.getByText('You are offline')).toBeInTheDocument(); + + expect( + screen.getByText(/It seems you don't have an internet connection/), + ).toBeInTheDocument(); + + expect( + screen.getByText('Check your internet connection'), + ).toBeInTheDocument(); + }); +}); diff --git a/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx b/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx new file mode 100644 index 00000000000..bab0d21abd1 --- /dev/null +++ b/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx @@ -0,0 +1,36 @@ +import { use } from 'react'; +import Helmet from 'react-helmet'; +import { ServiceContext } from '#contexts/ServiceContext'; +import ErrorMain from '#app/legacy/components/ErrorMain'; + +const OfflinePage = () => { + const { service, dir, script } = use(ServiceContext); + + 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."; + const solutions = [ + 'Check your internet connection', + 'Refresh the page when your connection is restored', + ]; + + return ( + <> + + {title} + + + + ); +}; + +export default OfflinePage; diff --git a/ws-nextjs-app/pages/_app.page.tsx b/ws-nextjs-app/pages/_app.page.tsx index db03d7fc4f9..3757dfdbad3 100644 --- a/ws-nextjs-app/pages/_app.page.tsx +++ b/ws-nextjs-app/pages/_app.page.tsx @@ -1,4 +1,5 @@ import type { AppProps } from 'next/app'; +import { useEffect } from 'react'; import { ATIData } from '#app/components/ATIAnalytics/types'; import ThemeProvider from '#app/components/ThemeProvider'; import { ToggleContextProvider } from '#app/contexts/ToggleContext'; @@ -15,6 +16,7 @@ import { ServiceContextProvider } from '#app/contexts/ServiceContext'; import { RequestContextProvider } from '#app/contexts/RequestContext'; import { EventTrackingContextProvider } from '#app/contexts/EventTrackingContext'; import { UserContextProvider } from '#app/contexts/UserContext'; +import useIsPWA from '#app/hooks/useIsPWA'; interface Props extends AppProps { pageProps: { @@ -74,6 +76,30 @@ export default function App({ Component, pageProps }: Props) { const { metadata: { atiAnalytics = undefined } = {} } = pageData ?? {}; + const isPWA = useIsPWA(); + + // Register service worker for offline functionality + useEffect(() => { + if ( + typeof window !== 'undefined' && + 'serviceWorker' in navigator && + service + ) { + // Register SW for this service (middleware rewrites to /sw.js in dev) + navigator.serviceWorker.register(`/${service}/sw.js`); + } + }, [service]); + + // Send PWA status to service worker + useEffect(() => { + if (typeof window !== 'undefined' && navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage({ + type: 'PWA_STATUS', + isPWA, + }); + } + }, [isPWA]); + const RenderChildrenOrError = status === 200 ? ( From 29cb53f549a2fee57cd0e17093794c3bbaaad94d Mon Sep 17 00:00:00 2001 From: jinidev Date: Fri, 5 Dec 2025 21:11:20 +0200 Subject: [PATCH 34/95] service worker changes to render offline page in PWA apps --- public/sw.js | 418 +++++++-------------- src/app/components/ServiceWorker/index.tsx | 14 +- ws-nextjs-app/pages/_app.page.tsx | 53 ++- 3 files changed, 198 insertions(+), 287 deletions(-) diff --git a/public/sw.js b/public/sw.js index dd727a6d561..eca2eed73b9 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,324 +1,194 @@ -/* eslint-disable no-useless-return */ -/* eslint-disable import/prefer-default-export */ -/* eslint-disable no-unused-vars */ -/* eslint-disable no-undef */ -/* eslint-disable no-restricted-globals */ const version = 'v0.3.1'; const cacheName = 'simorghCache_v1'; - const hasOfflinePageFunctionality = true; -// Helper to get service from URL -const getServiceFromUrl = url => { - const { pathname } = new URL(url); - return pathname.split('/')[1]; -}; +// Track PWA clients +const pwaClients = new Map(); -// Helper to get offline page URL for a service +console.log(`[SW v${version}] Service Worker loaded.`); + +// -------------------- +// Helper Functions +// -------------------- +const getServiceFromUrl = url => new URL(url).pathname.split('/')[1]; const getOfflinePageUrl = service => `/${service}/offline`; -// Track which clients are in PWA mode -const pwaClients = new Map(); +const openCache = async () => caches.open(cacheName); -// Listen for messages from clients about their PWA status -self.addEventListener('message', event => { - if (event.data && event.data.type === 'PWA_STATUS') { - pwaClients.set(event.source.id, event.data.isPWA); +const cacheResource = async (cache, url) => { + try { + const response = await fetch(url); + if (response.ok) await cache.put(url, response.clone()); + return response; + } catch (err) { + console.error(`[SW v${version}] Failed to cache ${url}:`, err); } -}); - -self.addEventListener('install', event => { - // eslint-disable-next-line no-console - console.log(`[SW v${version}] Installing...`); +}; - event.waitUntil( - (async () => { - if (hasOfflinePageFunctionality) { - const cache = await caches.open(cacheName); - const clients = await self.clients.matchAll({ type: 'window' }); +const cacheOfflinePageAndResources = async service => { + if (!hasOfflinePageFunctionality) return; + const cache = await openCache(); + const offlinePageUrl = new URL( + getOfflinePageUrl(service), + self.location.origin, + ).href; - // Get unique services from PWA clients only - const pwaServices = [ - ...new Set( - clients - .filter(client => pwaClients.get(client.id)) - .map(client => getServiceFromUrl(client.url)) - .filter(Boolean), - ), - ]; + if (await cache.match(offlinePageUrl)) return; - if (pwaServices.length > 0) { - // eslint-disable-next-line no-console - console.log( - `[SW v${version}] Caching offline pages for PWA:`, - pwaServices, - ); - } + const resp = await cacheResource(cache, offlinePageUrl); + if (!resp || !resp.ok) return; - // Cache offline pages for PWA services only - await Promise.allSettled( - pwaServices.map(async service => { - const offlinePageUrl = new URL( - getOfflinePageUrl(service), - self.location.origin, - ).href; + console.log(`[SW v${version}] Cached offline page for ${service}`); - try { - const response = await fetch(offlinePageUrl); - if (response && response.ok) { - await cache.put(offlinePageUrl, response.clone()); - - // Cache resources - const html = await response.text(); - const scriptSrcs = [ - ...html.matchAll(/]+src=["']([^"']+)["']/g), - ].map(m => m[1]); - const linkHrefs = [ - ...html.matchAll(/]+href=["']([^"']+)["']/g), - ].map(m => m[1]); + 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(url => url.startsWith('/') || url.startsWith(self.location.origin)) + .map(url => new URL(url, self.location.origin).href); - const resources = [...scriptSrcs, ...linkHrefs] - .filter( - url => - url.startsWith('/') || - url.startsWith(self.location.origin), - ) - .map(url => new URL(url, self.location.origin).href); + await Promise.allSettled(resources.map(url => cacheResource(cache, url))); +}; - await Promise.allSettled( - resources.map(async url => { - const res = await fetch(url); - if (res && res.ok) await cache.put(url, res); - }), - ); +// Cache patterns +const CACHEABLE_FILES = [ + /\.js$/, + /\.css$/, + /\.woff2$/, + /reverb-3\.10\.2\.js$/, + /smarttag-.*\.min\.js$/, + /modern\.frosted_promo.*\.js$/, + /moment-lib.*\.js$/, + /\/images\/icons\/icon-.*\.png\??v?=?\d*$/, +]; - // eslint-disable-next-line no-console - console.log( - `[SW v${version}] ✅ Cached offline page for ${service}`, - ); - } - } catch (err) { - // eslint-disable-next-line no-console - console.error( - `[SW v${version}] Failed to cache ${service}:`, - err.message, - ); - } - }), - ); - } +const WEBP_IMAGE = + /^https:\/\/ichef(\.test)?\.bbci\.co\.uk\/(news|images|ace\/(standard|ws))\/.+\.webp$/; + +const isCacheableRequest = url => + CACHEABLE_FILES.some(pattern => pattern.test(url)); + +const handleWebPRequest = async request => { + if (!WEBP_IMAGE.test(request.url)) return null; + const accepts = request.headers.get('accept') || ''; + if (accepts.includes('webp')) return null; + const fallbackUrl = request.url.replace('.webp', ''); + try { + return await fetch(fallbackUrl, { mode: 'no-cors' }); + } catch { + return null; + } +}; - self.skipWaiting(); - })(), - ); +// -------------------- +// Service Worker Events +// -------------------- +self.addEventListener('install', event => { + console.log(`[SW v${version}] Installing...`); + self.skipWaiting(); }); self.addEventListener('activate', event => { - // eslint-disable-next-line no-console console.log(`[SW v${version}] Activating...`); event.waitUntil( (async () => { - // Clean up old caches from previous SW versions - const cacheNames = await caches.keys(); - const currentCaches = [cacheName]; - + const keys = await caches.keys(); await Promise.all( - cacheNames.map(cache => { - if (!currentCaches.includes(cache)) { - // eslint-disable-next-line no-console - console.log(`[SW v${version}] Deleting old cache: ${cache}`); - return caches.delete(cache); - } - return null; - }), + keys.map(key => key !== cacheName && caches.delete(key)), ); - - // Take control of all pages immediately await self.clients.claim(); })(), ); }); -const CACHEABLE_FILES = [ - // Reverb - /^https:\/\/static(?:\.test)?\.files\.bbci\.co\.uk\/ws\/(?:simorgh-assets|simorgh1-preview-assets|simorgh2-preview-assets)\/public\/static\/js\/reverb\/reverb-3.10.2.js$/, - // Smart Tag - 'https://mybbc-analytics.files.bbci.co.uk/reverb-client-js/smarttag-5.29.4.min.js', - // Fonts - /\.woff2$/, - // Frosted Promo (test and live environments only) - /^https:\/\/static(\.test)?\.files\.bbci\.co\.uk\/ws\/simorgh-assets\/public\/static\/js\/modern\.frosted_promo+.*?\.js$/, - // Moment - /\/moment-lib+.*?\.js$/, - // PWA Icons - /\/images\/icons\/icon-.*?\.png\??v?=?\d*$/, -]; - -const WEBP_IMAGE = - /^https:\/\/ichef(\.test)?\.bbci\.co\.uk\/(news|images|ace\/(standard|ws))\/.+.webp$/; - -const fetchEventHandler = async event => { - const isRequestForCacheableFile = CACHEABLE_FILES.some(cacheableFile => - new RegExp(cacheableFile).test(event.request.url), - ); - - const isRequestForWebpImage = WEBP_IMAGE.test(event.request.url); - - if (isRequestForWebpImage) { - const req = event.request.clone(); +self.addEventListener('message', async event => { + if (event.data?.type === 'PWA_STATUS') { + const clientId = event.source.id; + const isPWA = event.data.isPWA; + pwaClients.set(clientId, isPWA); - // Inspect the accept header for WebP support + console.log(`[SW v${version}] Client ${clientId} PWA status: ${isPWA}`); - 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( - fetch(imageUrlWithoutWebp, { - mode: 'no-cors', - }), - ); + if (isPWA) { + const service = getServiceFromUrl(event.source.url); + await cacheOfflinePageAndResources(service); } - } else if (isRequestForCacheableFile) { - event.respondWith( - (async () => { - const cache = await caches.open(cacheName); - let response = await cache.match(event.request); - if (!response) { - response = await fetch(event.request.url); - cache.put(event.request, response.clone()); - } - return response; - })(), - ); - } else if ( - hasOfflinePageFunctionality && - (event.request.mode === 'navigate' || - event.request.destination === 'script' || - event.request.destination === 'style') - ) { - event.respondWith( - (async () => { - const cache = await caches.open(cacheName); + } +}); - // Try cache first for scripts/styles - if ( - event.request.destination === 'script' || - event.request.destination === 'style' - ) { - const cachedResponse = await cache.match(event.request); - if (cachedResponse) { - return cachedResponse; - } - } +// -------------------- +// Fetch Handler +// -------------------- +self.addEventListener('fetch', event => { + event.respondWith( + (async () => { + const cache = await openCache(); + const request = event.request; + + // WebP fallback + const webpFallback = await handleWebPRequest(request); + if (webpFallback) return webpFallback; + + // Cache-first static assets + if (isCacheableRequest(request.url)) { + const cached = await cache.match(request); + if (cached) return cached; + const resp = await fetch(request); + cache.put(request, resp.clone()); + return resp; + } - // For navigation or if not in cache, try network + // Navigation requests + if (request.mode === 'navigate') { try { const preloadResponse = await event.preloadResponse; if (preloadResponse) { return preloadResponse; } - const networkResponse = await fetch(event.request); - - // Cache offline page for this service when online (PWA only) - if (event.request.mode === 'navigate' && networkResponse.ok) { - const client = await self.clients.get(event.clientId); - const isPWA = client && pwaClients.get(client.id); - - if (isPWA) { - const service = getServiceFromUrl(event.request.url); - const offlinePageUrl = new URL( - getOfflinePageUrl(service), - self.location.origin, - ).href; - - // Only cache if not already cached - const cachedOffline = await cache.match(offlinePageUrl); - if (!cachedOffline) { - // eslint-disable-next-line no-console - console.log(`[SW] Caching offline page for ${service}...`); - // Cache asynchronously, don't block navigation - fetch(offlinePageUrl) - .then(async offlineResponse => { - if (offlineResponse && offlineResponse.ok) { - await cache.put(offlinePageUrl, offlineResponse.clone()); - // eslint-disable-next-line no-console - console.log(`[SW] ✅ Cached ${offlinePageUrl}`); - - // Also cache JS/CSS resources - const html = await offlineResponse.text(); - const scriptSrcs = [ - ...html.matchAll(/]+src=["']([^"']+)["']/g), - ].map(m => m[1]); - const linkHrefs = [ - ...html.matchAll(/]+href=["']([^"']+)["']/g), - ].map(m => m[1]); + const networkResp = await fetch(request); - const resources = [...scriptSrcs, ...linkHrefs] - .filter( - url => - url.startsWith('/') || - url.startsWith(self.location.origin), - ) - .map(url => new URL(url, self.location.origin).href); + // Ensure clientId exists + const client = event.clientId + ? await self.clients.get(event.clientId) + : null; - await Promise.allSettled( - resources.map(async url => { - const res = await fetch(url); - if (res && res.ok) await cache.put(url, res); - }), - ); - } - }) - .catch(err => { - // eslint-disable-next-line no-console - console.error( - `[SW] Failed to cache offline page for ${service}:`, - err, - ); - }); - } - } + if (client && pwaClients.get(client.id)) { + const service = getServiceFromUrl(request.url); + await cacheOfflinePageAndResources(service); } - return networkResponse; - } catch (error) { - // Network failed - serve offline page for navigation (PWA only) - if (event.request.mode === 'navigate') { - // Check if client is in PWA mode - const client = await self.clients.get(event.clientId); - const isPWA = client && pwaClients.get(client.id); + return networkResp; + } catch (err) { + console.warn( + `[SW v${version}] Navigation failed, serving offline fallback...`, + ); - if (isPWA) { - // PWA mode - serve custom offline page - const service = getServiceFromUrl(event.request.url); - const offlinePageUrl = new URL( - getOfflinePageUrl(service), - self.location.origin, - ).href; - const cachedResponse = await cache.match(offlinePageUrl); - if (cachedResponse) { - return cachedResponse; - } - } + const client = event.clientId + ? await self.clients.get(event.clientId) + : null; - // Browser mode or no cached page - let browser handle it - return new Response('You are offline', { - status: 503, - headers: { 'Content-Type': 'text/plain' }, - }); + if (client && pwaClients.get(client.id)) { + const service = getServiceFromUrl(request.url); + const offlinePageUrl = new URL( + getOfflinePageUrl(service), + self.location.origin, + ).href; + const cachedOffline = await cache.match(offlinePageUrl); + if (cachedOffline) return cachedOffline; } - // For scripts/styles, return error response - return new Response('Offline', { status: 503 }); + + return new Response('You are offline', { + status: 503, + headers: { 'Content-Type': 'text/plain' }, + }); } - })(), - ); - } - return; -}; + } -self.addEventListener('fetch', fetchEventHandler); + return fetch(request); + })(), + ); +}); diff --git a/src/app/components/ServiceWorker/index.tsx b/src/app/components/ServiceWorker/index.tsx index 33e26b216f1..8ae62ff8342 100644 --- a/src/app/components/ServiceWorker/index.tsx +++ b/src/app/components/ServiceWorker/index.tsx @@ -36,14 +36,14 @@ export default () => { const { isAmp, canonicalLink } = use(RequestContext); const swSrc = `${getEnvConfig().SIMORGH_BASE_URL}/${service}${swPath}`; - useEffect(() => { - const shouldInstallServiceWorker = - swPath && onClient() && 'serviceWorker' in navigator; + // useEffect(() => { + // const shouldInstallServiceWorker = + // swPath && onClient() && 'serviceWorker' in navigator; - if (shouldInstallServiceWorker) { - navigator.serviceWorker.register(`/${service}${swPath}`); - } - }, [swPath, service]); + // if (shouldInstallServiceWorker) { + // navigator.serviceWorker.register(`/${service}${swPath}`); + // } + // }, [swPath, service]); return isAmp && swPath ? ( <> diff --git a/ws-nextjs-app/pages/_app.page.tsx b/ws-nextjs-app/pages/_app.page.tsx index 3757dfdbad3..d68ea5d969c 100644 --- a/ws-nextjs-app/pages/_app.page.tsx +++ b/ws-nextjs-app/pages/_app.page.tsx @@ -92,14 +92,55 @@ export default function App({ Component, pageProps }: Props) { // Send PWA status to service worker useEffect(() => { - if (typeof window !== 'undefined' && navigator.serviceWorker.controller) { - navigator.serviceWorker.controller.postMessage({ - type: 'PWA_STATUS', - isPWA, - }); - } + const sendPWAStatus = () => { + if (typeof window !== 'undefined' && navigator.serviceWorker.controller) { + console.log('Sending PWA status to SW', isPWA); + navigator.serviceWorker.controller.postMessage({ + type: 'PWA_STATUS', + isPWA, + }); + } + }; + + // Send initially in case SW already controls page + // sendPWAStatus(); + navigator.serviceWorker.ready.then(sendPWAStatus); + + // Listen for SW taking control + navigator.serviceWorker.addEventListener('controllerchange', sendPWAStatus); + + return () => { + navigator.serviceWorker.removeEventListener( + 'controllerchange', + sendPWAStatus, + ); + }; }, [isPWA]); + // useEffect(() => { + // if (!('serviceWorker' in navigator)) return; + + // function send() { + // if (!navigator.serviceWorker.controller) return; + // console.log('Sending PWA status to SW:', isPWA); + // navigator.serviceWorker.controller.postMessage({ + // type: 'PWA_STATUS', + // isPWA, + // }); + // } + + // // Wait until SW is ready + // navigator.serviceWorker.ready.then(send); + + // // Also send when controller becomes active + // navigator.serviceWorker.addEventListener('controllerchange', send); + + // // Retry a moment later (SW init delay) + // const t = setTimeout(send, 500); + + // return () => clearTimeout(t); + // }, [isPWA]); + const RenderChildrenOrError = status === 200 ? ( From b7de762e3a6f957e4a30748530aec21bc10374dd Mon Sep 17 00:00:00 2001 From: jinidev Date: Mon, 8 Dec 2025 18:31:32 +0200 Subject: [PATCH 35/95] some more changes - cleaned sw.js --- public/sw.js | 220 +++++++++++++----- src/app/components/ServiceWorker/index.tsx | 4 +- .../[service]/live/[id]/LivePageLayout.tsx | 3 + ws-nextjs-app/pages/_app.page.tsx | 13 +- ws-nextjs-app/pages/_document.page.tsx | 64 +++++ 5 files changed, 234 insertions(+), 70 deletions(-) diff --git a/public/sw.js b/public/sw.js index eca2eed73b9..628321bdd0c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,5 @@ -const version = 'v0.3.1'; +/* eslint-disable no-console */ +const version = 'v0.3.6'; const cacheName = 'simorghCache_v1'; const hasOfflinePageFunctionality = true; @@ -26,16 +27,23 @@ const cacheResource = async (cache, url) => { }; const cacheOfflinePageAndResources = async service => { + console.log('Inside cacheOfflinePageAndResources--- 1111 ---', service); if (!hasOfflinePageFunctionality) return; const cache = await openCache(); const offlinePageUrl = new URL( getOfflinePageUrl(service), self.location.origin, ).href; + console.log( + 'Inside cacheOfflinePageAndResources--- 2222 ---', + offlinePageUrl, + ); if (await cache.match(offlinePageUrl)) return; const resp = await cacheResource(cache, offlinePageUrl); + console.log('Inside cacheOfflinePageAndResources--- 333 ---', resp); + if (!resp || !resp.ok) return; console.log(`[SW v${version}] Cached offline page for ${service}`); @@ -87,12 +95,60 @@ const handleWebPRequest = async request => { // -------------------- // Service Worker Events // -------------------- +// self.addEventListener('install', event => { +// console.log(`[SW v${version}] Installing...`); +// event.waitUntil( +// (async () => { +// const service = getServiceFromUrl(event.source.url); +// await cacheOfflinePageAndResources(service); +// })(), +// ); + +// self.skipWaiting(); +// }); + self.addEventListener('install', event => { + // eslint-disable-next-line no-console console.log(`[SW v${version}] Installing...`); - self.skipWaiting(); + + event.waitUntil( + (async () => { + if (hasOfflinePageFunctionality) { + const cache = await caches.open(cacheName); + const clients = await self.clients.matchAll({ type: 'window' }); + + // Get unique services from PWA clients only + const pwaServices = [ + ...new Set( + clients + .filter(client => pwaClients.get(client.id)) + .map(client => getServiceFromUrl(client.url)) + .filter(Boolean), + ), + ]; + + if (pwaServices.length > 0) { + // eslint-disable-next-line no-console + console.log( + `[SW v${version}] Caching offline pages for PWA:`, + pwaServices, + ); + } + + // Cache offline pages for PWA services only + await Promise.allSettled( + pwaServices.map(async service => { + return cacheOfflinePageAndResources(service); + }), + ); + } + self.skipWaiting(); + })(), + ); }); self.addEventListener('activate', event => { + // eslint-disable-next-line no-console console.log(`[SW v${version}] Activating...`); event.waitUntil( (async () => { @@ -111,7 +167,9 @@ self.addEventListener('message', async event => { const isPWA = event.data.isPWA; pwaClients.set(clientId, isPWA); - console.log(`[SW v${version}] Client ${clientId} PWA status: ${isPWA}`); + console.log( + `Inside messagemessagemessage [SW v${version}] Client ${clientId} PWA status: ${isPWA}`, + ); if (isPWA) { const service = getServiceFromUrl(event.source.url); @@ -123,72 +181,106 @@ self.addEventListener('message', async event => { // -------------------- // Fetch Handler // -------------------- -self.addEventListener('fetch', event => { - event.respondWith( - (async () => { - const cache = await openCache(); - const request = event.request; - - // WebP fallback - const webpFallback = await handleWebPRequest(request); - if (webpFallback) return webpFallback; - - // Cache-first static assets - if (isCacheableRequest(request.url)) { - const cached = await cache.match(request); - if (cached) return cached; - const resp = await fetch(request); - cache.put(request, resp.clone()); - return resp; +const fetchEventHandler = async event => { + const request = event.request; + const url = request.url; + + console.log(`[SW FETCH] ${url}`); + + // Clone accept header for WebP check + const isWebpRequest = WEBP_IMAGE.test(url); + + if (isWebpRequest) { + const fallbackResp = await handleWebPRequest(request); + if (fallbackResp) return fallbackResp; + } + + const cache = await openCache(); + + // -------------------------- + // 1. Cache-first static assets + // -------------------------- + if (isCacheableRequest(url)) { + const cached = await cache.match(request); + if (cached) return cached; + + try { + const networkResp = await fetch(request); + if (networkResp && networkResp.ok) { + cache.put(request, networkResp.clone()); } + return networkResp; + } catch (err) { + console.error('[SW] Cacheable request failed:', url, err); + return new Response('Offline', { status: 503 }); + } + } + + // -------------------------- + // 2. Navigation requests + // -------------------------- + if (request.mode === 'navigate') { + console.log(`[SW FETCH] Navigation: ${url}`); + + try { + // Use preload if available + const preloadResp = await event.preloadResponse; + if (preloadResp) return preloadResp; + + const networkResp = await fetch(request); - // Navigation requests - if (request.mode === 'navigate') { - try { - const preloadResponse = await event.preloadResponse; - if (preloadResponse) { - return preloadResponse; - } - const networkResp = await fetch(request); - - // Ensure clientId exists - const client = event.clientId - ? await self.clients.get(event.clientId) - : null; - - if (client && pwaClients.get(client.id)) { - const service = getServiceFromUrl(request.url); - await cacheOfflinePageAndResources(service); - } - - return networkResp; - } catch (err) { - console.warn( - `[SW v${version}] Navigation failed, serving offline fallback...`, + // Cache offline page if in PWA mode + if (networkResp && networkResp.ok && event.clientId) { + const client = await self.clients.get(event.clientId); + const isPWA = client && pwaClients.get(client.id); + + if (isPWA) { + const service = getServiceFromUrl(url); + cacheOfflinePageAndResources(service).catch(err => + console.error('[SW] Cache offline fail:', err), ); + } + } + + return networkResp; + } catch (err) { + console.error('[SW] Navigation failed:', url, err); + + // Attempt to serve offline page if PWA + if (event.clientId) { + const client = await self.clients.get(event.clientId); + const isPWA = client && pwaClients.get(client.id); + + if (isPWA) { + const service = getServiceFromUrl(url); + const offlineUrl = new URL( + getOfflinePageUrl(service), + self.location.origin, + ).href; - const client = event.clientId - ? await self.clients.get(event.clientId) - : null; - - if (client && pwaClients.get(client.id)) { - const service = getServiceFromUrl(request.url); - const offlinePageUrl = new URL( - getOfflinePageUrl(service), - self.location.origin, - ).href; - const cachedOffline = await cache.match(offlinePageUrl); - if (cachedOffline) return cachedOffline; - } - - return new Response('You are offline', { - status: 503, - headers: { 'Content-Type': 'text/plain' }, - }); + const cachedOffline = await cache.match(offlineUrl); + if (cachedOffline) return cachedOffline; } } - return fetch(request); - })(), - ); + return new Response('You are offline', { + status: 503, + headers: { 'Content-Type': 'text/plain' }, + }); + } + } + + // -------------------------- + // 3. Fall-through: Always network + // -------------------------- + try { + return await fetch(request); + } catch (err) { + console.error('[SW] Fetch failed:', url, err); + return new Response('Offline', { status: 503 }); + } +}; + +self.addEventListener('fetch', event => { + event.respondWith(fetchEventHandler(event)); }); diff --git a/src/app/components/ServiceWorker/index.tsx b/src/app/components/ServiceWorker/index.tsx index 8ae62ff8342..d0eed844aff 100644 --- a/src/app/components/ServiceWorker/index.tsx +++ b/src/app/components/ServiceWorker/index.tsx @@ -1,6 +1,6 @@ -import { use, useEffect } from 'react'; +import { use } from 'react'; import { Helmet } from 'react-helmet'; -import onClient from '#lib/utilities/onClient'; +// import onClient from '#lib/utilities/onClient'; import { RequestContext } from '#contexts/RequestContext'; import { getEnvConfig } from '#app/lib/utilities/getEnvConfig'; import { ServiceContext } from '../../contexts/ServiceContext'; diff --git a/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx b/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx index 306ddeca8a6..1872384b7ef 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx @@ -168,6 +168,9 @@ const LivePage = ({ pageData, assetId }: LivePageProps) => { imageWidth={imageWidth} mediaCollections={mediaCollections} /> + + click me +
{keyPoints && ( diff --git a/ws-nextjs-app/pages/_app.page.tsx b/ws-nextjs-app/pages/_app.page.tsx index d68ea5d969c..39642ccccd9 100644 --- a/ws-nextjs-app/pages/_app.page.tsx +++ b/ws-nextjs-app/pages/_app.page.tsx @@ -95,10 +95,15 @@ export default function App({ Component, pageProps }: Props) { const sendPWAStatus = () => { if (typeof window !== 'undefined' && navigator.serviceWorker.controller) { console.log('Sending PWA status to SW', isPWA); - navigator.serviceWorker.controller.postMessage({ - type: 'PWA_STATUS', - isPWA, - }); + if ( + navigator.serviceWorker.controller && + navigator.serviceWorker.controller.state === 'activated' + ) { + navigator.serviceWorker.controller.postMessage({ + type: 'PWA_STATUS', + isPWA, + }); + } } }; diff --git a/ws-nextjs-app/pages/_document.page.tsx b/ws-nextjs-app/pages/_document.page.tsx index ec46ce0fac5..c98432b94be 100644 --- a/ws-nextjs-app/pages/_document.page.tsx +++ b/ws-nextjs-app/pages/_document.page.tsx @@ -197,6 +197,70 @@ export default class AppDocument extends Document { __html: `document.documentElement.classList.remove("no-js");`, }} /> + {/*