Skip to content

feat: implement service worker for PWA - #1873

Open
paustint wants to merge 1 commit into
mainfrom
feat/pwa-installability
Open

feat: implement service worker for PWA#1873
paustint wants to merge 1 commit into
mainfrom
feat/pwa-installability

Conversation

@paustint

Copy link
Copy Markdown
Contributor
  • 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.

Copilot AI lite review requested due to automatic review settings July 21, 2026 15:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 new pwa-service-worker feature flag.
  • Add “Update available” state + header UI, driven by heartbeat version mismatch and optionally triggering a service worker update + reload.
  • Add a global vite:preloadError handler 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.

Comment thread apps/jetstream/src/app/components/core/preload-error-recovery.ts Outdated
Comment thread apps/jetstream/src/app/components/core/service-worker-registration.ts Outdated
Comment thread apps/jetstream/src/app/components/core/WebUpdateNotification.tsx
Copilot AI review requested due to automatic review settings July 28, 2026 02:18
@paustint
paustint force-pushed the feat/pwa-installability branch from 5af7f67 to 38372b9 Compare July 28, 2026 02:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /app subtree. Use a boundary check so only /app and /app/* pages register.
    if (!('serviceWorker' in navigator) || !window.location.pathname.startsWith(SW_SCOPE)) {
      return;
    }

libs/shared/ui-core/src/app/HeaderNavbar.tsx:272

  • updateNotification is treated as truthy and always appended to rightHandMenuItems, but callers will typically pass a React element like <WebUpdateNotification />, which is always truthy even when it renders null. Because Header wraps 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] : [];

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 22 changed files in this pull request and generated no new comments.

@paustint
paustint force-pushed the feat/pwa-installability branch from d4374a3 to e0c2bd8 Compare July 29, 2026 15:56
Copilot AI review requested due to automatic review settings July 29, 2026 15:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 every vite:preloadError can trigger another reload, creating the reload loop the comment warns about. Verify the cooldown marker was actually written (or bail out) before calling reload().
  tracker.error('Dynamic import failed, reloading to recover', payload);
  setItemInSessionStorage(RELOADED_AT_KEY, String(Date.now()));
  event.preventDefault();
  window.location.reload();

Copilot AI review requested due to automatic review settings July 30, 2026 03:18
@paustint
paustint force-pushed the feat/pwa-installability branch from e0c2bd8 to 60087e2 Compare July 30, 2026 03:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • setItemInSessionStorage swallows write errors. If sessionStorage reads work but writes fail (quota/private-mode edge cases), the reload timestamp won’t persist and a persistent vite:preloadError can 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 as FocusEvent. Using Event here avoids misleading typing and matches the actual event dispatched by visibilitychange.
    async (_: FocusEvent) => {

Copilot AI review requested due to automatic review settings August 1, 2026 15:24
@paustint
paustint force-pushed the feat/pwa-installability branch from 60087e2 to 29293fe Compare August 1, 2026 15:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 include checkForNewVersion in 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

@paustint
paustint force-pushed the feat/pwa-installability branch from 29293fe to b598fd0 Compare August 1, 2026 17:36
Copilot AI review requested due to automatic review settings August 1, 2026 17:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 28 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings August 1, 2026 17:49
@paustint
paustint force-pushed the feat/pwa-installability branch from b598fd0 to b4bf5bf Compare August 1, 2026 17:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.matchMedia is called unconditionally. In jsdom (Vitest) and some non-browser contexts matchMedia is undefined, which will throw and break any component that uses useTitle. Guard for typeof 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-deps and omits checkForNewVersion from the dependency array, which can lead to a stale closure (e.g. if version or onAnnouncements changes). Since checkForNewVersion is already a useCallback, include it in the deps and drop the eslint disable.
    if (staleBuildDetected) {
      checkForNewVersion();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [staleBuildDetected]);

@paustint
paustint force-pushed the feat/pwa-installability branch from b4bf5bf to 2979b12 Compare August 3, 2026 02:14
Copilot AI review requested due to automatic review settings August 3, 2026 02:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • formatTitle calls window.matchMedia(...) unconditionally. In the repo's Vitest jsdom environment window.matchMedia is typically undefined, which will throw whenever any component uses useTitle in tests (and can also fail in older/embedded browsers). Guard matchMedia existence 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(...).backgroundColor formatting is not standardized (e.g. 'rgba(0,0,0,0)' or 'transparent'), so startsWith('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;
    }

Copilot AI review requested due to automatic review settings August 4, 2026 15:09
@paustint
paustint force-pushed the feat/pwa-installability branch from 2979b12 to 19ff82a Compare August 4, 2026 15:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)').matches is not fully guarded: if matchMedia is undefined, optional chaining returns undefined and the subsequent .matches access will still throw. Use optional chaining on the result of the call (or a nullish-coalesced boolean) so environments without matchMedia safely 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/caches would 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.
@paustint
paustint force-pushed the feat/pwa-installability branch from 19ff82a to 6489464 Compare August 5, 2026 02:04
Copilot AI review requested due to automatic review settings August 5, 2026 02:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)').matches can still throw when matchMedia is undefined: the optional chain only applies to the call, so the expression becomes undefined.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 fetchedPaths already contains the install-time fetches, so it doesn't actually prove the fetch event didn't hit the network. Clearing fetchedPaths after 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');

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants