feat: implement service worker for PWA - #1873
Conversation
paustint
commented
Jul 21, 2026
- Added a service worker template (sw.template.js) to handle precaching of assets.
- Registered the service worker in the app initializer based on a feature flag.
- Implemented a WebUpdateNotification component to inform users of available updates.
- Enhanced the app state management to track the available version from the server.
- Updated the HeaderNavbar to include the update notification in the header.
- Introduced a preload error recovery mechanism to handle dynamic import failures gracefully.
- Removed the old manifest.json file and replaced it with a web manifest link in index.html.
- Added tests for the service worker template to ensure correct behavior during installation and activation.
- Updated Vite configuration to include the service worker plugin for generating the service worker.
There was a problem hiding this comment.
Pull request overview
This PR adds Progressive Web App (PWA) support to the Jetstream web app by introducing a build-generated precache service worker, wiring up registration behind a feature flag, and surfacing “update available” UI driven by server heartbeat version checks. It also adds a server-side kill switch for emergency service worker rollback and a global recovery hook for Vite dynamic-import preload failures.
Changes:
- Generate and serve a
/app-scoped precache service worker (sw.js) and add client registration/unregistration behind a newpwa-service-workerfeature flag. - Add “Update available” state + header UI, driven by heartbeat version mismatch and optionally triggering a service worker update + reload.
- Add a global
vite:preloadErrorhandler to reload once to recover from stale-deploy dynamic import failures.
Reviewed changes
Copilot reviewed 20 out of 22 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| libs/types/src/lib/feature-flags.ts | Adds pwa-service-worker feature flag to gate SW registration. |
| libs/shared/ui-core/src/app/HeaderNavbar.tsx | Allows apps to inject an update notification node into header actions. |
| libs/shared/ui-app-state/src/lib/ui-app-state.ts | Adds atom to track server version newer than the client. |
| libs/shared/constants/src/lib/shared-constants.ts | Introduces SW_PRECACHE_PREFIX used for cache naming/cleanup/kill switch. |
| libs/api-config/src/lib/env-config.ts | Adds SW_KILL_SWITCH env var to control SW self-destruct mode. |
| apps/jetstream/vite.plugins.ts | Adds serviceWorkerPlugin to emit sw.js at build time from a template. |
| apps/jetstream/vite.config.ts | Registers the service worker plugin in Vite build pipeline. |
| apps/jetstream/src/sw.template.js | New SW template implementing hashed-asset precaching + retention + passthrough. |
| apps/jetstream/src/main.tsx | Ensures preload error recovery is registered before app code loads. |
| apps/jetstream/src/assets/images/manifest.json | Removes old manifest file from the web app assets. |
| apps/jetstream/src/app/components/core/WebUpdateNotification.tsx | New header popover prompting the user to refresh when an update is available. |
| apps/jetstream/src/app/components/core/service-worker-registration.ts | New SW register/unregister/update helpers and reload handshake. |
| apps/jetstream/src/app/components/core/preload-error-recovery.ts | New global handler for Vite preload errors to recover via reload. |
| apps/jetstream/src/app/components/core/AppInitializer.tsx | Wires heartbeat-driven update detection + SW register/unregister behind the flag. |
| apps/jetstream/src/app/app.tsx | Injects WebUpdateNotification into HeaderNavbar. |
| apps/jetstream/src/tests/sw.template.spec.ts | Adds test harness validating SW install/activate/fetch behavior and prefix sync. |
| apps/jetstream/index.html | Adds web manifest link and app title meta. |
| apps/api/src/main.ts | Serves /app/sw.js, blocks /sw.js, and adds kill-switch behavior and headers. |
| apps/api/src/assets/jetstream-app.webmanifest | Adds new web manifest served from /assets/…. |
| .env.example | Documents SW_KILL_SWITCH env var for operators. |
5af7f67 to
38372b9
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 22 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
apps/jetstream/src/app/components/core/service-worker-registration.ts:20
window.location.pathname.startsWith(SW_SCOPE)will also match unrelated paths like/app2..., which could register the service worker outside the intended/appsubtree. Use a boundary check so only/appand/app/*pages register.
if (!('serviceWorker' in navigator) || !window.location.pathname.startsWith(SW_SCOPE)) {
return;
}
libs/shared/ui-core/src/app/HeaderNavbar.tsx:272
updateNotificationis treated as truthy and always appended torightHandMenuItems, but callers will typically pass a React element like<WebUpdateNotification />, which is always truthy even when it rendersnull. BecauseHeaderwraps each array item in an<li>(libs/ui/src/lib/layout/Header.tsx:109-114), this results in an empty header action slot (extra spacing / empty clickable area) whenever no update is available, contradicting the comment here.
const whatsNewItems: React.ReactNode[] = SHOW_WHATS_NEW_POPOVER ? [<HeaderWhatsNewPopover platform={releaseNotePlatform} />] : [];
// Renders nothing until the app detects an update, so it costs no header space normally
const updateNotificationItems = updateNotification ? [updateNotification] : [];
38372b9 to
d4374a3
Compare
d4374a3 to
e0c2bd8
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 22 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
apps/jetstream/src/app/components/core/preload-error-recovery.ts:44
- preload-error recovery intends to avoid reload loops by persisting a cooldown marker, but
setItemInSessionStorage()swallows write failures. If sessionStorage writes are blocked (quota, privacy mode), the marker won’t persist and everyvite:preloadErrorcan trigger another reload, creating the reload loop the comment warns about. Verify the cooldown marker was actually written (or bail out) before callingreload().
tracker.error('Dynamic import failed, reloading to recover', payload);
setItemInSessionStorage(RELOADED_AT_KEY, String(Date.now()));
event.preventDefault();
window.location.reload();
e0c2bd8 to
60087e2
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 23 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
apps/jetstream/src/app/components/core/preload-error-recovery.ts:44
setItemInSessionStorageswallows write errors. If sessionStorage reads work but writes fail (quota/private-mode edge cases), the reload timestamp won’t persist and a persistentvite:preloadErrorcan trigger an infinite reload loop. Gate the reload on a successful write (or a confirmed persisted value) and bail out when persistence isn’t possible.
tracker.error('Dynamic import failed, reloading to recover', payload);
setItemInSessionStorage(RELOADED_AT_KEY, String(Date.now()));
event.preventDefault();
window.location.reload();
apps/jetstream/src/app/components/core/AppInitializer.tsx:166
- This handler is wired to
document.addEventListener('visibilitychange', ...), but the callback parameter is typed asFocusEvent. UsingEventhere avoids misleading typing and matches the actual event dispatched byvisibilitychange.
async (_: FocusEvent) => {
60087e2 to
29293fe
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 26 changed files in this pull request and generated no new comments.
Suppressed comments (1)
apps/jetstream/src/app/components/core/AppInitializer.tsx:210
- This effect intentionally suppresses
react-hooks/exhaustive-deps, but it can safely includecheckForNewVersionin the dependency list and remove the eslint disable. Keeping the deps accurate avoids future refactors accidentally introducing stale closures here.
useEffect(() => {
if (staleBuildDetected) {
checkForNewVersion();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
29293fe to
b598fd0
Compare
b598fd0 to
b4bf5bf
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (2)
libs/shared/ui-utils/src/lib/hooks/useTitle.ts:16
window.matchMediais called unconditionally. In jsdom (Vitest) and some non-browser contextsmatchMediais undefined, which will throw and break any component that usesuseTitle. Guard fortypeof window.matchMedia === 'function'and treat unsupported environments as non-standalone.
if (isDesktop() || !title.endsWith(TITLE_SUFFIX) || !window.matchMedia('(display-mode: standalone)').matches) {
apps/jetstream/src/app/components/core/AppInitializer.tsx:230
- This effect suppresses
react-hooks/exhaustive-depsand omitscheckForNewVersionfrom the dependency array, which can lead to a stale closure (e.g. ifversionoronAnnouncementschanges). SincecheckForNewVersionis already auseCallback, include it in the deps and drop the eslint disable.
if (staleBuildDetected) {
checkForNewVersion();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [staleBuildDetected]);
b4bf5bf to
2979b12
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (3)
libs/shared/ui-utils/src/lib/hooks/useTitle.ts:16
formatTitlecallswindow.matchMedia(...)unconditionally. In the repo's Vitest jsdom environmentwindow.matchMediais typically undefined, which will throw whenever any component usesuseTitlein tests (and can also fail in older/embedded browsers). GuardmatchMediaexistence before calling it.
if (isDesktop() || !title.endsWith(TITLE_SUFFIX) || !window.matchMedia('(display-mode: standalone)').matches) {
libs/shared/ui-core/src/app/ThemeApplier.tsx:59
- The transparent-body check is brittle:
getComputedStyle(...).backgroundColorformatting is not standardized (e.g.'rgba(0,0,0,0)'or'transparent'), sostartsWith('rgba(0, 0, 0, 0)')can fail and incorrectly set<meta name="theme-color">to a transparent value.
const { backgroundColor } = getComputedStyle(document.body);
// Skip a transparent body (styles not applied yet) - the ThemeApplier effect re-runs this on mount
if (backgroundColor && !backgroundColor.startsWith('rgba(0, 0, 0, 0)')) {
meta.content = backgroundColor;
libs/shared/ui-core/src/app/service-worker-registration.ts:32
localStorage.getItem(...)can throw (e.g. privacy modes / blocked storage). If that happens here, service worker registration is skipped via the outer catch, which makes the local testing override unreliable. Read the key with a small try/catch so storage failures don't affect SW registration logic.
if (!import.meta.env.PROD && localStorage.getItem(LOCAL_TESTING_KEY) !== 'true') {
return;
}
2979b12 to
19ff82a
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (2)
libs/shared/ui-utils/src/lib/hooks/useTitle.ts:16
window.matchMedia?.('(display-mode: standalone)').matchesis not fully guarded: ifmatchMediais undefined, optional chaining returnsundefinedand the subsequent.matchesaccess will still throw. Use optional chaining on the result of the call (or a nullish-coalesced boolean) so environments withoutmatchMediasafely fall back to keeping the suffix.
function formatTitle(title: string): string {
if (isDesktop() || !title.endsWith(TITLE_SUFFIX) || !window.matchMedia?.('(display-mode: standalone)').matches) {
return title;
libs/shared/ui-core/src/app/service-worker-registration.ts:28
- This adds fairly nuanced registration/unregistration behavior (path gating, prod vs local testing override, and cleanup of SW caches) but there are no unit tests covering it. There are already Vitest specs in this package; adding tests that mock
navigator.serviceWorker/cacheswould help prevent regressions (e.g. not registering outside/app, not unregistering when feature flags are unresolved, and deleting only the intended cache keys).
export async function registerServiceWorker(): Promise<void> {
try {
const { pathname } = window.location;
if (!('serviceWorker' in navigator) || (pathname !== SW_SCOPE && !pathname.startsWith(`${SW_SCOPE}/`))) {
return;
- Added a service worker template (sw.template.js) to handle precaching of assets. - Registered the service worker in the app initializer based on a feature flag. - Implemented a WebUpdateNotification component to inform users of available updates. - Enhanced the app state management to track the available version from the server. - Updated the HeaderNavbar to include the update notification in the header. - Introduced a preload error recovery mechanism to handle dynamic import failures gracefully. - Removed the old manifest.json file and replaced it with a web manifest link in index.html. - Added tests for the service worker template to ensure correct behavior during installation and activation. - Updated Vite configuration to include the service worker plugin for generating the service worker.
19ff82a to
6489464
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (2)
libs/shared/ui-utils/src/lib/hooks/useTitle.ts:16
window.matchMedia?.('(display-mode: standalone)').matchescan still throw whenmatchMediais undefined: the optional chain only applies to the call, so the expression becomesundefined.matches. Add optional chaining on the return value (or coalesce to false) so non-browser / unsupported environments don't crash when formatting the title.
function formatTitle(title: string): string {
if (isDesktop() || !title.endsWith(TITLE_SUFFIX) || !window.matchMedia?.('(display-mode: standalone)').matches) {
return title;
apps/jetstream/src/tests/sw.template.spec.ts:248
- This test claims the fetch handler serves a precached asset from cache, but
fetchedPathsalready contains the install-time fetches, so it doesn't actually prove the fetch event didn't hit the network. ClearingfetchedPathsafter install (or tracking fetches separately) lets the test assert that cached assets do not trigger a new fetch while unknown hashes do.
it('serves precached root assets from cache, with network fallback for unknown hashes', async () => {
const harness = await setup();
const cached = await harness.dispatchFetch({ url: `${ORIGIN}/index-AAA.js`, method: 'GET', mode: 'no-cors' });
expect(cached.intercepted).toBe(true);
expect((cached.response as MockResponse).body).toBe('network:/index-AAA.js');