From 61f94478874ea81eb6dfa71ae31c83c38c8f2787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:39:59 +0100 Subject: [PATCH] fix(pwa): focus the open window on a notification tap instead of reloading it Tapping a reminder called `navigate()` on the first app window even when it already showed the target page: a full document load that dropped the query cache and any open sheet. The rejection of an unawaited `navigate()` on an uncontrolled window went unhandled. A window already on the target is now only focused, and preferred over the first window. Otherwise the window is focused, then navigated; if that fails a new window opens. --- public/sw.js | 22 +++++-- src/__tests__/service-worker.test.ts | 98 ++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/public/sw.js b/public/sw.js index 087a1f34e..315922846 100644 --- a/public/sw.js +++ b/public/sw.js @@ -580,17 +580,25 @@ self.addEventListener("notificationclick", (event) => { safeUrl = "/"; } + const target = new URL(safeUrl, self.location.origin).href; event.waitUntil( self.clients .matchAll({ type: "window", includeUncontrolled: true }) .then((clientList) => { - for (const client of clientList) { - if (client.url.includes(self.location.origin) && "focus" in client) { - client.navigate(safeUrl); - return client.focus(); - } - } - return self.clients.openWindow(safeUrl); + const windows = clientList.filter( + (c) => c.url.startsWith(self.location.origin + "/") && "focus" in c, + ); + // A window already on the target only needs focus: navigating it + // would reload the app and drop whatever is open there. + const there = windows.find((c) => c.url === target); + if (there) return there.focus(); + const client = windows[0]; + if (!client) return self.clients.openWindow(safeUrl); + // Focus first, while the tap still counts as user activation. + return client + .focus() + .then(() => client.navigate(safeUrl)) + .catch(() => self.clients.openWindow(safeUrl)); }), ); }); diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index 875a6b3e4..76f6099d5 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -1110,3 +1110,101 @@ describe("sw.js — push handler", () => { expect(badge.value).toBe(3); }); }); + +// ── notification tap ───────────────────────────────────────────────────────── +interface FakeWindow { + url: string; + focused: number; + navigatedTo: string[]; + focus: () => Promise; + navigate: (url: string) => Promise; +} + +function fakeWindow(path: string, navigateFails = false): FakeWindow { + const win: FakeWindow = { + url: `${ORIGIN}${path}`, + focused: 0, + navigatedTo: [], + focus: async () => { + win.focused += 1; + return win; + }, + navigate: async (url: string) => { + if (navigateFails) throw new TypeError("not controlled"); + win.navigatedTo.push(url); + return win; + }, + }; + return win; +} + +async function tapNotification( + harness: SwHarness, + windows: FakeWindow[], + url: string, +): Promise { + const opened: string[] = []; + (harness.context.self as { clients: unknown }).clients = { + claim: async () => {}, + matchAll: async () => windows, + openWindow: async (target: string) => { + opened.push(target); + return null; + }, + }; + let settled: Promise = Promise.resolve(); + harness.listeners.get("notificationclick")!({ + notification: { close: () => {}, data: { url } }, + waitUntil: (p: Promise) => { + settled = p; + }, + }); + await settled; + return opened; +} + +describe("sw.js — notification tap", () => { + it("only focuses a window that is already on the target", async () => { + const harness = bootServiceWorker(); + const win = fakeWindow("/medications"); + + const opened = await tapNotification(harness, [win], "/medications"); + + expect(win.focused).toBe(1); + expect(win.navigatedTo).toEqual([]); + expect(opened).toEqual([]); + }); + + it("prefers the window on the target over the first one", async () => { + const harness = bootServiceWorker(); + const other = fakeWindow("/"); + const there = fakeWindow("/mood?add=1"); + + await tapNotification(harness, [other, there], "/mood?add=1"); + + expect(there.focused).toBe(1); + expect(other.focused).toBe(0); + expect(other.navigatedTo).toEqual([]); + }); + + it("focuses and navigates a window that is elsewhere", async () => { + const harness = bootServiceWorker(); + const win = fakeWindow("/"); + + const opened = await tapNotification(harness, [win], "/medications"); + + expect(win.focused).toBe(1); + expect(win.navigatedTo).toEqual(["/medications"]); + expect(opened).toEqual([]); + }); + + it("opens a window when none is open or navigating fails", async () => { + const harness = bootServiceWorker(); + expect(await tapNotification(harness, [], "/medications")).toEqual([ + "/medications", + ]); + expect( + await tapNotification(harness, [fakeWindow("/", true)], "/medications"), + ).toEqual(["/medications"]); + }); +});