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/public/service-worker.js b/public/service-worker.js
new file mode 100644
index 000000000..23a248544
--- /dev/null
+++ b/public/service-worker.js
@@ -0,0 +1,178 @@
+/* eslint-env serviceworker */
+/* eslint-disable no-restricted-globals */
+
+// "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
+// 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 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,
+ });
+}
+
+// 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 })));
+}
+
+// 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 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;
+ }
+
+ // 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..97aa1168b
--- /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;
+ 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 {
+ 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 3b47ecd5c..2ee1142ad 100644
--- a/src/components/SaveButton/SaveButton.jsx
+++ b/src/components/SaveButton/SaveButton.jsx
@@ -8,7 +8,9 @@ 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";
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,35 @@ const SaveButton = ({ className, type, fill = false }) => {
const projectOwner = isOwner(user, project);
+ if (loading !== "success" || projectOwner || !buttonType) return null;
+
+ if (offlineEnabled && !isOnline && !user) {
+ return (
+
+
+
{t("header.offline")}
+
+
{t("header.offlineTooltipDevice")}
+
{t("header.offlineTooltipContinue")}
+
+
+ );
+ }
+
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..d917b5585 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,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/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..7b03bb9a4
--- /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 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);
+ }
+
+ return () => {
+ window.removeEventListener("online", handleOnline);
+ window.removeEventListener("offline", handleOffline);
+ if ("serviceWorker" in navigator) {
+ navigator.serviceWorker.removeEventListener("message", handleSWMessage);
+ }
+ };
+ }, []);
+
+ // 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;
+};
+
+export default useIsOnline;
diff --git a/src/hooks/useIsOnline.test.js b/src/hooks/useIsOnline.test.js
new file mode 100644
index 000000000..5418a77a3
--- /dev/null
+++ b/src/hooks/useIsOnline.test.js
@@ -0,0 +1,144 @@
+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.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,
+ });
+ });
+
+ 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("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();
+ });
+ });
+});
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/redux/EditorSlice.test.js b/src/redux/EditorSlice.test.js
index 5090ce66a..9b9a82146 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,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..c4bef9185 100644
--- a/src/web-component.html
+++ b/src/web-component.html
@@ -52,6 +52,18 @@
Editor Web component
+ <% if (htmlWebpackPlugin.options.enableSW) { %>
+
+ <% } %>
@@ -97,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([
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",
diff --git a/webpack.config.js b/webpack.config.js
index 2a95ff47b..9bf167c4f 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,18 @@ 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}`)
+ .replace("editor-translations-v1", `editor-translations-v${version}`);
+ },
+ },
{ from: "src/projects", to: "projects" },
],
}),