diff --git a/README.md b/README.md index 5d20b0ed8..c25628f0d 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 host page's service worker to broadcast `{ type: "OFFLINE" }` / `{ type: "ONLINE" }` messages - 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,30 @@ 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 displays an offline indicator when the host page's service worker broadcasts connectivity changes. The web component itself does not register a service worker - caching and offline detection are the host app's responsibility. + +To enable the offline indicator: + +1. Have your host page's service worker broadcast `{ type: "OFFLINE" }` when a network request falls back to cache, and `{ type: "ONLINE" }` when the network recovers: + ```js + // In your service worker + self.clients.matchAll().then(clients => + clients.forEach(c => c.postMessage({ type: "OFFLINE" })) + ); + ``` +2. Pass the `offline_enabled` attribute to the web component: + ```html + + ``` + +Offline mode is opt-in - the offline badge will not appear unless both steps are taken. + +#### Developing with offline support + +Set `offline_enabled="true"` on the web component (already the default in the dev HTML), then use the **Network** tab in browser DevTools to toggle offline mode. The browser's `offline` event fires immediately and the offline indicator will appear. + ## Development ### Previewing diff --git a/docs/OfflineServiceWorker.md b/docs/OfflineServiceWorker.md new file mode 100644 index 000000000..1eb38670c --- /dev/null +++ b/docs/OfflineServiceWorker.md @@ -0,0 +1,207 @@ +# Offline service worker example + +The editor-ui web component supports an offline indicator via the `offline_enabled` attribute. The offline UI is driven by `OFFLINE` / `ONLINE` messages broadcast from the host page's (ie. editor-standalone) service worker to all controlled clients - see the [Offline support section of the README](../README.md#offline-support) for the full integration contract. + +This document describes a service worker that could be bundled with editor-ui itself (rather than relying on the host app / editor-standalone). It is a working reference for any host wanting to implement the required messaging contract from scratch. + +## What the service worker must do + +For the editor-ui offline indicator to work, the controlling service worker must: + +1. Broadcast `{ type: "OFFLINE" }` to all clients when a network-first fetch falls back to cache (ie. the network is unreachable) +2. Broadcast `{ type: "ONLINE" }` to all clients when a network-first fetch succeeds after a period of serving from cache (i.e. the network has recovered) +3. Handle a `{ type: "CHECK_ONLINE" }` message from the client by probing the network and broadcasting `ONLINE` if the probe succeeds. This is used by `useIsOnline` to poll for recovery while offline, since no fetch may happen naturally + +## Example service worker + +The implementation below could be shipped as `public/service-worker.js` in editor-ui. It caches the editor shell and Pyodide assets and implements the messaging contract above. + +> **Note:** Cache version strings (`editor-app-v1`, `editor-translations-v1`) would usually be replaced, by the host app, with the package version at build time via a webpack `CopyWebpackPlugin` transform. If you adopt this SW you will need to wire up equivalent versioning, or replace them with a fixed string and manage cache invalidation another way. + +```js +/* eslint-env serviceworker */ +/* eslint-disable no-restricted-globals */ + +// "editor-app-v1" and "editor-translations-v1" are replaced with the package version at build time +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 +// 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( + 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(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter( + (key) => + key !== APP_CACHE && + key !== TRANSLATIONS_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 cross-origin isolation (COOP + COEP on the HTML response, CORP on every cross-origin resource) +// We re-apply those headers when caching responses so they are preserved when served from cache offline. +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, + }); +} + +// Tracks whether any network-first request has fallen back to cache, so we know to broadcast ONLINE when the network becomes reachable again. +let servingFromCache = false; + +self.addEventListener("online", () => { + servingFromCache = false; + broadcast("ONLINE"); +}); + +// Handle CHECK_ONLINE from the client (sent by useIsOnline while offline). +// SW-initiated fetches bypass the SW's own fetch handler, so this hits the network directly 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 }))); +} + +// 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) { + servingFromCache = true; + broadcast("OFFLINE"); + return addSecurityHeaders(cached); + } + return Response.error(); + } +} + +// Cache-first: serve from cache when available, populate on first fetch. +// importScripts produces opaque responses we can't modify, so we re-fetch as cors to get a modifiable response we can store with security headers +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 their URLs are version-pinned + if ( + url.hostname === "cdn.jsdelivr.net" && + url.pathname.includes("/pyodide/") + ) { + event.respondWith(cacheFirst(event.request, PYODIDE_CACHE)); + 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/public/translations/en.json b/public/translations/en.json index 584618c9f..7b2b2c7cf 100644 --- a/public/translations/en.json +++ b/public/translations/en.json @@ -83,6 +83,9 @@ "renameSave": "Save project name", "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..f257799bf --- /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: 12rem; + 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; + inset-block-start: -8px; + inset-inline-end: 40px; + 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 { + 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/Mobile/MobileProjectBar/MobileProjectBar.jsx b/src/components/Mobile/MobileProjectBar/MobileProjectBar.jsx index 4a4080dde..0f55b771a 100644 --- a/src/components/Mobile/MobileProjectBar/MobileProjectBar.jsx +++ b/src/components/Mobile/MobileProjectBar/MobileProjectBar.jsx @@ -1,17 +1,26 @@ import "../../../assets/stylesheets/MobileProjectBar.scss"; import SaveStatus from "../../SaveStatus/SaveStatus"; +import OfflineBadge from "../../OfflineBadge/OfflineBadge"; import { useSelector } from "react-redux"; +import useIsOnline from "../../../hooks/useIsOnline"; import React from "react"; const MobileProjectBar = () => { const projectName = useSelector((state) => state.editor.project.name); const lastSavedTime = useSelector((state) => state.editor.lastSavedTime); + const offlineEnabled = useSelector((state) => state.editor.offlineEnabled); const readOnly = useSelector((state) => state.editor.readOnly); + const isOnline = useIsOnline(); return (

{projectName}

- {lastSavedTime && !readOnly ? : null} + {!readOnly && + (offlineEnabled && !isOnline ? ( + + ) : ( + lastSavedTime && + ))}
); }; diff --git a/src/components/OfflineBadge/OfflineBadge.jsx b/src/components/OfflineBadge/OfflineBadge.jsx new file mode 100644 index 000000000..c434e2ce3 --- /dev/null +++ b/src/components/OfflineBadge/OfflineBadge.jsx @@ -0,0 +1,29 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import classNames from "classnames"; +import OfflineIcon from "../../assets/icons/offline.svg"; + +const OfflineBadge = ({ className }) => { + const { t } = useTranslation(); + + return ( +
+ + {t("header.offline")} + +
+ ); +}; + +export default OfflineBadge; diff --git a/src/components/ProjectBar/ProjectBar.jsx b/src/components/ProjectBar/ProjectBar.jsx index 9ba0ddff8..1b790663e 100644 --- a/src/components/ProjectBar/ProjectBar.jsx +++ b/src/components/ProjectBar/ProjectBar.jsx @@ -2,10 +2,12 @@ import React from "react"; import { useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; import SaveStatus from "../SaveStatus/SaveStatus"; +import OfflineBadge from "../OfflineBadge/OfflineBadge"; import DownloadIcon from "../../assets/icons/download.svg"; import ProjectName from "../ProjectName/ProjectName"; import DownloadButton from "../DownloadButton/DownloadButton"; import SaveButton from "../SaveButton/SaveButton"; +import useIsOnline from "../../hooks/useIsOnline"; import "../../assets/stylesheets/ProjectBar.scss"; import { isOwner } from "../../utils/projectHelpers"; @@ -15,10 +17,11 @@ const ProjectBar = ({ nameEditable = true }) => { const project = useSelector((state) => state.editor.project); const user = useSelector((state) => state.auth.user); const loading = useSelector((state) => state.editor.loading); - const saving = useSelector((state) => state.editor.saving); const lastSavedTime = useSelector((state) => state.editor.lastSavedTime); + const offlineEnabled = useSelector((state) => state.editor.offlineEnabled); const projectOwner = isOwner(user, project); const readOnly = useSelector((state) => state.editor.readOnly); + const isOnline = useIsOnline(); if (loading !== "success") { return null; @@ -41,9 +44,15 @@ const ProjectBar = ({ nameEditable = true }) => { )} - {lastSavedTime && user && !readOnly && ( - - )} + {user && + !readOnly && + (offlineEnabled && !isOnline + ? projectOwner && ( +
+ +
+ ) + : lastSavedTime && )} ); diff --git a/src/components/ProjectBar/ProjectBar.test.js b/src/components/ProjectBar/ProjectBar.test.js index 9a664c14b..46c7f0cfc 100644 --- a/src/components/ProjectBar/ProjectBar.test.js +++ b/src/components/ProjectBar/ProjectBar.test.js @@ -4,8 +4,10 @@ import { Provider } from "react-redux"; import configureStore from "redux-mock-store"; import { MemoryRouter } from "react-router-dom"; import ProjectBar from "./ProjectBar"; +import useIsOnline from "../../hooks/useIsOnline"; jest.mock("axios"); +jest.mock("../../hooks/useIsOnline"); jest.mock("react-router-dom", () => ({ ...jest.requireActual("react-router-dom"), @@ -27,6 +29,10 @@ const user = { }, }; +beforeEach(() => { + useIsOnline.mockReturnValue(true); +}); + const renderProjectBar = (state) => { const middlewares = []; const mockStore = configureStore(middlewares); @@ -184,6 +190,45 @@ describe("When no project loaded", () => { }); }); +describe("offline badge", () => { + beforeEach(() => { + useIsOnline.mockReturnValue(false); + }); + + test("shows offline badge for project owner when offline", () => { + renderProjectBar({ + editor: { project, offlineEnabled: true, lastSavedTime: Date.now() }, + auth: { user }, + }); + expect(screen.queryByText("header.offline")).toBeInTheDocument(); + }); + + test("shows offline badge for project owner on fresh load with no save history", () => { + renderProjectBar({ + editor: { project, offlineEnabled: true }, + auth: { user }, + }); + expect(screen.queryByText("header.offline")).toBeInTheDocument(); + }); + + test("does not show a second offline badge for non-owners (SaveButton handles it)", () => { + const nonOwner = { ...user, profile: { user: "someone-else" } }; + renderProjectBar({ + editor: { project, offlineEnabled: true, lastSavedTime: Date.now() }, + auth: { user: nonOwner }, + }); + expect(screen.queryAllByText("header.offline")).toHaveLength(1); + }); + + test("does not show offline badge when offlineEnabled is false", () => { + renderProjectBar({ + editor: { project, offlineEnabled: false }, + auth: { user }, + }); + expect(screen.queryByText("header.offline")).not.toBeInTheDocument(); + }); +}); + describe("When read only", () => { beforeEach(() => { renderProjectBar({ diff --git a/src/components/SaveButton/SaveButton.jsx b/src/components/SaveButton/SaveButton.jsx index 3b47ecd5c..26608f9b4 100644 --- a/src/components/SaveButton/SaveButton.jsx +++ b/src/components/SaveButton/SaveButton.jsx @@ -7,8 +7,10 @@ import { logInEvent } from "../../events/WebComponentCustomEvents"; import { isOwner } from "../../utils/projectHelpers"; import DesignSystemButton from "../DesignSystemButton/DesignSystemButton"; +import OfflineBadge from "../OfflineBadge/OfflineBadge"; 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 +21,8 @@ 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(() => { if (!type) { @@ -36,24 +40,26 @@ const SaveButton = ({ className, type, fill = false }) => { const projectOwner = isOwner(user, project); + if (loading !== "success" || projectOwner || !buttonType) return null; + + if (offlineEnabled && !isOnline) { + return ; + } + return ( - loading === "success" && - !projectOwner && - buttonType && ( - } - type={buttonType} - fill={fill} - /> - ) + } + type={buttonType} + fill={fill} + /> ); }; diff --git a/src/components/SaveButton/SaveButton.test.js b/src/components/SaveButton/SaveButton.test.js index 146720e0d..0cc55493a 100644 --- a/src/components/SaveButton/SaveButton.test.js +++ b/src/components/SaveButton/SaveButton.test.js @@ -4,6 +4,9 @@ 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"); const logInHandler = jest.fn(); @@ -12,6 +15,10 @@ describe("When project is loaded", () => { document.addEventListener("editor-logIn", logInHandler); }); + beforeEach(() => { + useIsOnline.mockReturnValue(true); + }); + describe("With logged in user", () => { let store; @@ -204,6 +211,95 @@ 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("shows offline badge when user is logged in and offline", () => { + const store = configureStore([])({ + ...offlineState, + editor: { + ...offlineState.editor, + project: { identifier: "some-project", user_id: "some-other-user" }, + }, + auth: { user: { profile: { user: "some-user" } } }, + }); + render( + + + , + ); + expect(screen.queryByText("header.offline")).toBeInTheDocument(); + }); + + describe("accessibility", () => { + beforeEach(() => { + const store = configureStore([])(offlineState); + render( + + + , + ); + }); + + test("badge is keyboard focusable", () => { + const badge = screen.getByText("header.offline").parentElement; + expect(badge).toHaveAttribute("tabIndex", "0"); + }); + + test("badge tooltip is associated via aria-describedby", () => { + const tooltip = screen.getByRole("tooltip"); + const badge = screen.getByText("header.offline").parentElement; + expect(badge).toHaveAttribute("aria-describedby", tooltip.id); + }); + }); + }); + afterAll(() => { document.removeEventListener("editor-logIn", logInHandler); }); diff --git a/src/components/SaveStatus/SaveStatus.test.js b/src/components/SaveStatus/SaveStatus.test.js index e359c0a7e..604efd804 100644 --- a/src/components/SaveStatus/SaveStatus.test.js +++ b/src/components/SaveStatus/SaveStatus.test.js @@ -11,29 +11,24 @@ const project = { }; let store; -let saveStatus; describe("With a save button", () => { beforeEach(() => { - const middlewares = []; - const mockStore = configureStore(middlewares); - const initialState = { + store = configureStore([])({ editor: { project: project, loading: "success", lastSavedTime: Date.now(), }, - }; - store = mockStore(initialState); + }); render( , ); - saveStatus = screen.queryByText("saveStatus.saved now"); }); - test("Renders save button", () => { - expect(saveStatus).toBeInTheDocument(); + test("Renders save status", () => { + expect(screen.queryByText("saveStatus.saved now")).toBeInTheDocument(); }); }); 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/hooks/useIsOnline.js b/src/hooks/useIsOnline.js new file mode 100644 index 000000000..63409a3d7 --- /dev/null +++ b/src/hooks/useIsOnline.js @@ -0,0 +1,43 @@ +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 host page's service worker broadcasts OFFLINE when a network-first fetch falls back to cache, 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); + } + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + if ("serviceWorker" in navigator) { + navigator.serviceWorker.removeEventListener("message", handleSWMessage); + } + }; + }, []); + + // While offline, ask the controlling service worker to probe the network. + // navigator.onLine alone is unreliable. The SW performs a real fetch and responds with ONLINE/OFFLINE based on whether it succeeds + useEffect(() => { + if (isOnline || !("serviceWorker" in navigator)) return; + const interval = setInterval(() => { + navigator.serviceWorker.controller?.postMessage({ type: "CHECK_ONLINE" }); + }, 3000); + return () => clearInterval(interval); + }, [isOnline]); + + return isOnline; +}; + +export default useIsOnline; diff --git a/src/hooks/useIsOnline.test.js b/src/hooks/useIsOnline.test.js new file mode 100644 index 000000000..d8406b664 --- /dev/null +++ b/src/hooks/useIsOnline.test.js @@ -0,0 +1,142 @@ +import { act, renderHook } from "@testing-library/react"; +import useIsOnline from "./useIsOnline"; + +const dispatchWindowEvent = (type) => { + act(() => { + window.dispatchEvent(new Event(type)); + }); +}; + +describe("useIsOnline", () => { + let swEventTarget; + let mockPostMessage; + + beforeEach(() => { + Object.defineProperty(navigator, "onLine", { + configurable: true, + get: () => true, + }); + + mockPostMessage = jest.fn(); + swEventTarget = new EventTarget(); + swEventTarget.controller = { postMessage: mockPostMessage }; + + // jsdom doesn't implement navigator.serviceWorker so provide a minimal stub + 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()); + dispatchWindowEvent("offline"); + expect(result.current).toBe(false); + }); + + test("updates to true when the online window event fires", () => { + const { result } = renderHook(() => useIsOnline()); + dispatchWindowEvent("offline"); + dispatchWindowEvent("online"); + expect(result.current).toBe(true); + }); + + test("updates to false when the service worker broadcasts OFFLINE", () => { + const { result } = renderHook(() => useIsOnline()); + 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("CHECK_ONLINE 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(); + }); + }); +}); diff --git a/src/redux/EditorSlice.js b/src/redux/EditorSlice.js index ec6cbb9f5..4e7a0a003 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, @@ -282,6 +283,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; }, @@ -515,6 +519,7 @@ export const { setReadOnly, setInstructionsEditable, setSenseHatAlwaysEnabled, + setOfflineEnabled, setSenseHatEnabled, setLoadRemixDisabled, setReactAppApiEndpoint, diff --git a/src/redux/EditorSlice.test.js b/src/redux/EditorSlice.test.js index 721b3909c..1cee95fa6 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, @@ -113,6 +114,14 @@ 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: { diff --git a/src/web-component.html b/src/web-component.html index 0611083eb..93456e704 100644 --- a/src/web-component.html +++ b/src/web-component.html @@ -97,6 +97,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([ 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",