tfrere HF Staff Cursor commited on
Commit
adb1aa1
·
1 Parent(s): 9fba033

fix(editor): SyncIndicator now reflects hero/settings/citation edits

Browse files

The "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>

frontend/src/components/SyncIndicator.tsx CHANGED
@@ -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`) and the TipTap
17
- * editor (`update`) to flip between "Saved", "Saving..." and "Offline".
 
 
 
 
 
 
 
 
 
 
 
 
 
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..." :