Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/fix-pwa-push-resume-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Keep background push registered while the app is open, and let the service worker fall back to a validated persisted session when an unfocused or resumed PWA tab stops answering session requests.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import type { MatrixClient } from '$types/matrix-sdk';

import type { ClientConfig } from '../../../hooks/useClientConfig';
import { disablePushNotifications, enablePushNotifications } from './PushNotifications';
import {
disablePushNotifications,
enablePushNotifications,
reconcilePushNotifications,
} from './PushNotifications';

vi.mock('@sentry/react', () => ({
metrics: {
Expand Down Expand Up @@ -106,6 +110,40 @@ afterEach(() => {
});

describe('web push notifications', () => {
it('reconciles an enabled push registration at startup when permission is already granted', async () => {
const subscription = makeSubscription();
installWebPush(subscription);
const mx = makeMatrixClient();

vi.stubGlobal('Notification', { permission: 'granted' });

await expect(
reconcilePushNotifications(mx, clientConfig, [subscription.toJSON(), vi.fn<() => void>()])
).resolves.toBe(true);

expect(mx.setPusher).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'http',
app_id: 'moe.sable.web',
pushkey: 'p256dh-key',
})
);
});

it('skips startup reconciliation when notification permission is not granted', async () => {
const subscription = makeSubscription();
installWebPush(subscription);
const mx = makeMatrixClient();

vi.stubGlobal('Notification', { permission: 'default' });

await expect(
reconcilePushNotifications(mx, clientConfig, [subscription.toJSON(), vi.fn<() => void>()])
).resolves.toBe(false);

expect(mx.setPusher).not.toHaveBeenCalled();
});

it('updates the Matrix pusher directly and removes the legacy Sable pusher when reusing a browser subscription', async () => {
const subscription = makeSubscription();
const { controllerPostMessage } = installWebPush(subscription);
Expand Down
37 changes: 19 additions & 18 deletions src/app/features/settings/notifications/PushNotifications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ export async function requestBrowserNotificationPermission(): Promise<Notificati
}
}

export async function reconcilePushNotifications(
mx: MatrixClient,
clientConfig: ClientConfig,
pushSubscriptionAtom: PushSubscriptionState
): Promise<boolean> {
if (isTauri()) return false;
if (
!('serviceWorker' in navigator) ||
!('PushManager' in window) ||
!('Notification' in window)
) {
return false;
}
if (window.Notification.permission !== 'granted') return false;

await enablePushNotifications(mx, clientConfig, pushSubscriptionAtom);
return true;
}

export async function enablePushNotifications(
mx: MatrixClient,
clientConfig: ClientConfig,
Expand Down Expand Up @@ -304,21 +323,3 @@ export async function deRegisterAllPushers(mx: MatrixClient): Promise<void> {

await Promise.allSettled(deletionPromises);
}

export async function togglePusher(
mx: MatrixClient,
clientConfig: ClientConfig,
visible: boolean,
usePushNotifications: boolean,
pushSubscriptionAtom: PushSubscriptionState,
keepEnabledWhenVisible = false
): Promise<void> {
if (!usePushNotifications) return;

if (visible && !keepEnabledWhenVisible) {
await disablePushNotifications(mx, clientConfig, pushSubscriptionAtom);
return;
}

await enablePushNotifications(mx, clientConfig, pushSubscriptionAtom);
}
154 changes: 53 additions & 101 deletions src/app/hooks/useAppVisibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import { useAppVisibility } from './useAppVisibility';

const mocks = vi.hoisted(() => ({
pushSessionToSW: vi.fn<() => void>(),
togglePusher: vi.fn<() => Promise<void>>(() => Promise.resolve()),
usePushNotifications: false,
swPostMessage: vi.fn<() => void>(),
}));

vi.mock('@sentry/react', () => ({
Expand All @@ -28,26 +27,6 @@ vi.mock('../../sw-session', () => ({
pushSessionToSW: mocks.pushSessionToSW,
}));

vi.mock('../features/settings/notifications/PushNotifications', () => ({
togglePusher: mocks.togglePusher,
}));

vi.mock('../state/hooks/settings', () => ({
useSetting: () => [mocks.usePushNotifications],
}));

vi.mock('../state/settings', () => ({
settingsAtom: {},
}));

vi.mock('../state/pushSubscription', () => ({
pushSubscriptionAtom: {},
}));

vi.mock('jotai', () => ({
useAtom: () => [undefined, vi.fn<() => void>()],
}));

vi.mock('./useClientConfig', () => ({
useClientConfig: () => ({}),
useExperimentVariant: () => ({
Expand Down Expand Up @@ -102,19 +81,31 @@ describe('useAppVisibility', () => {
setVisibilityState('visible');
setOnline(true);
mocks.pushSessionToSW.mockClear();
mocks.togglePusher.mockClear();
mocks.togglePusher.mockImplementation(() => Promise.resolve());
mocks.usePushNotifications = false;
mocks.swPostMessage.mockClear();

Object.defineProperty(window.navigator, 'serviceWorker', {
configurable: true,
value: {
controller: null,
ready: Promise.resolve({
active: {
state: 'activated',
postMessage: mocks.swPostMessage,
},
}),
},
});
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('does not abort a healthy sliding sync poll on focus', () => {
const mx = makeClient(SyncState.Syncing);

renderHook(() => useAppVisibility(mx, session));
renderHook(() => useAppVisibility(session));

act(() => {
window.dispatchEvent(new Event('focus'));
Expand All @@ -126,7 +117,7 @@ describe('useAppVisibility', () => {
it('does not automatically retry when sync becomes degraded', () => {
const mx = makeClient(SyncState.Syncing);

renderHook(() => useAppVisibility(mx, session));
renderHook(() => useAppVisibility(session));

act(() => {
mx.emitSyncState(SyncState.Reconnecting);
Expand All @@ -144,83 +135,13 @@ describe('useAppVisibility', () => {
it('does not retry immediately on mount when sync is still connecting', () => {
const mx = makeClient(SyncState.Reconnecting);

renderHook(() => useAppVisibility(mx, session));
renderHook(() => useAppVisibility(session));

expect(mx.retryImmediately).not.toHaveBeenCalled();
});

it('dedupes pusher visibility toggles while visible state is unchanged', () => {
mocks.usePushNotifications = true;
mocks.togglePusher.mockImplementation(() => new Promise(() => undefined));
const mx = makeClient(SyncState.Syncing);

renderHook(() => useAppVisibility(mx, session));

expect(mocks.togglePusher).toHaveBeenCalledOnce();
expect(mocks.togglePusher).toHaveBeenLastCalledWith(
mx,
{},
true,
true,
[undefined, expect.any(Function)],
false
);

act(() => {
window.dispatchEvent(new Event('focus'));
vi.advanceTimersByTime(100);
});

expect(mocks.togglePusher).toHaveBeenCalledOnce();
});

it('keeps the latest pusher visibility when toggles settle out of order', async () => {
mocks.usePushNotifications = true;
const resolveToggles: Array<() => void> = [];
mocks.togglePusher.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveToggles.push(resolve);
})
);
const mx = makeClient(SyncState.Syncing);

renderHook(() => useAppVisibility(mx, session));

act(() => {
setVisibilityState('hidden');
document.dispatchEvent(new Event('visibilitychange'));
});

act(() => {
setVisibilityState('visible');
document.dispatchEvent(new Event('visibilitychange'));
});

expect(mocks.togglePusher).toHaveBeenCalledTimes(3);

await act(async () => {
resolveToggles[2]?.();
await Promise.resolve();
});

await act(async () => {
resolveToggles[1]?.();
resolveToggles[0]?.();
await Promise.resolve();
});

act(() => {
window.dispatchEvent(new Event('focus'));
});

expect(mocks.togglePusher).toHaveBeenCalledTimes(3);
});

it('does not push the service worker session on focus or online events', () => {
const mx = makeClient(SyncState.Syncing);

renderHook(() => useAppVisibility(mx, session));
renderHook(() => useAppVisibility(session));

act(() => {
window.dispatchEvent(new Event('focus'));
Expand All @@ -235,7 +156,7 @@ describe('useAppVisibility', () => {
const visibilityHandler = vi.fn<(visible: boolean) => void>();
const unsubscribe = appEvents.onVisibilityChange(visibilityHandler);

renderHook(() => useAppVisibility(mx, session));
renderHook(() => useAppVisibility(session));

act(() => {
vi.advanceTimersByTime(100);
Expand All @@ -252,7 +173,7 @@ describe('useAppVisibility', () => {
const visibilityHandler = vi.fn<(visible: boolean) => void>();
const unsubscribe = appEvents.onVisibilityChange(visibilityHandler);

renderHook(() => useAppVisibility(mx, session));
renderHook(() => useAppVisibility(session));
visibilityHandler.mockClear();

act(() => {
Expand All @@ -264,4 +185,35 @@ describe('useAppVisibility', () => {

unsubscribe();
});

it('requests a service worker claim when the app becomes visible without a controller', async () => {
renderHook(() => useAppVisibility(session));

act(() => {
setVisibilityState('hidden');
document.dispatchEvent(new Event('visibilitychange'));
setVisibilityState('visible');
document.dispatchEvent(new Event('visibilitychange'));
});

await act(async () => {
await Promise.resolve();
});

expect(mocks.swPostMessage).toHaveBeenCalledWith({ type: 'CLAIM_CLIENTS' });
});

it('requests a service worker claim on bfcache restore without a controller', async () => {
renderHook(() => useAppVisibility(session));

act(() => {
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
});

await act(async () => {
await Promise.resolve();
});

expect(mocks.swPostMessage).toHaveBeenCalledWith({ type: 'CLAIM_CLIENTS' });
});
});
Loading
Loading