tfrere HF Staff Cursor commited on
Commit
fe9b487
·
1 Parent(s): 55f5851

feat(editor): local-first persistence + robust refocus re-sync

Browse files

Prevents a stale never-closed tab from clobbering a collaborator's
newer content (the core data-loss incident):

- Mesh y-indexeddb onto the shared Y.Doc so edits survive refresh /
offline, and a client can re-sync its durable local copy back up if
the server ever loses or resurrects a stale document.
- On tab refocus, force a sync round-trip and hold the editor
read-only until it completes, so the user can't type onto a
visibly-stale buffer. Detect dead sockets via websocket status,
readyState and last-message timestamp (incl. half-open sockets via
a post-forceSync probe). Always release after a 4s grace window so
offline editing is never trapped (local-first).

Co-authored-by: Cursor <cursoragent@cursor.com>

frontend/package-lock.json CHANGED
@@ -38,6 +38,7 @@
38
  "remark-gfm": "^4.0.1",
39
  "shiki": "^4.0.2",
40
  "tippy.js": "^6.3.7",
 
41
  "yjs": "^13.6.0",
42
  "zod": "^4.3.6"
43
  },
@@ -1626,7 +1627,6 @@
1626
  "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.3.tgz",
1627
  "integrity": "sha512-RiQtEjDAPrHpdo6sw6b7fOw/PijqgFIsozKKkGcSeBgWHQuFg7q9OxJTj+l0e60rVwSu/5gmKEEobzM9bX+t2Q==",
1628
  "license": "MIT",
1629
- "peer": true,
1630
  "funding": {
1631
  "type": "github",
1632
  "url": "https://github.com/sponsors/ueberdosis"
@@ -6348,6 +6348,26 @@
6348
  "node": ">=8"
6349
  }
6350
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6351
  "node_modules/y-protocols": {
6352
  "version": "1.0.7",
6353
  "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
 
38
  "remark-gfm": "^4.0.1",
39
  "shiki": "^4.0.2",
40
  "tippy.js": "^6.3.7",
41
+ "y-indexeddb": "^9.0.12",
42
  "yjs": "^13.6.0",
43
  "zod": "^4.3.6"
44
  },
 
1627
  "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.3.tgz",
1628
  "integrity": "sha512-RiQtEjDAPrHpdo6sw6b7fOw/PijqgFIsozKKkGcSeBgWHQuFg7q9OxJTj+l0e60rVwSu/5gmKEEobzM9bX+t2Q==",
1629
  "license": "MIT",
 
1630
  "funding": {
1631
  "type": "github",
1632
  "url": "https://github.com/sponsors/ueberdosis"
 
6348
  "node": ">=8"
6349
  }
6350
  },
6351
+ "node_modules/y-indexeddb": {
6352
+ "version": "9.0.12",
6353
+ "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz",
6354
+ "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==",
6355
+ "license": "MIT",
6356
+ "dependencies": {
6357
+ "lib0": "^0.2.74"
6358
+ },
6359
+ "engines": {
6360
+ "node": ">=16.0.0",
6361
+ "npm": ">=8.0.0"
6362
+ },
6363
+ "funding": {
6364
+ "type": "GitHub Sponsors ❤",
6365
+ "url": "https://github.com/sponsors/dmonad"
6366
+ },
6367
+ "peerDependencies": {
6368
+ "yjs": "^13.0.0"
6369
+ }
6370
+ },
6371
  "node_modules/y-protocols": {
6372
  "version": "1.0.7",
6373
  "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
frontend/package.json CHANGED
@@ -42,6 +42,7 @@
42
  "remark-gfm": "^4.0.1",
43
  "shiki": "^4.0.2",
44
  "tippy.js": "^6.3.7",
 
45
  "yjs": "^13.6.0",
46
  "zod": "^4.3.6"
47
  },
 
42
  "remark-gfm": "^4.0.1",
43
  "shiki": "^4.0.2",
44
  "tippy.js": "^6.3.7",
45
+ "y-indexeddb": "^9.0.12",
46
  "yjs": "^13.6.0",
47
  "zod": "^4.3.6"
48
  },
frontend/src/editor/Editor.tsx CHANGED
@@ -11,6 +11,7 @@ import { CodeBlockShiki } from "./extensions/code-block-shiki";
11
  import * as Y from "yjs";
12
  import { UndoManager } from "yjs";
13
  import { HocuspocusProvider } from "@hocuspocus/provider";
 
14
  import { useEffect, useMemo, useRef, useState, MutableRefObject } from "react";
15
  import { BubbleToolbar } from "./BubbleToolbar";
16
  import { BlockHandle } from "./BlockHandle";
@@ -77,6 +78,16 @@ export function Editor({
77
  const undoManagerCallbackRef = useRef(onUndoManagerReady);
78
  undoManagerCallbackRef.current = onUndoManagerReady;
79
 
 
 
 
 
 
 
 
 
 
 
80
  const providerRef = useRef<HocuspocusProvider | null>(null);
81
  if (!providerRef.current) {
82
  const wsUrl =
@@ -132,6 +143,8 @@ export function Editor({
132
  return () => {
133
  providerRef.current?.destroy();
134
  providerRef.current = null;
 
 
135
  ydocRef.current?.destroy();
136
  ydocRef.current = null;
137
  };
@@ -351,6 +364,100 @@ export function Editor({
351
  onEditorReady(editor);
352
  }, [editor, editorRef, onEditorReady]);
353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  const [containerEl, setContainerEl] = useState<HTMLElement | null>(null);
355
 
356
  if (!editor) return null;
 
11
  import * as Y from "yjs";
12
  import { UndoManager } from "yjs";
13
  import { HocuspocusProvider } from "@hocuspocus/provider";
14
+ import { IndexeddbPersistence } from "y-indexeddb";
15
  import { useEffect, useMemo, useRef, useState, MutableRefObject } from "react";
16
  import { BubbleToolbar } from "./BubbleToolbar";
17
  import { BlockHandle } from "./BlockHandle";
 
78
  const undoManagerCallbackRef = useRef(onUndoManagerReady);
79
  undoManagerCallbackRef.current = onUndoManagerReady;
80
 
81
+ // Local-first persistence layer. Mesh an IndexedDB provider onto the
82
+ // same Y.Doc as the network provider: edits survive a refresh / closed
83
+ // tab / offline window, and - critically - if the server ever loses or
84
+ // resurrects a stale document, this client re-syncs its locally-cached
85
+ // state back up. Yjs providers are meshable and dedupe automatically.
86
+ const idbRef = useRef<IndexeddbPersistence | null>(null);
87
+ if (!idbRef.current) {
88
+ idbRef.current = new IndexeddbPersistence(`collab-editor:${docName}`, ydoc);
89
+ }
90
+
91
  const providerRef = useRef<HocuspocusProvider | null>(null);
92
  if (!providerRef.current) {
93
  const wsUrl =
 
143
  return () => {
144
  providerRef.current?.destroy();
145
  providerRef.current = null;
146
+ idbRef.current?.destroy();
147
+ idbRef.current = null;
148
  ydocRef.current?.destroy();
149
  ydocRef.current = null;
150
  };
 
364
  onEditorReady(editor);
365
  }, [editor, editorRef, onEditorReady]);
366
 
367
+ // Re-sync on tab refocus, and hold the editor read-only until the
368
+ // round-trip completes. A long-stale never-closed tab must hear the
369
+ // latest server state BEFORE the user types on top of it, otherwise
370
+ // its old buffer can clobber a collaborator's newer content. Local-
371
+ // first safety net: if the sync can't complete (offline / Space
372
+ // asleep), we re-enable editing after a short grace period - the
373
+ // y-indexeddb copy is durable and merges on the next reconnect, so we
374
+ // never trap the user in a permanent read-only state.
375
+ useEffect(() => {
376
+ if (!editor) return;
377
+
378
+ let graceTimer: ReturnType<typeof setTimeout> | null = null;
379
+ let probeTimer: ReturnType<typeof setTimeout> | null = null;
380
+
381
+ const clearTimers = () => {
382
+ if (graceTimer) clearTimeout(graceTimer);
383
+ if (probeTimer) clearTimeout(probeTimer);
384
+ graceTimer = null;
385
+ probeTimer = null;
386
+ };
387
+
388
+ const unlock = () => {
389
+ clearTimers();
390
+ if (!editor.isDestroyed && !editor.isEditable) editor.setEditable(true);
391
+ };
392
+
393
+ const lock = () => {
394
+ if (!editor.isDestroyed && editor.isEditable) editor.setEditable(false);
395
+ if (graceTimer) clearTimeout(graceTimer);
396
+ // Never trap the user: offline edits stay valid (y-indexeddb) and
397
+ // merge on the next reconnect, so always release after a grace window.
398
+ graceTimer = setTimeout(unlock, 4000);
399
+ };
400
+
401
+ const resync = () => {
402
+ provider.connect().catch(() => {});
403
+ provider.forceSync();
404
+ };
405
+
406
+ const onSynced = () => unlock();
407
+ provider.on("synced", onSynced);
408
+
409
+ // The underlying websocket carries the only trustworthy liveness
410
+ // signals: socket-level status, the raw readyState, and the
411
+ // timestamp of the last server message.
412
+ const socket = () =>
413
+ provider.configuration?.websocketProvider as
414
+ | { status?: string; webSocket?: { readyState?: number } | null; lastMessageReceived?: number }
415
+ | undefined;
416
+
417
+ // "Obviously dead" cases we can detect synchronously, without a probe.
418
+ const looksDead = () => {
419
+ const ws = socket();
420
+ if (!ws) return !provider.synced;
421
+ if (ws.status !== "connected") return true;
422
+ if (ws.webSocket && ws.webSocket.readyState !== WebSocket.OPEN) return true;
423
+ return false;
424
+ };
425
+
426
+ const onVisibility = () => {
427
+ if (document.visibilityState !== "visible") return;
428
+
429
+ const before = socket()?.lastMessageReceived ?? 0;
430
+ resync();
431
+
432
+ // Synchronously-detectable stale state -> lock immediately.
433
+ if (looksDead() || !provider.synced) {
434
+ lock();
435
+ return;
436
+ }
437
+
438
+ // Socket *claims* to be alive (connected + OPEN) but a backgrounded
439
+ // tab can hold a half-dead socket that never fired a close event.
440
+ // Our forceSync() must draw a reply; if no new message lands within
441
+ // the probe window, treat it as stale and force a hard reconnect.
442
+ if (probeTimer) clearTimeout(probeTimer);
443
+ probeTimer = setTimeout(() => {
444
+ const advanced = (socket()?.lastMessageReceived ?? 0) > before;
445
+ if (!advanced) {
446
+ resync();
447
+ lock();
448
+ }
449
+ }, 2000);
450
+ };
451
+
452
+ document.addEventListener("visibilitychange", onVisibility);
453
+ return () => {
454
+ document.removeEventListener("visibilitychange", onVisibility);
455
+ provider.off("synced", onSynced);
456
+ clearTimers();
457
+ unlock();
458
+ };
459
+ }, [editor, provider]);
460
+
461
  const [containerEl, setContainerEl] = useState<HTMLElement | null>(null);
462
 
463
  if (!editor) return null;