tfrere HF Staff commited on
Commit
1c21ac9
·
1 Parent(s): f2312e5

fix: prevent Y.Doc content duplication from seed race condition

Browse files

The editor was seeding default content on an 800ms timer that could
fire before the Hocuspocus sync completed. When the server had existing
content from the HF dataset, Yjs would merge both copies (different
client IDs), causing all content, authors, and affiliations to appear
multiple times.

Fix: only seed after the "synced" event, and check the Y.Doc fragment
directly instead of ProseMirror state. Also adds /api/admin/reset-document
endpoint to clear corrupted Y.Doc state.

Made-with: Cursor

backend/src/server.ts CHANGED
@@ -277,6 +277,39 @@ app.post("/api/publish", async (req, res) => {
277
  }
278
  });
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  // ---------- Image Upload ----------
281
 
282
  const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
 
277
  }
278
  });
279
 
280
+ // ---------- Reset Document ----------
281
+
282
+ app.post("/api/admin/reset-document", async (req, res) => {
283
+ if (oauthEnabled) {
284
+ const token = extractToken(req.headers.cookie);
285
+ const user = await resolveUser(token);
286
+ if (!user || !user.canEdit) {
287
+ res.status(403).json({ error: "Unauthorized" });
288
+ return;
289
+ }
290
+ if (token) setUserToken(token);
291
+ }
292
+
293
+ try {
294
+ // Delete the local .yjs file so the next connection starts fresh
295
+ const p = docPath(DEFAULT_DOC_NAME);
296
+ if (existsSync(p)) {
297
+ const { unlinkSync } = await import("fs");
298
+ unlinkSync(p);
299
+ console.log("[admin] deleted local Y.Doc file:", p);
300
+ }
301
+
302
+ // Close existing connections to force reload
303
+ await hocuspocus.closeConnections(DEFAULT_DOC_NAME);
304
+ console.log("[admin] closed connections for", DEFAULT_DOC_NAME);
305
+
306
+ res.json({ success: true, message: "Document reset. Refresh the editor to start fresh." });
307
+ } catch (err: any) {
308
+ console.error("[admin] reset error:", err);
309
+ res.status(500).json({ error: err.message || "Reset failed" });
310
+ }
311
+ });
312
+
313
  // ---------- Image Upload ----------
314
 
315
  const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
frontend/src/editor/Editor.tsx CHANGED
@@ -208,7 +208,9 @@ export function Editor({
208
 
209
  const seed = () => {
210
  if (seededRef.current) return;
211
- if (editor.state.doc.textContent.trim().length === 0) {
 
 
212
  seededRef.current = true;
213
  editor.commands.setContent(DEFAULT_CONTENT);
214
  seedFrontmatter(frontmatterStore);
@@ -218,19 +220,23 @@ export function Editor({
218
  if (!settingsMap.has("citationStyle")) {
219
  settingsMap.set("citationStyle", "apa");
220
  }
 
 
221
  }
222
  };
223
 
224
- const timer = setTimeout(seed, 800);
225
-
226
- const onSync = () => setTimeout(seed, 100);
 
 
 
227
  provider.on("synced", onSync);
228
 
229
  return () => {
230
  provider.off("synced", onSync);
231
- clearTimeout(timer);
232
  };
233
- }, [editor, provider, frontmatterStore]);
234
 
235
  useEffect(() => {
236
  if (!editor) return;
 
208
 
209
  const seed = () => {
210
  if (seededRef.current) return;
211
+ // Check the Y.Doc fragment directly (more reliable than ProseMirror state timing)
212
+ const fragment = ydoc.getXmlFragment("default");
213
+ if (fragment.length === 0) {
214
  seededRef.current = true;
215
  editor.commands.setContent(DEFAULT_CONTENT);
216
  seedFrontmatter(frontmatterStore);
 
220
  if (!settingsMap.has("citationStyle")) {
221
  settingsMap.set("citationStyle", "apa");
222
  }
223
+ } else {
224
+ seededRef.current = true;
225
  }
226
  };
227
 
228
+ // Only seed AFTER server sync to avoid Yjs merge duplication.
229
+ // If we seed before sync, the server's existing state gets merged
230
+ // with our seeded content, creating duplicates.
231
+ const onSync = () => {
232
+ setTimeout(seed, 100);
233
+ };
234
  provider.on("synced", onSync);
235
 
236
  return () => {
237
  provider.off("synced", onSync);
 
238
  };
239
+ }, [editor, provider, ydoc, frontmatterStore]);
240
 
241
  useEffect(() => {
242
  if (!editor) return;