Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 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
5673dd4
feat(offline): remove service worker (rely on host app), migrate to a…
grega May 27, 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
25 changes: 25 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 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`)
Expand Down Expand Up @@ -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
<editor-wc offline_enabled="true"></editor-wc>
```

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
Expand Down
207 changes: 207 additions & 0 deletions docs/OfflineServiceWorker.md
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
grega marked this conversation as resolved.
}),
),
),
);
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;
}

Comment thread
grega marked this conversation as resolved.
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) {
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