fix(editor): SyncIndicator now reflects hero/settings/citation edits
Browse filesThe "Saving..." / "Saved" badge in the topbar used to listen only to
TipTap's editor.on('update') event. That event fires solely for
prosemirror document changes - i.e. when the article BODY is edited.
The hero (title/subtitle/authors/links/...), the per-article settings,
citations, embeds and the comment store all live in sibling Y.Maps on
the same Yjs document. Edits there don't round-trip through TipTap, so
they never triggered the indicator. Net effect for users: editing the
hero felt like a no-op - no "Saving..." feedback - and the natural
conclusion was "this isn't being saved", even though the backend's
Hocuspocus onChange handler was happily persisting the full ydoc on
every Yjs update.
Subscribe one layer lower, on ydoc.on('update', ...). That callback
fires for any Y.Map / Y.Array / Y.XmlFragment change in the document,
which exactly mirrors what the backend persists. The indicator now
flashes "Saving..." for every kind of edit and settles back to
"Saved" once the write debounce window closes.
Implementation notes:
- The Y.Doc is the same instance the editor passed to
HocuspocusProvider, accessible via provider.document. We resolve
it inside the existing provider-attach block so we tear listeners
down cleanly when the provider goes away.
- The update callback ignores events whose origin is the provider
itself. Hocuspocus emits Yjs updates with the provider as origin
when it APPLIES remote changes (i.e. someone else just edited),
and those are not saves we need to advertise - they're already
saved by their author. Without this filter, the indicator would
blink "Saving..." constantly while collaborating.
- editorInstance prop is kept for backwards compatibility (still
received by TopBar.tsx) but no longer used here; renamed to
_editorInstance to silence the unused-arg lint.
Co-authored-by: Cursor <cursoragent@cursor.com>
|
@@ -13,10 +13,23 @@ interface Props {
|
|
| 13 |
|
| 14 |
/**
|
| 15 |
* Tiny connection/saving indicator for the editor top bar. Listens to the
|
| 16 |
-
* Hocuspocus provider (`connect` / `disconnect` / `synced`)
|
| 17 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
*/
|
| 19 |
-
export function SyncIndicator({ editorInstance, providerRef }: Props) {
|
| 20 |
const [status, setStatus] = useState<SyncStatus>("disconnected");
|
| 21 |
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 22 |
|
|
@@ -56,10 +69,34 @@ export function SyncIndicator({ editorInstance, providerRef }: Props) {
|
|
| 56 |
// and reconcile. Cheap and self-correcting.
|
| 57 |
const reconcileId = setInterval(seedStatus, 1000);
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
return () => {
|
| 60 |
p.off("connect", onConnect);
|
| 61 |
p.off("disconnect", onDisconnect);
|
| 62 |
p.off("synced", onSynced);
|
|
|
|
| 63 |
clearInterval(reconcileId);
|
| 64 |
};
|
| 65 |
};
|
|
@@ -87,22 +124,6 @@ export function SyncIndicator({ editorInstance, providerRef }: Props) {
|
|
| 87 |
};
|
| 88 |
}, [providerRef]);
|
| 89 |
|
| 90 |
-
useEffect(() => {
|
| 91 |
-
const editor = editorInstance;
|
| 92 |
-
if (!editor) return;
|
| 93 |
-
const onUpdate = () => {
|
| 94 |
-
setStatus("syncing");
|
| 95 |
-
if (timerRef.current) clearTimeout(timerRef.current);
|
| 96 |
-
timerRef.current = setTimeout(() => {
|
| 97 |
-
const p = providerRef.current;
|
| 98 |
-
const wsProvider = (p as any)?.configuration?.websocketProvider;
|
| 99 |
-
setStatus(wsProvider?.status === "connected" ? "saved" : "disconnected");
|
| 100 |
-
}, 1500);
|
| 101 |
-
};
|
| 102 |
-
editor.on("update", onUpdate);
|
| 103 |
-
return () => { editor.off("update", onUpdate); };
|
| 104 |
-
}, [editorInstance, providerRef]);
|
| 105 |
-
|
| 106 |
const label =
|
| 107 |
status === "saved" ? "Saved" :
|
| 108 |
status === "syncing" ? "Saving..." :
|
|
|
|
| 13 |
|
| 14 |
/**
|
| 15 |
* Tiny connection/saving indicator for the editor top bar. Listens to the
|
| 16 |
+
* Hocuspocus provider (`connect` / `disconnect` / `synced`) for connection
|
| 17 |
+
* status, and to the underlying Yjs document (`update`) for change
|
| 18 |
+
* activity.
|
| 19 |
+
*
|
| 20 |
+
* Why ydoc.on('update') and not editor.on('update'):
|
| 21 |
+
* TipTap's `update` event only fires for changes to the prosemirror
|
| 22 |
+
* document (the article body). The hero (title/subtitle/authors/...)
|
| 23 |
+
* and the article settings live in sibling Y.Maps on the same ydoc,
|
| 24 |
+
* and edits there go straight to Yjs without round-tripping through
|
| 25 |
+
* TipTap. Listening on the prosemirror editor would therefore miss
|
| 26 |
+
* every hero / settings / citation / embed change and make the user
|
| 27 |
+
* believe nothing is being saved - even though the backend's
|
| 28 |
+
* Hocuspocus `onChange` handler is happily persisting the full ydoc
|
| 29 |
+
* on every Yjs update. Subscribing at the Yjs layer fixes that: we
|
| 30 |
+
* see every change the backend sees, in the same order it sees them.
|
| 31 |
*/
|
| 32 |
+
export function SyncIndicator({ editorInstance: _editorInstance, providerRef }: Props) {
|
| 33 |
const [status, setStatus] = useState<SyncStatus>("disconnected");
|
| 34 |
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 35 |
|
|
|
|
| 69 |
// and reconcile. Cheap and self-correcting.
|
| 70 |
const reconcileId = setInterval(seedStatus, 1000);
|
| 71 |
|
| 72 |
+
// Listen at the Yjs layer for change activity. Hocuspocus
|
| 73 |
+
// stores the ydoc on `provider.document` (it's the same Y.Doc
|
| 74 |
+
// we passed in when constructing the provider). Filtering out
|
| 75 |
+
// updates whose origin is the provider itself avoids flashing
|
| 76 |
+
// "Saving..." every time we just RECEIVED collab updates from
|
| 77 |
+
// someone else - those don't need to be saved by us, they were
|
| 78 |
+
// saved by their author.
|
| 79 |
+
const ydoc = (p as any).document as
|
| 80 |
+
| { on: Function; off: Function }
|
| 81 |
+
| undefined;
|
| 82 |
+
const onYUpdate = (_update: Uint8Array, origin: unknown) => {
|
| 83 |
+
if (origin === p) return;
|
| 84 |
+
setStatus("syncing");
|
| 85 |
+
if (timerRef.current) clearTimeout(timerRef.current);
|
| 86 |
+
timerRef.current = setTimeout(() => {
|
| 87 |
+
const wsProvider = (p as any).configuration?.websocketProvider;
|
| 88 |
+
setStatus(
|
| 89 |
+
wsProvider?.status === "connected" ? "saved" : "disconnected",
|
| 90 |
+
);
|
| 91 |
+
}, 1500);
|
| 92 |
+
};
|
| 93 |
+
ydoc?.on?.("update", onYUpdate);
|
| 94 |
+
|
| 95 |
return () => {
|
| 96 |
p.off("connect", onConnect);
|
| 97 |
p.off("disconnect", onDisconnect);
|
| 98 |
p.off("synced", onSynced);
|
| 99 |
+
ydoc?.off?.("update", onYUpdate);
|
| 100 |
clearInterval(reconcileId);
|
| 101 |
};
|
| 102 |
};
|
|
|
|
| 124 |
};
|
| 125 |
}, [providerRef]);
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
const label =
|
| 128 |
status === "saved" ? "Saved" :
|
| 129 |
status === "syncing" ? "Saving..." :
|