client: nudge sync on tab/PWA resume (retryImmediately + one stop/start fallback) #9

Open
void wants to merge 1 commit from void/resume-sync into ratatoskr/estate-navigator
3 changed files with 225 additions and 2 deletions

View file

@ -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) => {

View 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
View 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);
};
};