Spaces:
Sleeping
Sleeping
| /** | |
| * 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 }; | |
| } | |