feat(editor): local-first persistence + robust refocus re-sync
Browse filesPrevents 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 +21 -1
- frontend/package.json +1 -0
- frontend/src/editor/Editor.tsx +107 -0
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;
|