From 3cfab390a0da4a6aa64c62f6dfbc70e7697190e8 Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Thu, 1 Oct 2026 12:42:34 +0200 Subject: [PATCH] fix(push): recreate a browser subscription that was lost The background re-sync skipped any account whose browser subscription was gone (isWebPushEnabled needs a live one), which is exactly the state that needs repairing: the browser or the user cleared the endpoint while the opt-in and the server registration survived, and push stopped for good. It now runs whenever the user opted in and permission is granted, so the normal enable flow recreates the subscription. It still never prompts. A failed attempt no longer waits a day for the next one. --- lib/__tests__/web-push.test.ts | 35 ++++++++++++++++++++++++++++++++++ lib/web-push.ts | 11 +++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index a8b24b077..a6884a579 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -6,6 +6,7 @@ import { disableWebPush, enableWebPush, listPushDevices, + isWebPushEnabled, resetWebPushResyncState, resyncWebPush, revokePushDevice, @@ -274,6 +275,40 @@ describe('enableWebPush', () => { // repaired in the background on app start - nobody should have to find the // settings toggle to stop the spam pushes. describe('resyncWebPush', () => { + it('repairs a lost browser subscription for an account that previously opted in', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-old'); + const { registration } = installPushBrowser(); + registration.pushManager.getSubscription.mockResolvedValue(null as never); + const client = makeClient([sub('push-old', THIS_DEVICE)]); + const calls = installFetch({}); + + expect(await isWebPushEnabled(ACCOUNT_ID)).toBe(false); + expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true); + expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); + expect(calls).toContainEqual({ url: `${RELAY}/api/push/register/web`, method: 'POST' }); + }); + + it.each(['default', 'denied'])('does not resubscribe or prompt with %s permission', async (permission) => { + localStorage.setItem(SUB_KEY, 'push-old'); + const { registration } = installPushBrowser(); + vi.stubGlobal('Notification', { permission, requestPermission: vi.fn() }); + const client = makeClient([sub('push-old', THIS_DEVICE)]); + expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false); + expect(registration.pushManager.subscribe).not.toHaveBeenCalled(); + expect(Notification.requestPermission).not.toHaveBeenCalled(); + }); + + it('allows retry after a transient relay failure', async () => { + localStorage.setItem(SUB_KEY, 'push-old'); + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + const client = makeClient([sub('push-old', THIS_DEVICE)]); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); + expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false); + installFetch({}); + expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true); + }); + it('re-syncs an enabled registration and installs the missing filter', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); diff --git a/lib/web-push.ts b/lib/web-push.ts index c3f742025..90280a5cd 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -732,7 +732,7 @@ export interface ResyncWebPushParams { /** * Bring an already-enabled push registration up to date without any user * action: refresh its expiry and install/repair the delivery filter. Nothing - * here can prompt - it only runs when push is already on for the account - + * here can prompt - it requires a saved opt-in and granted permission - * and every failure is swallowed because the app must not care whether the * background touch-up worked. Returns true when a re-sync actually ran. */ @@ -747,7 +747,12 @@ export async function resyncWebPush(params: ResyncWebPushParams): Promise