fix: prevent Y.Doc content duplication from seed race condition
Browse filesThe 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 +33 -0
- frontend/src/editor/Editor.tsx +12 -6
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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 225 |
-
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
| 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;
|