/** * Refocus re-sync guard. * * A long-stale never-closed tab must hear the latest server state BEFORE * the user types on top of it - otherwise its old buffer can clobber a * collaborator's newer content (the data-loss incident this guards * against). On tab refocus we force a sync round-trip and hold the editor * read-only until it completes, detecting dead sockets via websocket * status / readyState / last-message timestamp (including half-open * sockets that never fired a close event, via a post-forceSync probe). * * Local-first safety net: if the sync can't complete (offline / Space * asleep) editing is always released after a grace window - the * y-indexeddb copy is durable and merges on the next reconnect, so the * user is never trapped in a permanent read-only state. * * The DOM concern (the `visibilitychange` listener + `visibilityState` * gate) stays in the React effect; this module is pure logic so it can * be unit-tested with fake timers and no browser. */ // WebSocket.OPEN is always 1 per the WHATWG spec. We hardcode it instead // of referencing the `WebSocket` DOM global so this module runs in a // plain Node test environment. const WS_OPEN = 1; export interface RefocusSocketLike { status?: string; webSocket?: { readyState?: number } | null; lastMessageReceived?: number; } export interface RefocusProviderLike { synced: boolean; connect: () => Promise; forceSync: () => void; on: (event: string, callback: (...args: any[]) => void) => unknown; off: (event: string, callback: (...args: any[]) => void) => unknown; configuration?: { websocketProvider?: RefocusSocketLike }; } export interface RefocusEditorLike { isEditable: boolean; isDestroyed: boolean; setEditable: (editable: boolean) => unknown; } export interface RefocusSyncOptions { provider: RefocusProviderLike; editor: RefocusEditorLike; /** How long to stay read-only before releasing when offline. */ graceMs?: number; /** How long to wait for a forceSync reply before declaring a half-open socket. */ probeMs?: number; } export interface RefocusSync { /** Call when the tab becomes visible again. */ onVisibility: () => void; /** Detach listeners, clear timers, and release any read-only lock. */ dispose: () => void; } export function createRefocusSync({ provider, editor, graceMs = 4000, probeMs = 2000, }: RefocusSyncOptions): RefocusSync { let graceTimer: ReturnType | null = null; let probeTimer: ReturnType | null = null; const clearTimers = () => { if (graceTimer) clearTimeout(graceTimer); if (probeTimer) clearTimeout(probeTimer); graceTimer = null; probeTimer = null; }; const unlock = () => { clearTimers(); if (!editor.isDestroyed && !editor.isEditable) editor.setEditable(true); }; const lock = () => { if (!editor.isDestroyed && editor.isEditable) editor.setEditable(false); if (graceTimer) clearTimeout(graceTimer); // Never trap the user: offline edits stay valid (y-indexeddb) and // merge on the next reconnect, so always release after the grace window. graceTimer = setTimeout(unlock, graceMs); }; const resync = () => { provider.connect().catch(() => {}); provider.forceSync(); }; const socket = () => provider.configuration?.websocketProvider; // "Obviously dead" cases we can detect synchronously, without a probe. const looksDead = () => { const ws = socket(); if (!ws) return !provider.synced; if (ws.status !== "connected") return true; if (ws.webSocket && ws.webSocket.readyState !== WS_OPEN) return true; return false; }; const onSynced = () => unlock(); provider.on("synced", onSynced); const onVisibility = () => { const before = socket()?.lastMessageReceived ?? 0; resync(); // Synchronously-detectable stale state -> lock immediately. if (looksDead() || !provider.synced) { lock(); return; } // Socket *claims* to be alive (connected + OPEN) but a backgrounded // tab can hold a half-dead socket that never fired a close event. // Our forceSync() must draw a reply; if no new message lands within // the probe window, treat it as stale and force a hard reconnect. if (probeTimer) clearTimeout(probeTimer); probeTimer = setTimeout(() => { const advanced = (socket()?.lastMessageReceived ?? 0) > before; if (!advanced) { resync(); lock(); } }, probeMs); }; const dispose = () => { provider.off("synced", onSynced); clearTimers(); unlock(); }; return { onVisibility, dispose }; }