-
Notifications
You must be signed in to change notification settings - Fork 12
Feat: Offline support #1470
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
grega
wants to merge
20
commits into
main
Choose a base branch
from
offline
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Feat: Offline support #1470
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 991febe
feat(offline): Toggle for SW, also pass package version to SW to use …
grega 02c9a88
feat(offline): isOnline effect for handling on/offline UI changes
grega 82b15d0
feat(offline): Offline UI
grega 14109e2
fix(offline): Improve comments re. offline state broadcast
grega 84d2c35
feat(offline): Offline indicator styling w/ tooltip
grega db2c70a
feat(offline): Allow service worker / offline mode to be toggled via …
grega 13c0f45
chore(offline): Document offline support, incl. offline_enabled attr …
grega d271714
chore(offline): Add tests for offline mode
grega 4c44e98
chore(offline): Prettier fixes
grega d53bf39
feat(offline): Add cache for translations
grega 22b92c6
chore(offline): Stylelint fixes
grega cee1081
fix(offline): Pre-cache default translations on service worker install
grega f8fab9b
fix(offline): Correctly handle transition from offline back to online
grega bd3d496
fix(offline): Improve failure mode around package version injection
grega 65e139a
fix(offline): Use serviceWorker.controller directly for CHECK_ONLINE …
grega 46f9a6d
feat(offline): A11y improvements to offline indicator and tooltip
grega 411e35f
fix(offline): Ensure offline badge shown if user is logged in
grega 8448962
feat(offline): Ensure offline UI displays for logged-in users
grega d44771a
fix(offline): Offline UI tooltip size and positioning
grega File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| .map((key) => { | ||
| console.log("[SW] Deleting old cache:", key); | ||
| return caches.delete(key); | ||
|
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; | ||
| } | ||
|
|
||
|
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) { | ||
|
grega marked this conversation as resolved.
|
||
| event.respondWith(networkFirst(event.request, APP_CACHE)); | ||
| } | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
11
src/components/Mobile/MobileProjectBar/MobileProjectBar.jsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.