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.
This commit is contained in:
lucamzanon
2026-10-01 12:42:34 +02:00
parent 410d6b3532
commit 3cfab390a0
2 changed files with 44 additions and 2 deletions
+35
View File
@@ -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');
+9 -2
View File
@@ -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<boolea
const last = lastResyncAt.get(accountId);
if (last !== undefined && Date.now() - last < RESYNC_INTERVAL_MS) return false;
try {
if (!(await isWebPushEnabled(accountId))) return false;
// The browser may have lost its endpoint while our saved opt-in and
// server registration survived. Recreate it through the normal enable
// flow instead of skipping the account in that state, which is what
// isWebPushEnabled (it requires a live browser subscription) would do.
if (!isWebPushSupported() || Notification.permission !== 'granted'
|| !localStorage.getItem(subscriptionIdKey(accountId))) return false;
lastResyncAt.set(accountId, Date.now());
await enableWebPush({
client: params.client,
@@ -757,6 +762,8 @@ export async function resyncWebPush(params: ResyncWebPushParams): Promise<boolea
});
return true;
} catch {
// A failed attempt must not wait a day for the next one.
lastResyncAt.delete(accountId);
return false;
}
}