carbon-tokenization / frontend /src /editor /refocus-sync.ts
tfrere's picture
tfrere HF Staff
test(editor): extract refocus re-sync into a unit-tested module
45d6656
/**
* 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<unknown>;
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<typeof setTimeout> | null = null;
let probeTimer: ReturnType<typeof setTimeout> | 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 };
}