client: nudge sync on tab/PWA resume (retryImmediately + one stop/start fallback) #9
3 changed files with 225 additions and 2 deletions
|
|
@ -3,6 +3,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
|
|||
import { cryptoCallbacks } from './secretStorageKeys';
|
||||
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
|
||||
import { pushSessionToSW } from '../sw-session';
|
||||
import { startResumeSync } from './resumeSync';
|
||||
|
||||
type Session = {
|
||||
baseUrl: string;
|
||||
|
|
@ -40,10 +41,14 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
|
|||
return mx;
|
||||
};
|
||||
|
||||
export const startClient = async (mx: MatrixClient) => {
|
||||
await mx.startClient({
|
||||
const startSync = (mx: MatrixClient) =>
|
||||
mx.startClient({
|
||||
lazyLoadMembers: true,
|
||||
});
|
||||
|
||||
export const startClient = async (mx: MatrixClient) => {
|
||||
await startSync(mx);
|
||||
startResumeSync(mx, { restart: startSync });
|
||||
};
|
||||
|
||||
export const clearCacheAndReload = async (mx: MatrixClient) => {
|
||||
|
|
|
|||
132
src/client/resumeSync.test.ts
Normal file
132
src/client/resumeSync.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { HEALTH_CHECK_MS, MIN_HIDDEN_MS, RESUME_DEBOUNCE_MS, startResumeSync } from './resumeSync';
|
||||
|
||||
class Target extends EventTarget {
|
||||
visibilityState: 'visible' | 'hidden' = 'visible';
|
||||
}
|
||||
|
||||
const setup = (state: string | null = 'SYNCING') => {
|
||||
const doc = new Target();
|
||||
const win = new Target();
|
||||
const mx = {
|
||||
state,
|
||||
getSyncState: () => mx.state,
|
||||
retryImmediately: vi.fn(() => true),
|
||||
stopClient: vi.fn(),
|
||||
};
|
||||
const restart = vi.fn(async () => {});
|
||||
const stop = startResumeSync(mx, {
|
||||
restart,
|
||||
doc: doc as never,
|
||||
win: win as never,
|
||||
});
|
||||
const hide = () => {
|
||||
doc.visibilityState = 'hidden';
|
||||
doc.dispatchEvent(new Event('visibilitychange'));
|
||||
};
|
||||
const show = () => {
|
||||
doc.visibilityState = 'visible';
|
||||
doc.dispatchEvent(new Event('visibilitychange'));
|
||||
};
|
||||
return { doc, win, mx, restart, stop, hide, show };
|
||||
};
|
||||
|
||||
const settle = () => vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS);
|
||||
|
||||
describe('startResumeSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('nudges the poll when the tab returns after a real absence', async () => {
|
||||
const t = setup();
|
||||
t.hide();
|
||||
await vi.advanceTimersByTimeAsync(MIN_HIDDEN_MS);
|
||||
t.show();
|
||||
expect(t.mx.retryImmediately).not.toHaveBeenCalled();
|
||||
await settle();
|
||||
expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores a hide shorter than the minimum', async () => {
|
||||
const t = setup();
|
||||
t.hide();
|
||||
await vi.advanceTimersByTimeAsync(MIN_HIDDEN_MS - 1);
|
||||
t.show();
|
||||
await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS);
|
||||
expect(t.mx.retryImmediately).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles pageshow only when restored from bfcache, and online always', async () => {
|
||||
const t = setup();
|
||||
t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: false }));
|
||||
await settle();
|
||||
expect(t.mx.retryImmediately).not.toHaveBeenCalled();
|
||||
t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true }));
|
||||
await settle();
|
||||
expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS);
|
||||
t.win.dispatchEvent(new Event('online'));
|
||||
await settle();
|
||||
expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('debounces a burst of triggers into one nudge', async () => {
|
||||
const t = setup();
|
||||
t.win.dispatchEvent(new Event('online'));
|
||||
t.win.dispatchEvent(new Event('online'));
|
||||
t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true }));
|
||||
await settle();
|
||||
expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not restart when sync is healthy after the check window', async () => {
|
||||
for (const state of ['PREPARED', 'SYNCING', 'CATCHUP']) {
|
||||
const t = setup(state);
|
||||
t.win.dispatchEvent(new Event('online'));
|
||||
await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS);
|
||||
expect(t.restart).not.toHaveBeenCalled();
|
||||
t.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('does one stop/start when sync is still unhealthy, never overlapping', async () => {
|
||||
const t = setup('RECONNECTING');
|
||||
let release!: () => void;
|
||||
t.restart.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((r) => {
|
||||
release = r;
|
||||
})
|
||||
);
|
||||
t.win.dispatchEvent(new Event('online'));
|
||||
await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS);
|
||||
expect(t.mx.stopClient).toHaveBeenCalledTimes(1);
|
||||
expect(t.restart).toHaveBeenCalledTimes(1);
|
||||
// a trigger while the restart is in flight does nothing
|
||||
t.win.dispatchEvent(new Event('online'));
|
||||
await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS);
|
||||
expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1);
|
||||
expect(t.restart).toHaveBeenCalledTimes(1);
|
||||
release();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
t.win.dispatchEvent(new Event('online'));
|
||||
await settle();
|
||||
expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('removes listeners and cancels pending work on dispose', async () => {
|
||||
const t = setup('ERROR');
|
||||
t.win.dispatchEvent(new Event('online'));
|
||||
await settle();
|
||||
t.stop();
|
||||
await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS);
|
||||
expect(t.restart).not.toHaveBeenCalled();
|
||||
t.win.dispatchEvent(new Event('online'));
|
||||
await settle();
|
||||
expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
86
src/client/resumeSync.ts
Normal file
86
src/client/resumeSync.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/** The slice of MatrixClient this module drives; a MatrixClient satisfies it. */
|
||||
export type SyncHost = {
|
||||
getSyncState(): string | null;
|
||||
retryImmediately(): boolean;
|
||||
stopClient(): void;
|
||||
};
|
||||
|
||||
// matrix-js-sdk SyncState values for a live sync loop.
|
||||
const HEALTHY: ReadonlySet<string | null> = new Set(['PREPARED', 'SYNCING', 'CATCHUP']);
|
||||
|
||||
export const RESUME_DEBOUNCE_MS = 250;
|
||||
export const MIN_HIDDEN_MS = 5000;
|
||||
export const HEALTH_CHECK_MS = 3000;
|
||||
|
||||
type ResumeSyncOptions<Host> = {
|
||||
/** Event sources and visibility; default to the browser globals. */
|
||||
doc?: Pick<Document, 'addEventListener' | 'removeEventListener' | 'visibilityState'>;
|
||||
win?: Pick<Window, 'addEventListener' | 'removeEventListener'>;
|
||||
/** Restart the client after stopClient; must resolve once startClient has been issued. */
|
||||
restart: (mx: Host) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A backgrounded tab or PWA leaves the sync long-poll dead until its 30 s timeout.
|
||||
* On return (visible after a real absence, pageshow from bfcache, online) nudge the
|
||||
* poll with retryImmediately; if sync is still unhealthy after HEALTH_CHECK_MS, do one
|
||||
* stop/start. Returns a disposer that removes every listener and timer.
|
||||
*/
|
||||
export const startResumeSync = <Host extends SyncHost>(
|
||||
mx: Host,
|
||||
{ restart, doc = document, win = window }: ResumeSyncOptions<Host>
|
||||
): (() => void) => {
|
||||
let hiddenAt: number | undefined;
|
||||
let debounce: ReturnType<typeof setTimeout> | undefined;
|
||||
let healthCheck: ReturnType<typeof setTimeout> | undefined;
|
||||
let restarting = false;
|
||||
let disposed = false;
|
||||
|
||||
const check = async () => {
|
||||
healthCheck = undefined;
|
||||
if (disposed || restarting || HEALTHY.has(mx.getSyncState())) return;
|
||||
restarting = true;
|
||||
try {
|
||||
mx.stopClient();
|
||||
await restart(mx);
|
||||
} finally {
|
||||
restarting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resume = () => {
|
||||
if (debounce !== undefined) clearTimeout(debounce);
|
||||
debounce = setTimeout(() => {
|
||||
debounce = undefined;
|
||||
if (disposed || restarting || healthCheck !== undefined) return;
|
||||
mx.retryImmediately();
|
||||
healthCheck = setTimeout(check, HEALTH_CHECK_MS);
|
||||
}, RESUME_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const onVisibility = () => {
|
||||
if (doc.visibilityState === 'hidden') {
|
||||
hiddenAt = Date.now();
|
||||
return;
|
||||
}
|
||||
const away = hiddenAt === undefined ? Infinity : Date.now() - hiddenAt;
|
||||
hiddenAt = undefined;
|
||||
if (away >= MIN_HIDDEN_MS) resume();
|
||||
};
|
||||
const onPageShow = (e: Event) => {
|
||||
if ((e as PageTransitionEvent).persisted) resume();
|
||||
};
|
||||
|
||||
doc.addEventListener('visibilitychange', onVisibility);
|
||||
win.addEventListener('pageshow', onPageShow);
|
||||
win.addEventListener('online', resume);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (debounce !== undefined) clearTimeout(debounce);
|
||||
if (healthCheck !== undefined) clearTimeout(healthCheck);
|
||||
doc.removeEventListener('visibilitychange', onVisibility);
|
||||
win.removeEventListener('pageshow', onPageShow);
|
||||
win.removeEventListener('online', resume);
|
||||
};
|
||||
};
|
||||
Loading…
Reference in a new issue