Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
dca5683
feat(offline): Base service worker for handling fetch() cycle (cache …
grega May 14, 2026
991febe
feat(offline): Toggle for SW, also pass package version to SW to use …
grega May 14, 2026
02c9a88
feat(offline): isOnline effect for handling on/offline UI changes
grega May 14, 2026
82b15d0
feat(offline): Offline UI
grega May 14, 2026
14109e2
fix(offline): Improve comments re. offline state broadcast
grega May 14, 2026
84d2c35
feat(offline): Offline indicator styling w/ tooltip
grega May 14, 2026
db2c70a
feat(offline): Allow service worker / offline mode to be toggled via …
grega May 14, 2026
13c0f45
chore(offline): Document offline support, incl. offline_enabled attr …
grega May 14, 2026
d271714
chore(offline): Add tests for offline mode
grega May 14, 2026
4c44e98
chore(offline): Prettier fixes
grega May 14, 2026
d53bf39
feat(offline): Add cache for translations
grega May 14, 2026
22b92c6
chore(offline): Stylelint fixes
grega May 14, 2026
cee1081
fix(offline): Pre-cache default translations on service worker install
grega May 14, 2026
f8fab9b
fix(offline): Correctly handle transition from offline back to online
grega May 14, 2026
bd3d496
fix(offline): Improve failure mode around package version injection
grega May 21, 2026
65e139a
fix(offline): Use serviceWorker.controller directly for CHECK_ONLINE …
grega May 21, 2026
46f9a6d
feat(offline): A11y improvements to offline indicator and tooltip
grega May 21, 2026
411e35f
fix(offline): Ensure offline badge shown if user is logged in
grega May 26, 2026
8448962
feat(offline): Ensure offline UI displays for logged-in users
grega May 26, 2026
d44771a
fix(offline): Offline UI tooltip size and positioning
grega May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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
<editor-wc offline_enabled="true"></editor-wc>
```

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
Expand Down
178 changes: 178 additions & 0 deletions public/service-worker.js
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
grega marked this conversation as resolved.
.map((key) => {
console.log("[SW] Deleting old cache:", key);
return caches.delete(key);
Comment thread
grega marked this conversation as resolved.
}),
),
),
);
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. We already set these headers on editor-static.raspberrypi.org, but worth being explicit here for other hosts eg. cdn.jsdelivr.net
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;
}

Comment thread
grega marked this conversation as resolved.
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) {
Comment thread
grega marked this conversation as resolved.
event.respondWith(networkFirst(event.request, APP_CACHE));
}
});
3 changes: 3 additions & 0 deletions public/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions src/assets/icons/offline.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/stylesheets/InternalStyles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 *;
Expand Down
67 changes: 67 additions & 0 deletions src/assets/stylesheets/OfflineIndicator.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
11 changes: 10 additions & 1 deletion src/components/Mobile/MobileProjectBar/MobileProjectBar.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mobile-project-bar">
<p className="mobile-project-bar__name">{projectName}</p>
{lastSavedTime && !readOnly ? <SaveStatus isMobile={true} /> : null}
{!readOnly &&
(offlineEnabled && !isOnline ? (
<OfflineBadge />
) : (
lastSavedTime && <SaveStatus isMobile={true} />
))}
</div>
);
};
Expand Down
29 changes: 29 additions & 0 deletions src/components/OfflineBadge/OfflineBadge.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={classNames(className, "offline-badge")}
tabIndex={0}
aria-describedby="offline-badge-tooltip"
>
<OfflineIcon />
<span>{t("header.offline")}</span>
<div
id="offline-badge-tooltip"
className="offline-badge__tooltip"
role="tooltip"
>
<p>{t("header.offlineTooltipDevice")}</p>
<p>{t("header.offlineTooltipContinue")}</p>
</div>
</div>
);
};

export default OfflineBadge;
Loading
Loading