Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/app/hooks/useBackgroundSyncPause.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';

const { pause, resume, getSlidingSyncManager, listen, listenOff, mockIsTauri, mockCallEmbed } =
vi.hoisted(() => ({
pause: vi.fn<() => void>(),
resume: vi.fn<() => void>(),
getSlidingSyncManager: vi.fn<() => unknown>(),
listen: vi.fn<(_event: string, _cb: () => void) => Promise<() => void>>(),
listenOff: vi.fn<() => void>(),
mockIsTauri: { value: false },
mockCallEmbed: { value: undefined as unknown },
}));

vi.mock('$client/initMatrix', () => ({ getSlidingSyncManager }));

vi.mock('jotai', () => ({
useAtomValue: () => mockCallEmbed.value,
atom: vi.fn<() => unknown>(),
}));

vi.mock('../state/callEmbed', () => ({ callEmbedAtom: {} }));

vi.mock('@tauri-apps/api/event', () => ({
listen,
TauriEvent: { WINDOW_RESUMED: 'tauri://resumed' },
}));

vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => mockIsTauri.value }));

import { useBackgroundSyncPause } from './useBackgroundSyncPause';

const setVisibility = (state: 'visible' | 'hidden') =>
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue(state);

describe('useBackgroundSyncPause', () => {
beforeEach(() => {
vi.restoreAllMocks();
pause.mockReset();
resume.mockReset();
listenOff.mockReset();
listen.mockReset().mockResolvedValue(listenOff);
getSlidingSyncManager.mockReset().mockReturnValue({ pause, resume });
mockIsTauri.value = false;
mockCallEmbed.value = undefined;
});

it('pauses sync when the app is backgrounded', () => {
setVisibility('visible');
renderHook(() => useBackgroundSyncPause({ clientRunning: true } as never));

setVisibility('hidden');
document.dispatchEvent(new Event('visibilitychange'));

expect(pause).toHaveBeenCalled();
});

it('resumes sync when the app returns to the foreground', () => {
setVisibility('hidden');
renderHook(() => useBackgroundSyncPause({ clientRunning: true } as never));
pause.mockClear();

setVisibility('visible');
document.dispatchEvent(new Event('visibilitychange'));

expect(resume).toHaveBeenCalled();
});

it('keeps polling in the background during a call', () => {
mockCallEmbed.value = { dispose: vi.fn<() => void>() };
setVisibility('hidden');

renderHook(() => useBackgroundSyncPause({ clientRunning: true } as never));

expect(pause).not.toHaveBeenCalled();
expect(resume).toHaveBeenCalled();
});

it('resumes on tauri://resumed without waiting for visibilitychange', () => {
mockIsTauri.value = true;
setVisibility('hidden');
renderHook(() => useBackgroundSyncPause({ clientRunning: true } as never));
resume.mockClear();

const [, cb] = listen.mock.calls[0] as [string, () => void];
cb();

expect(resume).toHaveBeenCalled();
});

it('does nothing without a client', () => {
renderHook(() => useBackgroundSyncPause(undefined));

expect(pause).not.toHaveBeenCalled();
expect(resume).not.toHaveBeenCalled();
});

it('resumes on unmount so a paused transport is never left parked', () => {
setVisibility('hidden');
const { unmount } = renderHook(() => useBackgroundSyncPause({ clientRunning: true } as never));
resume.mockClear();

unmount();

expect(resume).toHaveBeenCalled();
});
});
41 changes: 41 additions & 0 deletions src/app/hooks/useBackgroundSyncPause.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { TauriEvent, listen } from '@tauri-apps/api/event';
import { isTauri } from '@tauri-apps/api/core';
import type { MatrixClient } from '$types/matrix-sdk';
import { getSlidingSyncManager } from '$client/initMatrix';
import { callEmbedAtom } from '../state/callEmbed';

/**
* Stop polling while the app is backgrounded. wry only calls Android `WebView.onPause()`,
* which does not pause JavaScript, so the long-poll otherwise runs until the OS freezes
* the process. Driven by `visibilitychange` rather than `tauri://suspended`, which on iOS
* maps to applicationWillResignActive.
*/
export const useBackgroundSyncPause = (mx: MatrixClient | undefined): void => {
const callEmbed = useAtomValue(callEmbedAtom);
const callActive = callEmbed !== undefined;

useEffect(() => {
if (!mx) return undefined;

const resume = () => getSlidingSyncManager(mx)?.resume();
const applyVisibility = () => {
const manager = getSlidingSyncManager(mx);
if (!manager) return;
if (document.visibilityState === 'hidden' && !callActive) manager.pause();
else manager.resume();
};

document.addEventListener('visibilitychange', applyVisibility);
const unlisten = isTauri() ? listen(TauriEvent.WINDOW_RESUMED, resume) : undefined;

applyVisibility();

return () => {
document.removeEventListener('visibilitychange', applyVisibility);
unlisten?.then((off) => off());
resume();
};
}, [mx, callActive]);
};
23 changes: 16 additions & 7 deletions src/app/hooks/useNetworkRecovery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { renderHook } from '@testing-library/react';

const { nudgeReconnect, setOnline, listenOff, listen, mockIsTauri } = vi.hoisted(() => ({
nudgeReconnect: vi.fn<(mx: unknown, reason: string) => boolean>(),
nudgeReconnect: vi.fn<(mx: unknown, reason: string, opts?: { force?: boolean }) => boolean>(),
setOnline: vi.fn<() => void>(),
listenOff: vi.fn<() => void>(),
listen: vi.fn<(_event: string, _cb: () => void) => Promise<() => void>>(),
Expand Down Expand Up @@ -59,7 +59,7 @@ describe('useNetworkRecovery', () => {

dispatchEvent(new Event('online'));

expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'online');
expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'online', undefined);
expect(setOnline).toHaveBeenCalled();
});

Expand All @@ -71,7 +71,7 @@ describe('useNetworkRecovery', () => {
vi.advanceTimersByTime(30_001);
document.dispatchEvent(new Event('visibilitychange'));

expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'visible');
expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'visible', undefined);
visState.mockRestore();
});

Expand Down Expand Up @@ -112,7 +112,7 @@ describe('useNetworkRecovery', () => {

vi.advanceTimersByTime(80_000);

expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'stalled');
expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'stalled', undefined);
});

it('does not trigger stalled nudge when Sync events keep arriving', () => {
Expand Down Expand Up @@ -178,7 +178,7 @@ describe('useNetworkRecovery', () => {

dispatchEvent(new Event('online'));
expect(nudgeReconnect).toHaveBeenCalledTimes(1);
expect(nudgeReconnect).toHaveBeenLastCalledWith(expect.anything(), 'online');
expect(nudgeReconnect).toHaveBeenLastCalledWith(expect.anything(), 'online', undefined);

vi.advanceTimersByTime(3_000);

Expand Down Expand Up @@ -213,7 +213,7 @@ describe('useNetworkRecovery', () => {
mockIsTauri.value = true;
});

it('nudges on tauri://resumed event', () => {
it('force-nudges on tauri://resumed event', () => {
listen.mockResolvedValue(listenOff);

renderHook(() => useNetworkRecovery({ clientRunning: true } as never));
Expand All @@ -222,7 +222,16 @@ describe('useNetworkRecovery', () => {
const [, cb] = listen.mock.calls[0] as [string, () => void];
cb();

expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'resumed');
expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'resumed', { force: true });
});

it('does not subscribe to tauri://suspended', () => {
listen.mockResolvedValue(listenOff);

renderHook(() => useNetworkRecovery({ clientRunning: true } as never));

expect(listen).toHaveBeenCalledOnce();
expect(listen).not.toHaveBeenCalledWith('tauri://suspended', expect.any(Function));
});

it('calls the listen cleanup on unmount', async () => {
Expand Down
12 changes: 7 additions & 5 deletions src/app/hooks/useNetworkRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ export const useNetworkRecovery = (mx: MatrixClient | undefined): void => {
}, []);

const nudge = useCallback(
(reason: NudgeReason): boolean => {
(reason: NudgeReason, opts?: { force?: boolean }): boolean => {
if (!mx) return false;
onlineManager.setOnline(true);
return nudgeReconnect(mx, reason);
return nudgeReconnect(mx, reason, opts);
},
[mx]
);
Expand All @@ -45,11 +45,11 @@ export const useNetworkRecovery = (mx: MatrixClient | undefined): void => {
// Foreground nudges (resume / visible-stale / online) get one sync verification:
// if no Sync arrives within VERIFY_AFTER_NUDGE_MS, retry once past the throttle.
const nudgeForeground = useCallback(
(reason: NudgeReason): void => {
(reason: NudgeReason, opts?: { force?: boolean }): void => {
cancelVerify();
if (!mx) return;
const syncAtNudge = lastSyncAtRef.current;
if (!nudge(reason)) return;
if (!nudge(reason, opts)) return;
verifyTimerRef.current = window.setTimeout(() => {
verifyTimerRef.current = undefined;
if (lastSyncAtRef.current === syncAtNudge) {
Expand Down Expand Up @@ -82,8 +82,10 @@ export const useNetworkRecovery = (mx: MatrixClient | undefined): void => {
if (nudge('stalled')) lastSyncAtRef.current = Date.now();
}, STALL_CHECK_INTERVAL_MS);

// WINDOW_SUSPENDED is skipped on purpose: on iOS it maps to applicationWillResignActive,
// which fires on Control Center, notification banners and other transient interruptions.
const unlisten = isTauri()
? listen(TauriEvent.WINDOW_RESUMED, () => nudgeForeground('resumed'))
? listen(TauriEvent.WINDOW_RESUMED, () => nudgeForeground('resumed', { force: true }))
: undefined;

return () => {
Expand Down
2 changes: 2 additions & 0 deletions src/app/pages/client/ClientRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { createLogger } from '$utils/debug';
import { useSyncNicknames } from '$hooks/useNickname';
import { useAppVisibility } from '$hooks/useAppVisibility';
import { useNetworkRecovery } from '$hooks/useNetworkRecovery';
import { useBackgroundSyncPause } from '$hooks/useBackgroundSyncPause';
import { composerIcon, DotsThreeOutlineVerticalIcon } from '$components/icons/phosphor';
import { getHomePath } from '$pages/pathUtils';
import { DIRECT_ROOM_PATH, HOME_ROOM_PATH, SPACE_ROOM_PATH } from '$pages/paths';
Expand Down Expand Up @@ -353,6 +354,7 @@ export function ClientRoot({ children }: ClientRootProps) {
useLogoutListener(mx);
useAppVisibility(mx);
useNetworkRecovery(mx);
useBackgroundSyncPause(mx);
useCrossSigningResetDetect(mx);
useDeviceDisplayName(mx);

Expand Down
39 changes: 37 additions & 2 deletions src/client/initMatrix.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Session } from '$state/sessions';
import type * as PlatformModule from '$utils/platform';
import { ACTIVE_SESSION_KEY, MATRIX_SESSIONS_KEY } from '$state/sessions';
import { ownsActiveMediaSession } from './initMatrix';

const { isMobileTauri } = vi.hoisted(() => ({
isMobileTauri: vi.fn<() => boolean>(),
}));

vi.mock('$utils/platform', async (importOriginal) => ({
...(await importOriginal<typeof PlatformModule>()),
isMobileTauri,
}));

import { ownsActiveMediaSession, resolvePollTimeoutMs } from './initMatrix';

const alice = { userId: '@alice:example.org' } as Session;
const bob = { userId: '@bob:example.org' } as Session;
Expand All @@ -24,3 +35,27 @@ describe('ownsActiveMediaSession', () => {
expect(ownsActiveMediaSession(alice)).toBe(true);
});
});

describe('resolvePollTimeoutMs', () => {
beforeEach(() => {
isMobileTauri.mockReset();
});

it('uses the shorter poll on mobile tauri', () => {
isMobileTauri.mockReturnValue(true);

expect(resolvePollTimeoutMs(undefined)).toBe(30000);
});

it('uses the default poll elsewhere', () => {
isMobileTauri.mockReturnValue(false);

expect(resolvePollTimeoutMs(undefined)).toBe(45000);
});

it('prefers an explicitly configured timeout on mobile', () => {
isMobileTauri.mockReturnValue(true);

expect(resolvePollTimeoutMs(5000)).toBe(5000);
});
});
19 changes: 17 additions & 2 deletions src/client/initMatrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage';
import { createLogger } from '$utils/debug';
import { createDebugLogger } from '$utils/debugLogger';
import { isMobileTauri } from '$utils/platform';
import * as Sentry from '@sentry/react';
import { pushSessionToSW } from '../sw-session';
import { assertAuthMetadataIssuer, createSessionTokenRefresher } from './oidcTokenRefresher';
Expand Down Expand Up @@ -58,6 +59,12 @@ export const ownsActiveMediaSession = (session?: Session): boolean => {
};
const presenceStartCleanupByClient = new WeakMap<MatrixClient, () => void>();
const SLIDING_SYNC_POLL_TIMEOUT_MS = 45000;
const SLIDING_SYNC_POLL_TIMEOUT_MOBILE_MS = 30000;

/** Shorter poll on mobile: a wedged long-poll costs more when the OS freezes the webview. */
export const resolvePollTimeoutMs = (configured?: number): number =>
configured ??
(isMobileTauri() ? SLIDING_SYNC_POLL_TIMEOUT_MOBILE_MS : SLIDING_SYNC_POLL_TIMEOUT_MS);

const isInitialSyncReady = (state: string | null): boolean =>
state === SyncState.Prepared || state === SyncState.Syncing || state === SyncState.Catchup;
Expand Down Expand Up @@ -146,7 +153,15 @@ function installSlidingSyncRequestPatch(mx: MatrixClient, manager: SlidingSyncMa

const mxWritable = mx as MatrixClientWithWritableSlidingSync;
const original = mx.slidingSync.bind(mx) as SlidingSyncMethod;
mxWritable.slidingSync = (reqBody, baseUrl, abortSignal) => {
mxWritable.slidingSync = async (reqBody, baseUrl, abortSignal) => {
// AbortError makes the SDK loop `continue` and reissue at the same `pos`, no sleep.
if (manager.isPaused()) {
await manager.waitForResume();
const aborted = new Error('Sliding sync paused while backgrounded');
aborted.name = 'AbortError';
throw aborted;
}

const req = reqBody as SlidingSyncRequestWithConnId;
if (req.conn_id === undefined) {
req.conn_id = SLIDING_SYNC_CONN_ID;
Expand Down Expand Up @@ -437,7 +452,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig):
startPresenceAfterInitialSync(mx, presenceManager);

manager = new SlidingSyncManager(mx, baseUrl, {
pollTimeoutMs: config?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS,
pollTimeoutMs: resolvePollTimeoutMs(config?.pollTimeoutMs),
timelineLimit: config?.timelineLimit,
initialRoomIds: config?.initialRoomIds,
});
Expand Down
Loading
Loading