Skip to content

Commit e20f686

Browse files
[WIKI-844] fix: realtime sync post vite migration with title editor sync and indexed db access (#8294)
* fix: robust way to handle socket connection and read from indexeddb cache when reqd * fix: realtime sync working with failure handling * fix: title editor added * merge preview into fix/realtime-sync * check * page renderer props * lint errors * lint errors * lint errors * sanitize html * sanitize html * format fix * fix lint
1 parent ff544c9 commit e20f686

File tree

32 files changed

+4054
-1982
lines changed

32 files changed

+4054
-1982
lines changed

apps/live/src/extensions/database.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP
2727
const pageDetails = await service.fetchDetails(pageId);
2828
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "<p></p>");
2929
if (convertedBinaryData) {
30+
// save the converted binary data back to the database
31+
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
32+
convertedBinaryData,
33+
true
34+
);
35+
const payload = {
36+
description_binary: contentBinaryEncoded,
37+
description_html: contentHTML,
38+
description: contentJSON,
39+
};
40+
await service.updateDescriptionBinary(pageId, payload);
3041
return convertedBinaryData;
3142
}
3243
}
@@ -52,8 +63,10 @@ const storeDocument = async ({
5263
try {
5364
const service = getPageService(context.documentType, context);
5465
// convert binary data to all formats
55-
const { contentBinaryEncoded, contentHTML, contentJSON } =
56-
getAllDocumentFormatsFromDocumentEditorBinaryData(pageBinaryData);
66+
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
67+
pageBinaryData,
68+
true
69+
);
5770
// create payload
5871
const payload = {
5972
description_binary: contentBinaryEncoded,
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// hocuspocus
2+
import type { Extension, Hocuspocus, Document } from "@hocuspocus/server";
3+
import { TiptapTransformer } from "@hocuspocus/transformer";
4+
import type * as Y from "yjs";
5+
// editor extensions
6+
import { TITLE_EDITOR_EXTENSIONS, createRealtimeEvent } from "@plane/editor";
7+
import { logger } from "@plane/logger";
8+
import { AppError } from "@/lib/errors";
9+
// helpers
10+
import { getPageService } from "@/services/page/handler";
11+
import type { HocusPocusServerContext, OnLoadDocumentPayloadWithContext } from "@/types";
12+
import { generateTitleProsemirrorJson } from "@/utils";
13+
import { broadcastMessageToPage } from "@/utils/broadcast-message";
14+
import { TitleUpdateManager } from "./title-update/title-update-manager";
15+
import { extractTextFromHTML } from "./title-update/title-utils";
16+
17+
/**
18+
* Hocuspocus extension for synchronizing document titles
19+
*/
20+
export class TitleSyncExtension implements Extension {
21+
// Maps document names to their observers and update managers
22+
private titleObservers: Map<string, (events: Y.YEvent<any>[]) => void> = new Map();
23+
private titleUpdateManagers: Map<string, TitleUpdateManager> = new Map();
24+
// Store minimal data needed for each document's title observer (prevents closure memory leaks)
25+
private titleObserverData: Map<
26+
string,
27+
{
28+
parentId?: string | null;
29+
userId: string;
30+
workspaceSlug: string | null;
31+
instance: Hocuspocus;
32+
}
33+
> = new Map();
34+
35+
/**
36+
* Handle document loading - migrate old titles if needed
37+
*/
38+
async onLoadDocument({ context, document, documentName }: OnLoadDocumentPayloadWithContext) {
39+
try {
40+
// initially for on demand migration of old titles to a new title field
41+
// in the yjs binary
42+
if (document.isEmpty("title")) {
43+
const service = getPageService(context.documentType, context);
44+
// const title = await service.fe
45+
const title = (await service.fetchDetails?.(documentName)).name;
46+
if (title == null) return;
47+
const titleField = TiptapTransformer.toYdoc(
48+
generateTitleProsemirrorJson(title),
49+
"title",
50+
// editor
51+
TITLE_EDITOR_EXTENSIONS as any
52+
);
53+
document.merge(titleField);
54+
}
55+
} catch (error) {
56+
const appError = new AppError(error, {
57+
context: { operation: "onLoadDocument", documentName },
58+
});
59+
logger.error("Error loading document title", appError);
60+
}
61+
}
62+
/**
63+
* Set up title synchronization for a document after it's loaded
64+
*/
65+
async afterLoadDocument({
66+
document,
67+
documentName,
68+
context,
69+
instance,
70+
}: {
71+
document: Document;
72+
documentName: string;
73+
context: HocusPocusServerContext;
74+
instance: Hocuspocus;
75+
}) {
76+
// Create a title update manager for this document
77+
const updateManager = new TitleUpdateManager(documentName, context);
78+
79+
// Store the manager
80+
this.titleUpdateManagers.set(documentName, updateManager);
81+
82+
// Store minimal data needed for the observer (prevents closure memory leak)
83+
this.titleObserverData.set(documentName, {
84+
userId: context.userId,
85+
workspaceSlug: context.workspaceSlug,
86+
instance: instance,
87+
});
88+
89+
// Create observer using bound method to avoid closure capturing heavy objects
90+
const titleObserver = this.handleTitleChange.bind(this, documentName);
91+
92+
// Observe the title field
93+
document.getXmlFragment("title").observeDeep(titleObserver);
94+
this.titleObservers.set(documentName, titleObserver);
95+
}
96+
97+
/**
98+
* Handle title changes for a document
99+
* This is a separate method to avoid closure memory leaks
100+
*/
101+
private handleTitleChange(documentName: string, events: Y.YEvent<any>[]) {
102+
let title = "";
103+
events.forEach((event) => {
104+
title = extractTextFromHTML(event.currentTarget.toJSON() as string);
105+
});
106+
107+
// Get the manager for this document
108+
const manager = this.titleUpdateManagers.get(documentName);
109+
110+
// Get the stored data for this document
111+
const data = this.titleObserverData.get(documentName);
112+
113+
// Broadcast to parent page if it exists
114+
if (data?.parentId && data.workspaceSlug && data.instance) {
115+
const event = createRealtimeEvent({
116+
user_id: data.userId,
117+
workspace_slug: data.workspaceSlug,
118+
action: "property_updated",
119+
page_id: documentName,
120+
data: { name: title },
121+
descendants_ids: [],
122+
});
123+
124+
// Use the instance from stored data (guaranteed to be set)
125+
broadcastMessageToPage(data.instance, data.parentId, event);
126+
}
127+
128+
// Schedule the title update
129+
if (manager) {
130+
manager.scheduleUpdate(title);
131+
}
132+
}
133+
134+
/**
135+
* Force save title before unloading the document
136+
*/
137+
async beforeUnloadDocument({ documentName }: { documentName: string }) {
138+
const updateManager = this.titleUpdateManagers.get(documentName);
139+
if (updateManager) {
140+
// Force immediate save and wait for it to complete
141+
await updateManager.forceSave();
142+
// Clean up the manager
143+
this.titleUpdateManagers.delete(documentName);
144+
}
145+
}
146+
147+
/**
148+
* Remove observers after document unload
149+
*/
150+
async afterUnloadDocument({ documentName, document }: { documentName: string; document?: Document }) {
151+
// Clean up observer when document is unloaded
152+
const observer = this.titleObservers.get(documentName);
153+
if (observer) {
154+
// unregister observer from Y.js document to prevent memory leak
155+
if (document) {
156+
try {
157+
document.getXmlFragment("title").unobserveDeep(observer);
158+
} catch (error) {
159+
logger.error("Failed to unobserve title field", new AppError(error, { context: { documentName } }));
160+
}
161+
}
162+
this.titleObservers.delete(documentName);
163+
}
164+
165+
// Clean up the observer data map to prevent memory leak
166+
this.titleObserverData.delete(documentName);
167+
168+
// Ensure manager is cleaned up if beforeUnloadDocument somehow didn't run
169+
if (this.titleUpdateManagers.has(documentName)) {
170+
const manager = this.titleUpdateManagers.get(documentName)!;
171+
manager.cancel();
172+
this.titleUpdateManagers.delete(documentName);
173+
}
174+
}
175+
}

0 commit comments

Comments
 (0)