From 18ec2b639c9b97e18dae49cffa0bb484f970ddb3 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Thu, 14 May 2026 18:31:03 +0100 Subject: [PATCH 01/14] feat: Base service worker for handling fetch() cycle (cache when online, fetch from cache if offline) --- public/service-worker.js | 130 +++++++++++++++++++++++++++++++++++++++ src/web-component.html | 12 ++++ 2 files changed, 142 insertions(+) create mode 100644 public/service-worker.js diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 000000000..9cc134699 --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,130 @@ +/* eslint-env serviceworker */ +/* eslint-disable no-restricted-globals */ + +// "editor-app-v1" is replaced with the package version at build time (see webpack.config.js) +const APP_CACHE = "editor-app-v1"; +const PYODIDE_CACHE = "pyodide-v0.26.2"; + +// Minimal set of assets to pre-cache on install +// All other assets (chunks, translations, etc.) are cached dynamically on first use via the network-first fetch handler below +const APP_SHELL = [ + "./web-component.html", + "./web-component.js", + "./PyodideWorker.js", + "./manifest.json", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(APP_CACHE) + .then((cache) => + cache + .addAll(APP_SHELL) + .catch((err) => + console.warn( + "[SW] Pre-cache failed, will rely on dynamic caching:", + err, + ), + ), + ), + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((key) => key !== APP_CACHE && key !== PYODIDE_CACHE) + .map((key) => { + console.log("[SW] Deleting old cache:", key); + return caches.delete(key); + }), + ), + ), + ); + self.clients.claim(); +}); + +// Pyodide needs SharedArrayBuffer which requires the page to be cross-origin isolated. That means serving COOP + COEP on the HTML response, and CORP on every cross-origin resource the page loads +function addSecurityHeaders(response) { + if (response.type === "opaque") return response; + const headers = new Headers(response.headers); + headers.set("Cross-Origin-Opener-Policy", "same-origin"); + headers.set("Cross-Origin-Embedder-Policy", "require-corp"); + headers.set("Cross-Origin-Resource-Policy", "cross-origin"); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +function broadcastOffline() { + self.clients + .matchAll() + .then((clients) => clients.forEach((c) => c.postMessage({ type: "OFFLINE" }))); +} + +// Network-first: try the network, update the cache, fall back to cache +async function networkFirst(request, cacheName) { + const cache = await caches.open(cacheName); + try { + const networkResponse = await fetch(request); + if (networkResponse.ok) { + cache.put(request, addSecurityHeaders(networkResponse.clone())); + } + return addSecurityHeaders(networkResponse); + } catch { + const cached = await cache.match(request); + if (cached) { + broadcastOffline(); + return addSecurityHeaders(cached); + } + return Response.error(); + } +} + +// Cache-first: serve from cache when available, populate cache on first fetch +// importScripts produces opaque responses we can't modify, so we re-fetch as cors +async function cacheFirst(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request.url); + if (cached) return cached; + + const corsRequest = new Request(request.url, { + mode: "cors", + credentials: "omit", + }); + const networkResponse = await fetch(corsRequest); + cache.put(request.url, addSecurityHeaders(networkResponse.clone())); + return addSecurityHeaders(networkResponse); +} + +self.addEventListener("fetch", (event) => { + // Chrome bug: skip only-if-cached requests for cross-origin resources + if ( + event.request.cache === "only-if-cached" && + event.request.mode !== "same-origin" + ) { + return; + } + + const url = new URL(event.request.url); + + // Pyodide CDN assets are cache-first since URLs are version-pinned + if ( + url.hostname === "cdn.jsdelivr.net" && + url.pathname.includes("/pyodide/") + ) { + event.respondWith(cacheFirst(event.request, PYODIDE_CACHE)); + return; + } + + // Same-origin app assets are network-first so users get fresh content online + if (url.origin === self.location.origin) { + event.respondWith(networkFirst(event.request, APP_CACHE)); + } +}); diff --git a/src/web-component.html b/src/web-component.html index 0611083eb..932795a5e 100644 --- a/src/web-component.html +++ b/src/web-component.html @@ -52,6 +52,18 @@ Editor Web component + <% if (htmlWebpackPlugin.options.enableSW) { %> + + <% } %>
From eba66d7577d4bf304d43c3ae8b2ee9f45d22ffc4 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Thu, 14 May 2026 18:31:40 +0100 Subject: [PATCH 02/14] feat: toggle for SW, also pass package version to SW to use as cache key --- webpack.config.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/webpack.config.js b/webpack.config.js index 2a95ff47b..7334d84e3 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -13,6 +13,8 @@ if (!publicUrl.endsWith("/")) { publicUrl += "/"; } const isDev = process.env.NODE_ENV !== "production"; +const enableSW = + !isDev || process.env.REACT_APP_ENABLE_SERVICE_WORKER === "true"; const toOrigin = (envVarName, value) => { const normalizedValue = String(value || "") @@ -223,6 +225,7 @@ const mainConfig = { template: "src/web-component.html", filename: "web-component.html", chunks: ["web-component"], + enableSW, }), new HtmlWebpackPlugin({ inject: "body", @@ -232,7 +235,17 @@ const mainConfig = { }), new CopyWebpackPlugin({ patterns: [ - { from: "public", to: "" }, + { + from: "public", + to: "", + transform: (content, filePath) => { + if (!filePath.endsWith("service-worker.js")) return content; + const version = process.env.npm_package_version; + return content + .toString() + .replace("editor-app-v1", `editor-app-v${version}`); + }, + }, { from: "src/projects", to: "projects" }, ], }), From 22b149e57c6040406f03f10f991483006583e3ef Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Thu, 14 May 2026 18:32:07 +0100 Subject: [PATCH 03/14] feat: isOnline effect for handling on/offline UI changes --- src/hooks/useIsOnline.js | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/hooks/useIsOnline.js diff --git a/src/hooks/useIsOnline.js b/src/hooks/useIsOnline.js new file mode 100644 index 000000000..7ecb7beff --- /dev/null +++ b/src/hooks/useIsOnline.js @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; + +const useIsOnline = () => { + const [isOnline, setIsOnline] = useState(navigator.onLine); + + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + // The service worker is authoritative: it broadcasts OFFLINE whenever a + // network-first fetch falls back to cache, which reliably catches the case + // where navigator.onLine hasn't settled yet after a page reload offline. + const handleSWMessage = ({ data }) => { + if (data?.type === "OFFLINE") setIsOnline(false); + }; + if ("serviceWorker" in navigator) { + navigator.serviceWorker.addEventListener("message", handleSWMessage); + } + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + if ("serviceWorker" in navigator) { + navigator.serviceWorker.removeEventListener("message", handleSWMessage); + } + }; + }, []); + + return isOnline; +}; + +export default useIsOnline; From cafc8b0d0852daf3410f1d0e41fd18227066dcf8 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Thu, 14 May 2026 18:32:19 +0100 Subject: [PATCH 04/14] feat: "Offline" UI --- public/translations/en.json | 1 + src/components/SaveButton/SaveButton.jsx | 42 ++++++++++++++---------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/public/translations/en.json b/public/translations/en.json index 584618c9f..80e88fc97 100644 --- a/public/translations/en.json +++ b/public/translations/en.json @@ -83,6 +83,7 @@ "renameSave": "Save project name", "save": "Save", "loginToSave": "Log in to save", + "offline": "Offline", "settings": "Settings" }, "imagePanel": { diff --git a/src/components/SaveButton/SaveButton.jsx b/src/components/SaveButton/SaveButton.jsx index 3b47ecd5c..7dff6516f 100644 --- a/src/components/SaveButton/SaveButton.jsx +++ b/src/components/SaveButton/SaveButton.jsx @@ -9,6 +9,7 @@ import { isOwner } from "../../utils/projectHelpers"; import DesignSystemButton from "../DesignSystemButton/DesignSystemButton"; import SaveIcon from "../../assets/icons/save.svg"; import { triggerSave } from "../../redux/EditorSlice"; +import useIsOnline from "../../hooks/useIsOnline"; const SaveButton = ({ className, type, fill = false }) => { const dispatch = useDispatch(); @@ -19,6 +20,7 @@ const SaveButton = ({ className, type, fill = false }) => { const webComponent = useSelector((state) => state.editor.webComponent); const user = useSelector((state) => state.auth.user); const project = useSelector((state) => state.editor.project); + const isOnline = useIsOnline(); useEffect(() => { if (!type) { @@ -36,24 +38,30 @@ const SaveButton = ({ className, type, fill = false }) => { const projectOwner = isOwner(user, project); + if (loading !== "success" || projectOwner || !buttonType) return null; + + if (!isOnline && !user) { + return ( + + {t("header.offline")} + + ); + } + return ( - loading === "success" && - !projectOwner && - buttonType && ( - } - type={buttonType} - fill={fill} - /> - ) + } + type={buttonType} + fill={fill} + /> ); }; From 14270294dc7c1669cefad33d2077c102b0df5b97 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Thu, 14 May 2026 18:34:24 +0100 Subject: [PATCH 05/14] fix: Improve comments re. offline state broadcast --- src/hooks/useIsOnline.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/hooks/useIsOnline.js b/src/hooks/useIsOnline.js index 7ecb7beff..e1c480267 100644 --- a/src/hooks/useIsOnline.js +++ b/src/hooks/useIsOnline.js @@ -9,9 +9,8 @@ const useIsOnline = () => { window.addEventListener("online", handleOnline); window.addEventListener("offline", handleOffline); - // The service worker is authoritative: it broadcasts OFFLINE whenever a - // network-first fetch falls back to cache, which reliably catches the case - // where navigator.onLine hasn't settled yet after a page reload offline. + // The service worker broadcasts OFFLINE whenever a network-first fetch falls back to cache, which reliably catches the case where navigator.onLine hasn't settled yet after a page reload when offline + // This ensures that we can show "offline" state / UI immediately on page load when offline const handleSWMessage = ({ data }) => { if (data?.type === "OFFLINE") setIsOnline(false); }; From 1256fd48655f7b1640fa269763fbaefdca894a79 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Thu, 14 May 2026 18:50:54 +0100 Subject: [PATCH 06/14] feat: Offline indicator styling w/ tooltip --- public/translations/en.json | 2 + src/assets/icons/offline.svg | 3 + src/assets/stylesheets/InternalStyles.scss | 1 + src/assets/stylesheets/OfflineIndicator.scss | 67 ++++++++++++++++++++ src/components/SaveButton/SaveButton.jsx | 12 +++- 5 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 src/assets/icons/offline.svg create mode 100644 src/assets/stylesheets/OfflineIndicator.scss diff --git a/public/translations/en.json b/public/translations/en.json index 80e88fc97..7b2b2c7cf 100644 --- a/public/translations/en.json +++ b/public/translations/en.json @@ -84,6 +84,8 @@ "save": "Save", "loginToSave": "Log in to save", "offline": "Offline", + "offlineTooltipDevice": "Code changes are being saved to your device.", + "offlineTooltipContinue": "You can keep coding and your work will be saved when you are back online.", "settings": "Settings" }, "imagePanel": { diff --git a/src/assets/icons/offline.svg b/src/assets/icons/offline.svg new file mode 100644 index 000000000..0e4cb349a --- /dev/null +++ b/src/assets/icons/offline.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/stylesheets/InternalStyles.scss b/src/assets/stylesheets/InternalStyles.scss index 01f136b70..f790feb70 100644 --- a/src/assets/stylesheets/InternalStyles.scss +++ b/src/assets/stylesheets/InternalStyles.scss @@ -37,6 +37,7 @@ @use "./Button" as *; @use "./DesignSystemButton" as *; @use "./SaveStatus" as *; +@use "./OfflineIndicator" as *; @use "./ContextMenu" as *; @use "./FilePanel" as *; // needs to be below Button @use "./EmbeddedViewer" as *; diff --git a/src/assets/stylesheets/OfflineIndicator.scss b/src/assets/stylesheets/OfflineIndicator.scss new file mode 100644 index 000000000..3c8b4e446 --- /dev/null +++ b/src/assets/stylesheets/OfflineIndicator.scss @@ -0,0 +1,67 @@ +@use "./rpf_design_system/colours" as *; +@use "./rpf_design_system/spacing" as *; + +.offline-badge { + position: relative; + display: inline-flex; + align-items: center; + gap: $space-0-5; + padding-inline: $space-0-5; + padding-block: $space-0-5; + border-radius: 100vw; + border: 3px solid $rpf-red-400; + color: $rpf-red-900; + white-space: nowrap; + cursor: default; + background-color: $rpf-red-100; + font-weight: bold; + + svg { + flex-shrink: 0; + } + + &__tooltip { + position: absolute; + inset-block-start: calc(100% + $space-1); + inset-inline-end: 0; + inline-size: 20rem; + padding: $space-1; + border-radius: $space-0-5; + background: #1d1d1d; + color: #fff; + font-size: 1rem; + white-space: normal; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 0.15s ease; + z-index: 100; + + &::before { + content: ""; + position: absolute; + top: -8px; + right: 20px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-bottom: 8px solid #1d1d1d; + } + + p { + margin: 0; + + & + p { + margin-block-start: $space-1-5; + } + } + } + + &:hover &__tooltip, + &:focus-within &__tooltip { + opacity: 1; + visibility: visible; + pointer-events: auto; + } +} diff --git a/src/components/SaveButton/SaveButton.jsx b/src/components/SaveButton/SaveButton.jsx index 7dff6516f..923314690 100644 --- a/src/components/SaveButton/SaveButton.jsx +++ b/src/components/SaveButton/SaveButton.jsx @@ -8,6 +8,7 @@ import { isOwner } from "../../utils/projectHelpers"; import DesignSystemButton from "../DesignSystemButton/DesignSystemButton"; import SaveIcon from "../../assets/icons/save.svg"; +import OfflineIcon from "../../assets/icons/offline.svg"; import { triggerSave } from "../../redux/EditorSlice"; import useIsOnline from "../../hooks/useIsOnline"; @@ -42,9 +43,14 @@ const SaveButton = ({ className, type, fill = false }) => { if (!isOnline && !user) { return ( - - {t("header.offline")} - +
+ + {t("header.offline")} +
+

{t("header.offlineTooltipDevice")}

+

{t("header.offlineTooltipContinue")}

+
+
); } From c4bb1e3d12a7c0d259d06715f41d61b61e344bcc Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Thu, 14 May 2026 19:00:52 +0100 Subject: [PATCH 07/14] feat: Allow service worker / offline mode to be toggled via web component attri --- src/components/SaveButton/SaveButton.jsx | 3 ++- src/containers/WebComponentLoader.jsx | 6 ++++++ src/redux/EditorSlice.js | 5 +++++ src/web-component.js | 2 ++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/SaveButton/SaveButton.jsx b/src/components/SaveButton/SaveButton.jsx index 923314690..2ee1142ad 100644 --- a/src/components/SaveButton/SaveButton.jsx +++ b/src/components/SaveButton/SaveButton.jsx @@ -21,6 +21,7 @@ const SaveButton = ({ className, type, fill = false }) => { const webComponent = useSelector((state) => state.editor.webComponent); const user = useSelector((state) => state.auth.user); const project = useSelector((state) => state.editor.project); + const offlineEnabled = useSelector((state) => state.editor.offlineEnabled); const isOnline = useIsOnline(); useEffect(() => { @@ -41,7 +42,7 @@ const SaveButton = ({ className, type, fill = false }) => { if (loading !== "success" || projectOwner || !buttonType) return null; - if (!isOnline && !user) { + if (offlineEnabled && !isOnline && !user) { return (
diff --git a/src/containers/WebComponentLoader.jsx b/src/containers/WebComponentLoader.jsx index df4aa7cc4..7f86dca3f 100644 --- a/src/containers/WebComponentLoader.jsx +++ b/src/containers/WebComponentLoader.jsx @@ -3,6 +3,7 @@ import { useSelector, useDispatch } from "react-redux"; import { disableTheming, setSenseHatAlwaysEnabled, + setOfflineEnabled, setLoadRemixDisabled, setReactAppApiEndpoint, setScratchApiEndpoint, @@ -75,6 +76,7 @@ const WebComponentLoader = (props) => { withSidebar = false, loadCache = true, // Always use cache unless explicitly disabled initialProject = null, + offlineEnabled = false, } = props; const dispatch = useDispatch(); @@ -199,6 +201,10 @@ const WebComponentLoader = (props) => { dispatch(setReadOnly(readOnly)); }, [readOnly, dispatch]); + useEffect(() => { + dispatch(setOfflineEnabled(offlineEnabled)); + }, [offlineEnabled, dispatch]); + useEffect(() => { // Create a script element to save the existing Prism object if there is one const script = document.createElement("script"); diff --git a/src/redux/EditorSlice.js b/src/redux/EditorSlice.js index 096189265..0d667ed4c 100644 --- a/src/redux/EditorSlice.js +++ b/src/redux/EditorSlice.js @@ -112,6 +112,7 @@ export const editorInitialState = { lastSaveAutosave: false, lastSavedTime: null, senseHatAlwaysEnabled: false, + offlineEnabled: false, senseHatEnabled: false, loadRemixDisabled: false, betaModalShowing: false, @@ -248,6 +249,9 @@ export const EditorSlice = createSlice({ setSenseHatAlwaysEnabled: (state, action) => { state.senseHatAlwaysEnabled = action.payload; }, + setOfflineEnabled: (state, action) => { + state.offlineEnabled = action.payload; + }, setSenseHatEnabled: (state, action) => { state.senseHatEnabled = action.payload; }, @@ -477,6 +481,7 @@ export const { setReadOnly, setInstructionsEditable, setSenseHatAlwaysEnabled, + setOfflineEnabled, setSenseHatEnabled, setLoadRemixDisabled, setReactAppApiEndpoint, diff --git a/src/web-component.js b/src/web-component.js index dcabd62c6..89d29da97 100644 --- a/src/web-component.js +++ b/src/web-component.js @@ -79,6 +79,7 @@ class WebComponent extends HTMLElement { "with_projectbar", "with_sidebar", "load_cache", + "offline_enabled", ]; } @@ -97,6 +98,7 @@ class WebComponent extends HTMLElement { "with_projectbar", "with_sidebar", "load_cache", + "offline_enabled", ]; const jsonAttrs = [ "instructions", From 35adae02c7175f4e82c641233d63bb5a887a735d Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Thu, 14 May 2026 19:06:35 +0100 Subject: [PATCH 08/14] chore: Document offline support, incl. offline_enabled attr for web component --- README.md | 28 ++++++++++++++++++++++++++++ src/web-component.html | 2 ++ 2 files changed, 30 insertions(+) diff --git a/README.md b/README.md index 5d20b0ed8..4ff1fe8f5 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ The `editor-wc` tag accepts the following attributes, which must be provided as - `output_split_view`: Start with split view in output panel (defaults to `false`, i.e. tabbed view) - `project_name_editable`: Allow the user to edit the project name in the project bar (defaults to `false`) - `react_app_api_endpoint`: API endpoint to send project-related requests to +- `offline_enabled`: Show an offline indicator when the user's device loses connectivity (defaults to `false`). Requires the service worker to be registered on the host page — see [Offline support](#offline-support). - `read_only`: Display the editor in read only mode (defaults to `false`) - `sense_hat_always_enabled`: Show the Astro Pi Sense HAT emulator on page load (defaults to `false`) - `show_save_prompt`: Prompt the user to save their work (defaults to `false`) @@ -130,6 +131,33 @@ The host page is able to communicate with the web component via custom methods p This allows the host page to query the current code in the editor and to control code runs from outside the web component, for example. +### Offline support + +The web component ships a service worker (`service-worker.js`) that caches the editor shell and Pyodide assets so the component remains usable after a network loss. + +To enable offline support on your host page: + +1. Register the service worker from your host page (or let the bundled `web-component.html` do it automatically): + ```js + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("./service-worker.js"); + } + ``` +2. Pass the `offline_enabled` attribute to the web component so the offline indicator is shown when connectivity is lost: + ```html + + ``` + +Offline mode is opt-in — neither the service worker registration nor the offline badge will appear unless these steps are taken. + +#### Developing with offline support + +The service worker is not registered in development by default. To enable it locally, set the environment variable before starting the dev server: + +```sh +REACT_APP_ENABLE_SERVICE_WORKER=true yarn start +``` + ## Development ### Previewing diff --git a/src/web-component.html b/src/web-component.html index 932795a5e..c4bef9185 100644 --- a/src/web-component.html +++ b/src/web-component.html @@ -109,6 +109,8 @@ newWebComp.setAttribute("with_projectbar", "true"); newWebComp.setAttribute("with_sidebar", "true"); newWebComp.setAttribute("use_editor_styles", "true"); + // see "Offline support" section in README for more details + newWebComp.setAttribute("offline_enabled", "true"); newWebComp.setAttribute( "sidebar_options", JSON.stringify([ From 4b148d17e3976f15d46b76e51086a27ab88b36e7 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Thu, 14 May 2026 19:12:33 +0100 Subject: [PATCH 09/14] chore: add tests for offline mode Check useIsOnline hook's events Ensure offlineEnabled correctly handled Ensure offline badge is displayed --- src/components/SaveButton/SaveButton.test.js | 71 ++++++++++++++++++ src/hooks/useIsOnline.test.js | 77 ++++++++++++++++++++ src/redux/EditorSlice.test.js | 7 ++ 3 files changed, 155 insertions(+) create mode 100644 src/hooks/useIsOnline.test.js diff --git a/src/components/SaveButton/SaveButton.test.js b/src/components/SaveButton/SaveButton.test.js index 146720e0d..b6d8a6b2a 100644 --- a/src/components/SaveButton/SaveButton.test.js +++ b/src/components/SaveButton/SaveButton.test.js @@ -5,6 +5,10 @@ import configureStore from "redux-mock-store"; import { triggerSave } from "../../redux/EditorSlice"; import SaveButton from "./SaveButton"; +jest.mock("../../hooks/useIsOnline"); + +import useIsOnline from "../../hooks/useIsOnline"; + const logInHandler = jest.fn(); describe("When project is loaded", () => { @@ -12,6 +16,10 @@ describe("When project is loaded", () => { document.addEventListener("editor-logIn", logInHandler); }); + beforeEach(() => { + useIsOnline.mockReturnValue(true); + }); + describe("With logged in user", () => { let store; @@ -204,6 +212,69 @@ describe("When project is loaded", () => { }); }); + describe("offline badge", () => { + const offlineState = { + editor: { + loading: "success", + webComponent: true, + offlineEnabled: true, + project: {}, + }, + auth: {}, + }; + + beforeEach(() => { + useIsOnline.mockReturnValue(false); + }); + + test("shows offline badge when offline and offlineEnabled is true", () => { + const store = configureStore([])(offlineState); + render( + + + , + ); + expect(screen.queryByText("header.offline")).toBeInTheDocument(); + }); + + test("does not show offline badge when offlineEnabled is false", () => { + const store = configureStore([])({ + ...offlineState, + editor: { ...offlineState.editor, offlineEnabled: false }, + }); + render( + + + , + ); + expect(screen.queryByText("header.offline")).not.toBeInTheDocument(); + }); + + test("does not show offline badge when online, even if offlineEnabled is true", () => { + useIsOnline.mockReturnValue(true); + const store = configureStore([])(offlineState); + render( + + + , + ); + expect(screen.queryByText("header.offline")).not.toBeInTheDocument(); + }); + + test("does not show offline badge when user is logged in", () => { + const store = configureStore([])({ + ...offlineState, + auth: { user: { profile: { user: "some-user" } } }, + }); + render( + + + , + ); + expect(screen.queryByText("header.offline")).not.toBeInTheDocument(); + }); + }); + afterAll(() => { document.removeEventListener("editor-logIn", logInHandler); }); diff --git a/src/hooks/useIsOnline.test.js b/src/hooks/useIsOnline.test.js new file mode 100644 index 000000000..52dacdeac --- /dev/null +++ b/src/hooks/useIsOnline.test.js @@ -0,0 +1,77 @@ +import { act, renderHook } from "@testing-library/react"; +import useIsOnline from "./useIsOnline"; + +const dispatchWindowEvent = (type) => { + act(() => { + window.dispatchEvent(new Event(type)); + }); +}; + +describe("useIsOnline", () => { + let swEventTarget; + + beforeEach(() => { + Object.defineProperty(navigator, "onLine", { + configurable: true, + get: () => true, + }); + + // jsdom doesn't implement navigator.serviceWorker so provide a minimal stub + swEventTarget = new EventTarget(); + Object.defineProperty(navigator, "serviceWorker", { + configurable: true, + value: swEventTarget, + }); + }); + + const dispatchSWMessage = (data) => { + act(() => { + swEventTarget.dispatchEvent(new MessageEvent("message", { data })); + }); + }; + + test("returns true when navigator.onLine is true", () => { + const { result } = renderHook(() => useIsOnline()); + expect(result.current).toBe(true); + }); + + test("returns false when navigator.onLine is false", () => { + Object.defineProperty(navigator, "onLine", { + configurable: true, + get: () => false, + }); + const { result } = renderHook(() => useIsOnline()); + expect(result.current).toBe(false); + }); + + test("updates to false when the offline window event fires", () => { + const { result } = renderHook(() => useIsOnline()); + expect(result.current).toBe(true); + dispatchWindowEvent("offline"); + expect(result.current).toBe(false); + }); + + test("updates to true when the online window event fires", () => { + Object.defineProperty(navigator, "onLine", { + configurable: true, + get: () => false, + }); + const { result } = renderHook(() => useIsOnline()); + expect(result.current).toBe(false); + dispatchWindowEvent("online"); + expect(result.current).toBe(true); + }); + + test("updates to false when the service worker broadcasts OFFLINE", () => { + const { result } = renderHook(() => useIsOnline()); + expect(result.current).toBe(true); + dispatchSWMessage({ type: "OFFLINE" }); + expect(result.current).toBe(false); + }); + + test("ignores service worker messages with a different type", () => { + const { result } = renderHook(() => useIsOnline()); + dispatchSWMessage({ type: "OTHER" }); + expect(result.current).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/redux/EditorSlice.test.js b/src/redux/EditorSlice.test.js index 5090ce66a..dd2d1c1f1 100644 --- a/src/redux/EditorSlice.test.js +++ b/src/redux/EditorSlice.test.js @@ -11,6 +11,7 @@ import reducer, { setIsOutputOnly, setErrorDetails, setReadOnly, + setOfflineEnabled, addProjectComponent, updateProjectComponent, setCascadeUpdate, @@ -109,6 +110,12 @@ test("Action setReadOnly correctly sets readOnly", () => { expect(reducer(previousState, setReadOnly(true))).toEqual(expectedState); }); +test("Action setOfflineEnabled correctly sets offlineEnabled", () => { + const previousState = { offlineEnabled: false }; + const expectedState = { offlineEnabled: true }; + expect(reducer(previousState, setOfflineEnabled(true))).toEqual(expectedState); +}); + test("Action addProjectComponent adds component to project with correct content", () => { const previousState = { project: { From 75c2865e50a15b5418857b2f884fc1a66f97125f Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Thu, 14 May 2026 19:23:32 +0100 Subject: [PATCH 10/14] chore: Prettier fixes --- src/components/SaveButton/SaveButton.test.js | 3 +-- src/hooks/useIsOnline.test.js | 2 +- src/redux/EditorSlice.test.js | 4 +++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/SaveButton/SaveButton.test.js b/src/components/SaveButton/SaveButton.test.js index b6d8a6b2a..d917b5585 100644 --- a/src/components/SaveButton/SaveButton.test.js +++ b/src/components/SaveButton/SaveButton.test.js @@ -4,11 +4,10 @@ import { Provider } from "react-redux"; import configureStore from "redux-mock-store"; import { triggerSave } from "../../redux/EditorSlice"; import SaveButton from "./SaveButton"; +import useIsOnline from "../../hooks/useIsOnline"; jest.mock("../../hooks/useIsOnline"); -import useIsOnline from "../../hooks/useIsOnline"; - const logInHandler = jest.fn(); describe("When project is loaded", () => { diff --git a/src/hooks/useIsOnline.test.js b/src/hooks/useIsOnline.test.js index 52dacdeac..1bbe7bec8 100644 --- a/src/hooks/useIsOnline.test.js +++ b/src/hooks/useIsOnline.test.js @@ -74,4 +74,4 @@ describe("useIsOnline", () => { dispatchSWMessage({ type: "OTHER" }); expect(result.current).toBe(true); }); -}); \ No newline at end of file +}); diff --git a/src/redux/EditorSlice.test.js b/src/redux/EditorSlice.test.js index dd2d1c1f1..9b9a82146 100644 --- a/src/redux/EditorSlice.test.js +++ b/src/redux/EditorSlice.test.js @@ -113,7 +113,9 @@ test("Action setReadOnly correctly sets readOnly", () => { test("Action setOfflineEnabled correctly sets offlineEnabled", () => { const previousState = { offlineEnabled: false }; const expectedState = { offlineEnabled: true }; - expect(reducer(previousState, setOfflineEnabled(true))).toEqual(expectedState); + expect(reducer(previousState, setOfflineEnabled(true))).toEqual( + expectedState, + ); }); test("Action addProjectComponent adds component to project with correct content", () => { From dfe175b5c88b35dfa8f10c49bd7787d0b2303c2a Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Thu, 14 May 2026 19:23:43 +0100 Subject: [PATCH 11/14] feat: Add cache for translations --- public/service-worker.js | 11 +++++++++-- webpack.config.js | 3 ++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/public/service-worker.js b/public/service-worker.js index 9cc134699..54fa55d34 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,8 +1,9 @@ /* eslint-env serviceworker */ /* eslint-disable no-restricted-globals */ -// "editor-app-v1" is replaced with the package version at build time (see webpack.config.js) +// "editor-app-v1" and "editor-translations-v1" are replaced with the package version at build time (see webpack.config.js) const APP_CACHE = "editor-app-v1"; +const TRANSLATIONS_CACHE = "editor-translations-v1"; const PYODIDE_CACHE = "pyodide-v0.26.2"; // Minimal set of assets to pre-cache on install @@ -37,7 +38,7 @@ self.addEventListener("activate", (event) => { caches.keys().then((keys) => Promise.all( keys - .filter((key) => key !== APP_CACHE && key !== PYODIDE_CACHE) + .filter((key) => key !== APP_CACHE && key !== TRANSLATIONS_CACHE && key !== PYODIDE_CACHE) .map((key) => { console.log("[SW] Deleting old cache:", key); return caches.delete(key); @@ -123,6 +124,12 @@ self.addEventListener("fetch", (event) => { return; } + // Translation files get their own cache so they can be evicted independently of the app shell + if (url.origin === self.location.origin && url.pathname.includes("/translations/")) { + event.respondWith(networkFirst(event.request, TRANSLATIONS_CACHE)); + return; + } + // Same-origin app assets are network-first so users get fresh content online if (url.origin === self.location.origin) { event.respondWith(networkFirst(event.request, APP_CACHE)); diff --git a/webpack.config.js b/webpack.config.js index 7334d84e3..9bf167c4f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -243,7 +243,8 @@ const mainConfig = { const version = process.env.npm_package_version; return content .toString() - .replace("editor-app-v1", `editor-app-v${version}`); + .replace("editor-app-v1", `editor-app-v${version}`) + .replace("editor-translations-v1", `editor-translations-v${version}`); }, }, { from: "src/projects", to: "projects" }, From f75be13b001b8768d22e6a44a0d07271cdf4dec5 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Thu, 14 May 2026 19:34:12 +0100 Subject: [PATCH 12/14] fix: Stylelint --- src/assets/stylesheets/OfflineIndicator.scss | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/assets/stylesheets/OfflineIndicator.scss b/src/assets/stylesheets/OfflineIndicator.scss index 3c8b4e446..97aa1168b 100644 --- a/src/assets/stylesheets/OfflineIndicator.scss +++ b/src/assets/stylesheets/OfflineIndicator.scss @@ -40,13 +40,13 @@ &::before { content: ""; position: absolute; - top: -8px; - right: 20px; - width: 0; - height: 0; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-bottom: 8px solid #1d1d1d; + inset-block-start: -8px; + inset-inline-end: 20px; + inline-size: 0; + block-size: 0; + border-inline-start: 8px solid transparent; + border-inline-end: 8px solid transparent; + border-block-end: 8px solid #1d1d1d; } p { From 0df148a0d68fc45f46437ccf245bdcd77d54d798 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Thu, 14 May 2026 19:37:21 +0100 Subject: [PATCH 13/14] fix: Pre-cache default translations on service worker install --- public/service-worker.js | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/public/service-worker.js b/public/service-worker.js index 54fa55d34..076e6a545 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -17,18 +17,32 @@ const APP_SHELL = [ self.addEventListener("install", (event) => { event.waitUntil( - caches - .open(APP_CACHE) - .then((cache) => - cache - .addAll(APP_SHELL) - .catch((err) => - console.warn( - "[SW] Pre-cache failed, will rely on dynamic caching:", - err, + Promise.all([ + caches + .open(APP_CACHE) + .then((cache) => + cache + .addAll(APP_SHELL) + .catch((err) => + console.warn( + "[SW] Pre-cache failed, will rely on dynamic caching:", + err, + ), ), - ), - ), + ), + caches + .open(TRANSLATIONS_CACHE) + .then((cache) => + cache + .addAll(["./translations/en.json"]) + .catch((err) => + console.warn( + "[SW] Translation pre-cache failed, will rely on dynamic caching:", + err, + ), + ), + ), + ]), ); self.skipWaiting(); }); From f0416310228fa1d3773017e3354f1da6359a279e Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Thu, 14 May 2026 20:12:51 +0100 Subject: [PATCH 14/14] fix: Correctly handle transition from offline back to online Previously we were relying on a fetch() succeeding to tell us whether we were back online, but there's no guarantee of a fetch() being attempted (so we stayed "offline" longer than we needed to) --- public/service-worker.js | 37 +++++++++++++--- src/hooks/useIsOnline.js | 14 +++++- src/hooks/useIsOnline.test.js | 83 +++++++++++++++++++++++++++++++---- 3 files changed, 119 insertions(+), 15 deletions(-) diff --git a/public/service-worker.js b/public/service-worker.js index 076e6a545..23a248544 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -77,25 +77,49 @@ function addSecurityHeaders(response) { }); } -function broadcastOffline() { +// Tracks whether any network-first request has fallen back to cache, so that we can broadcast ONLINE when the network becomes reachable again +let servingFromCache = false; + +self.addEventListener("online", () => { + servingFromCache = false; + broadcast("ONLINE"); +}); + +// Send CHECK_ONLINE when offline. We probe the network directly here because SW-initiated fetches bypass the SW's own fetch handler, hitting the network without being served from cache +self.addEventListener("message", (event) => { + if (event.data?.type !== "CHECK_ONLINE") return; + fetch("./manifest.json", { cache: "no-store" }) + .then(() => { + servingFromCache = false; + broadcast("ONLINE"); + }) + .catch(() => {}); +}); + +function broadcast(type) { self.clients .matchAll() - .then((clients) => clients.forEach((c) => c.postMessage({ type: "OFFLINE" }))); + .then((clients) => clients.forEach((c) => c.postMessage({ type }))); } -// Network-first: try the network, update the cache, fall back to cache +// Network-first try the network, update the cache, fall back to cache async function networkFirst(request, cacheName) { const cache = await caches.open(cacheName); try { const networkResponse = await fetch(request); if (networkResponse.ok) { cache.put(request, addSecurityHeaders(networkResponse.clone())); + if (servingFromCache) { + servingFromCache = false; + broadcast("ONLINE"); + } } return addSecurityHeaders(networkResponse); } catch { const cached = await cache.match(request); if (cached) { - broadcastOffline(); + servingFromCache = true; + broadcast("OFFLINE"); return addSecurityHeaders(cached); } return Response.error(); @@ -139,7 +163,10 @@ self.addEventListener("fetch", (event) => { } // Translation files get their own cache so they can be evicted independently of the app shell - if (url.origin === self.location.origin && url.pathname.includes("/translations/")) { + if ( + url.origin === self.location.origin && + url.pathname.includes("/translations/") + ) { event.respondWith(networkFirst(event.request, TRANSLATIONS_CACHE)); return; } diff --git a/src/hooks/useIsOnline.js b/src/hooks/useIsOnline.js index e1c480267..7b03bb9a4 100644 --- a/src/hooks/useIsOnline.js +++ b/src/hooks/useIsOnline.js @@ -9,10 +9,10 @@ const useIsOnline = () => { window.addEventListener("online", handleOnline); window.addEventListener("offline", handleOffline); - // The service worker broadcasts OFFLINE whenever a network-first fetch falls back to cache, which reliably catches the case where navigator.onLine hasn't settled yet after a page reload when offline - // This ensures that we can show "offline" state / UI immediately on page load when offline + // The service worker broadcasts OFFLINE when a network-first fetch falls back to cache (catching cases where navigator.onLine hasn't settled on page reload), and ONLINE when the network becomes reachable again after a cache fallback period const handleSWMessage = ({ data }) => { if (data?.type === "OFFLINE") setIsOnline(false); + if (data?.type === "ONLINE") setIsOnline(true); }; if ("serviceWorker" in navigator) { navigator.serviceWorker.addEventListener("message", handleSWMessage); @@ -27,6 +27,16 @@ const useIsOnline = () => { }; }, []); + // While offline, poll the SW every 3s to check if the network has returned, broadcast ONLINE if successful + useEffect(() => { + if (isOnline || !("serviceWorker" in navigator)) return; + const interval = setInterval(async () => { + const reg = await navigator.serviceWorker.ready; + reg.active?.postMessage({ type: "CHECK_ONLINE" }); + }, 3000); + return () => clearInterval(interval); + }, [isOnline]); + return isOnline; }; diff --git a/src/hooks/useIsOnline.test.js b/src/hooks/useIsOnline.test.js index 1bbe7bec8..5418a77a3 100644 --- a/src/hooks/useIsOnline.test.js +++ b/src/hooks/useIsOnline.test.js @@ -9,6 +9,7 @@ const dispatchWindowEvent = (type) => { describe("useIsOnline", () => { let swEventTarget; + let mockPostMessage; beforeEach(() => { Object.defineProperty(navigator, "onLine", { @@ -16,8 +17,13 @@ describe("useIsOnline", () => { get: () => true, }); - // jsdom doesn't implement navigator.serviceWorker so provide a minimal stub + mockPostMessage = jest.fn(); swEventTarget = new EventTarget(); + swEventTarget.ready = Promise.resolve({ + active: { postMessage: mockPostMessage }, + }); + + // jsdom doesn't implement navigator.serviceWorker so provide a minimal stub Object.defineProperty(navigator, "serviceWorker", { configurable: true, value: swEventTarget, @@ -46,32 +52,93 @@ describe("useIsOnline", () => { test("updates to false when the offline window event fires", () => { const { result } = renderHook(() => useIsOnline()); - expect(result.current).toBe(true); dispatchWindowEvent("offline"); expect(result.current).toBe(false); }); test("updates to true when the online window event fires", () => { - Object.defineProperty(navigator, "onLine", { - configurable: true, - get: () => false, - }); const { result } = renderHook(() => useIsOnline()); - expect(result.current).toBe(false); + dispatchWindowEvent("offline"); dispatchWindowEvent("online"); expect(result.current).toBe(true); }); test("updates to false when the service worker broadcasts OFFLINE", () => { const { result } = renderHook(() => useIsOnline()); - expect(result.current).toBe(true); dispatchSWMessage({ type: "OFFLINE" }); expect(result.current).toBe(false); }); + test("updates to true when the service worker broadcasts ONLINE", () => { + const { result } = renderHook(() => useIsOnline()); + dispatchSWMessage({ type: "OFFLINE" }); + dispatchSWMessage({ type: "ONLINE" }); + expect(result.current).toBe(true); + }); + test("ignores service worker messages with a different type", () => { const { result } = renderHook(() => useIsOnline()); dispatchSWMessage({ type: "OTHER" }); expect(result.current).toBe(true); }); + + describe("connectivity polling", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test("does not poll while online", async () => { + renderHook(() => useIsOnline()); + await act(async () => { + jest.advanceTimersByTime(6000); + }); + expect(mockPostMessage).not.toHaveBeenCalled(); + }); + + test("sends CHECK_ONLINE to the service worker every 3 seconds while offline", async () => { + const { result } = renderHook(() => useIsOnline()); + dispatchSWMessage({ type: "OFFLINE" }); + expect(result.current).toBe(false); + + await act(async () => { + jest.advanceTimersByTime(3000); + }); + expect(mockPostMessage).toHaveBeenCalledTimes(1); + expect(mockPostMessage).toHaveBeenCalledWith({ type: "CHECK_ONLINE" }); + + await act(async () => { + jest.advanceTimersByTime(3000); + }); + expect(mockPostMessage).toHaveBeenCalledTimes(2); + }); + + test("stops polling when the service worker broadcasts ONLINE", async () => { + const { result } = renderHook(() => useIsOnline()); + dispatchSWMessage({ type: "OFFLINE" }); + dispatchSWMessage({ type: "ONLINE" }); + expect(result.current).toBe(true); + + await act(async () => { + jest.advanceTimersByTime(6000); + }); + expect(mockPostMessage).not.toHaveBeenCalled(); + }); + + test("stops polling on unmount", async () => { + const { result, unmount } = renderHook(() => useIsOnline()); + dispatchSWMessage({ type: "OFFLINE" }); + expect(result.current).toBe(false); + + unmount(); + + await act(async () => { + jest.advanceTimersByTime(6000); + }); + expect(mockPostMessage).not.toHaveBeenCalled(); + }); + }); });