From 79e8bbf9837f4642e9c722a2ca5f76f1b728648d Mon Sep 17 00:00:00 2001 From: bitfactory Date: Wed, 26 Aug 2026 01:40:18 +0200 Subject: [PATCH] fix(sw): focus the client before navigating on notification click Clicking a push notification on Android closed the notification and did nothing: the app never came to the front. handleNotificationClick navigates an existing client and then focuses it: if ("navigate" in client && targetUrl) { await client.navigate(targetUrl); } return client.focus(); A notification click grants the service worker a transient user activation, and both focus() and openWindow() refuse to run without one. navigate() spends that activation, and it also replaces the client's document, which invalidates the WindowClient handle the next line uses. So navigating first breaks the click two ways over. Instrumented on a device, against a backgrounded installed PWA: client 1: navigate ok -> focus() threw NotFoundError (stale handle) client 2: navigate ok -> focus() threw InvalidAccessError (activation spent) openWindow() -> threw InvalidAccessError (same) Every rejection was swallowed by the surrounding catch, so the toast closed and nothing opened, with nothing in any log to say why. Focusing first uses the activation while it is still live. navigate() needs none of its own once the client is focused, and it is wrapped so a failed navigation still leaves the app raised rather than falling through to opening a second window. Running in production since 2026-08-24. --- public/sw.js | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/public/sw.js b/public/sw.js index 6c1f0274c..3c1b3b5b3 100644 --- a/public/sw.js +++ b/public/sw.js @@ -218,10 +218,30 @@ async function handleNotificationClick(event) { // get annoyed when each notification opens a fresh window. if ("focus" in client) { try { - if ("navigate" in client && targetUrl) { - await client.navigate(targetUrl); + // FOCUS FIRST, NAVIGATE SECOND, and the order is the entire fix. + // + // A notification click grants the service worker a TRANSIENT USER ACTIVATION, and + // both focus() and openWindow() refuse to run without one. navigate() SPENDS that + // activation, and it also replaces the client's document, which invalidates the + // WindowClient handle we are about to use. Navigating first therefore breaks the + // click two ways over. Instrumented on Android in a real deployment: + // + // client 1: navigate ok -> focus() threw NotFoundError (handle stale after nav) + // client 2: navigate ok -> focus() threw InvalidAccessError (activation spent) + // openWindow() -> threw InvalidAccessError (same) + // + // Every rejection was swallowed by the catch below, so the toast closed and nothing + // came to the front. Focusing first uses the activation while it is still live; + // navigate() does not need one of its own once the client is focused. + const focused = await client.focus(); + if (focused && "navigate" in focused && targetUrl) { + try { + await focused.navigate(targetUrl); + } catch (_) { + // Focused but not navigated still beats nothing opening at all. + } } - return client.focus(); + return focused; } catch (_) { // navigate() can reject for cross-origin or detached clients - fall // through and open a new window below.