Skip to content

fix(app): show web notifications through the service worker on Android - #62750

Closed
dennyschwender wants to merge 1 commit into
anomalyco:devfrom
dennyschwender:fix/android-pwa-notifications
Closed

dennyschwender wants to merge 1 commit into
anomalyco:devfrom
dennyschwender:fix/android-pwa-notifications

Conversation

@dennyschwender

Copy link
Copy Markdown

Fixes #49961

Root cause

The web platform showed every notification through the Notification constructor. Chrome for Android has deliberately never supported that constructor (since Chrome 42); its only notification path is ServiceWorkerRegistration.showNotification(). On Android the constructor throws and nothing has ever been displayed:

TypeError: Failed to construct 'Notification': Illegal constructor. Use ServiceWorkerRegistration.showNotification() instead.

The generated service worker (strategies: "generateSW") also had no notificationclick handler, and Notification.requestPermission() was only ever called from inside notify, i.e. never from a user gesture, so the permission prompt could not be answered in-app either.

Evidence from a raw-CDP loop running the deployed code verbatim in Chromium with --disable-blink-features=NotificationConstructor (real Blink Android behavior, zero monkeypatching):

T2-android-shape -> displayed:FAIL chain-settled:FAIL no-unhandled:FAIL locks-free:FAIL
                    unhandled=TypeError: Failed to construct 'Notification': Illegal constructor.
                    Use ServiceWorkerRegistration.showNotification() instead.
T5-post-failure-cascade -> delivered-after-prior-failure:FAIL settled:FAIL  (Web-Lock deadlock)

What changed

  • packages/app/src/runtime/platform/web.ts — notify is now service-worker-first: show via registration.showNotification(title, { ...opts, tag, data: { key } }), falling back to new Notification(...) where the worker cannot show one (no registration / non-secure context / first install window). The path never rejects: a notification failure can't propagate to the Web-Locks coordinator. Click semantics are preserved: the callback closure cannot cross into the worker, so a worker notification's key is posted back to the window clients (notificationclick → postMessage) and the window that created the notification runs window.focus() + the click callback, exactly like the old notification.onclick. If no window survives (frozen-and-killed PWA), the click focuses/opens the app at its restored route — the session callback itself is lost with the page; that is the one behavior that cannot cross the worker boundary. Extracted as createWebNotify(), mirroring the tested createDesktopNotify seam in packages/desktop.
  • packages/app/notification-click.js (new) + packages/app/vite.pwa.ts — the notificationclick handler for the generated worker (generateSW can't host custom handlers, so it ships next to sw.js via workbox importScripts, emitted by a tiny plugin and precached with a revision so it always matches the worker).
  • packages/app/src/runtime/plugin/host-apis.tsx — the plugin attention.notify wrapper settles on failure too (.then(resolve, skip)), so an unsettled notification promise can never hold opencode:notification-delivery / -focus forever (the old deadlock).
  • packages/app/src/settings/notifications/notifications.tsx — enabling any notification channel now asks for the notification permission from that user gesture while it is still default (web only). This makes the permission prompt reachable in-app; Chrome for Android cannot grant notifications from a background request.
  • packages/app/src/runtime/platform/web.test.ts (new) — 8 unit tests at the createWebNotify seam: Android shape shows through the worker and settles, constructor fallback keeps desktop click semantics (focus + callback + close), constructor-throw with no worker resolves silently, the foreground guard is preserved, showNotification failure settles, permission gating, and the worker click round-trip (callback runs once, foreign keys ignored).

Desktop behavior is unchanged in outcome: a notification is still displayed with the same body/icon, click still focuses the window, runs the callback and closes it. The desktop platform file was not touched; on desktop the worker now displays instead of the constructor when one is active, and the constructor path remains where it doesn't.

Test plan

  • bun test src/runtime/platform/web.test.ts (packages/app): RED on the old logic (6/8 fail with the exact Android TypeError) → GREEN after (8/8).
  • bun run test:unit (packages/app): 2591 pass / 0 fail.
  • bun run typecheck (bun check) and bun run typecheck:tsc (tsc is CI's source of truth): clean.
  • bun run build: clean; dist/sw.js carries importScripts("notification-click.js") and the script is in the precache manifest.
  • bun run test:service-worker (build + Playwright, real generated worker): 14 passed, incl. "the production build precaches every deployable file except on-demand Office assets".
  • bun run lint (oxlint + ast-grep + sdk-docs): clean.
  • End-to-end CDP loop against the built app output (dist/sw.js + the fix verbatim) in two headless Chromiums — desktop shape and Android shape (--disable-blink-features=NotificationConstructor, real Blink throws). After this PR, both:
T1-delivery                -> displayed: via service-worker (count 1) settled:PASS locks-free:PASS no-unhandled:PASS
T2-foreground-guard        -> suppressed-while-focused:PASS settled:PASS
T3-cascade-after-failure   -> failure-settled:PASS delivered-after-failure:PASS locks-free:PASS
T4-sw-click                -> callback-ran:PASS notification-closed:PASS (real sw.js notificationclick, dispatched {"key":"loop-4"})

Visual

<style> .banner { animation: slide 3.2s ease-in-out infinite } @Keyframes slide { 0%, 12% { transform: translateY(4px); opacity: 0 } 28%, 68% { transform: translateY(0); opacity: 1 } 84%, 100% { transform: translateY(4px); opacity: 0 } } @media (prefers-reduced-motion: reduce) { .banner { animation: none; opacity: 1; transform: none } } </style>

Out of scope / follow-ups

  • Web Push for background delivery while the PWA is frozen or killed ([FEATURE]: Web Push notifications for paired browsers #58428). This PR delivers notifications whenever the app process is alive; a killed PWA still cannot be woken without push.
  • The per-session click callback cannot survive a page reload (the closure is gone with the page): after a reload the click focuses/opens the app instead of opening the exact session. Deep-linking the worker click to /server/:server/session/:id would need session identity in Platform.notify's signature — happy to follow up if wanted.
  • No feature flag: this is a bug fix (AGENTS.md → Feature Flags).

@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Nov 8, 2026
@github-actions

github-actions Bot commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 72 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@opencodejr opencodejr Bot closed this Nov 8, 2026
@opencodejr

opencodejr Bot commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Thanks for the thorough write-up and the CDP evidence!

I think this duplicates #58399, which is still open and fixes the same issue (#49961) the same way:

  • fix(app): use service worker notifications on Android #58399: shows notifications through ServiceWorkerRegistration.showNotification() and falls back to new Notification(...) only when that can't work. Its worker script handles notificationclick, and clicking a notification opens the session it is about.

This PR has two pieces #58399 doesn't: asking for notification permission when notifications are turned on in settings, and making the plugin attention.notify call finish even when showing the notification fails. Those could help there. Please suggest them on #58399, or review and test it there, especially on a real Android device.

@dennyschwender
dennyschwender deleted the fix/android-pwa-notifications branch November 8, 2026 16:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs:compliance This means the issue will auto-close after 2 hours.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

web: notifications fail in Android PWA

1 participant