fix editor flickers on collab reconnection (#1295)

* fix editor flickers on reconnection

* cleanup

* adjust copy
This commit is contained in:
Philip Okugbe
2025-06-27 10:58:18 +01:00
committed by GitHub
parent 1be39d4353
commit e44c170873
3 changed files with 127 additions and 95 deletions
@@ -154,7 +154,7 @@ export default function BillingDetails() {
Current Tier Current Tier
</Text> </Text>
<Text fw={700} fz="lg"> <Text fw={700} fz="lg">
For up to {billing.tieredUpTo} users For {billing.tieredUpTo} users
</Text> </Text>
{/*billing.tieredFlatAmount && ( {/*billing.tieredFlatAmount && (
<Text c="dimmed" fz="sm"> <Text c="dimmed" fz="sm">
@@ -155,7 +155,7 @@ export default function BillingPlans() {
</Text> </Text>
)} )}
<Text size="md" fw={500}> <Text size="md" fw={500}>
for up to {planSelectedTier.upTo} users For {planSelectedTier.upTo} users
</Text> </Text>
</Stack> </Stack>
+125 -93
View File
@@ -1,7 +1,6 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { import React, {
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@@ -72,7 +71,11 @@ export default function PageEditor({
const [, setAsideState] = useAtom(asideStateAtom); const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const ydoc = useMemo(() => new Y.Doc(), [pageId]); const ydocRef = useRef<Y.Doc | null>(null);
if (!ydocRef.current) {
ydocRef.current = new Y.Doc();
}
const ydoc = ydocRef.current;
const [isLocalSynced, setLocalSynced] = useState(false); const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@@ -89,66 +92,100 @@ export default function PageEditor({
const userPageEditMode = const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const localProvider = useMemo(() => { // Providers only created once per pageId
const provider = new IndexeddbPersistence(documentName, ydoc); const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
provider.on("synced", () => { const localProvider = providersRef.current?.local;
setLocalSynced(true); const remoteProvider = providersRef.current?.remote;
});
return provider; // Track when collaborative provider is ready and synced
}, [pageId, ydoc]); const [collabReady, setCollabReady] = useState(false);
useEffect(() => {
if (
remoteProvider?.status === WebSocketStatus.Connected &&
isLocalSynced &&
isRemoteSynced
) {
setCollabReady(true);
}
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
const remoteProvider = useMemo(() => { useEffect(() => {
const provider = new HocuspocusProvider({ if (!providersRef.current) {
name: documentName, const local = new IndexeddbPersistence(documentName, ydoc);
url: collaborationURL, local.on("synced", () => setLocalSynced(true));
document: ydoc, const remote = new HocuspocusProvider({
token: collabQuery?.token, name: documentName,
connect: false, url: collaborationURL,
preserveConnection: false, document: ydoc,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => { token: collabQuery?.token,
const payload = jwtDecode(collabQuery?.token); connect: true,
const now = Date.now().valueOf() / 1000; preserveConnection: false,
const isTokenExpired = now >= payload.exp; onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
if (isTokenExpired) { const payload = jwtDecode(collabQuery?.token);
refetchCollabToken(); const now = Date.now().valueOf() / 1000;
} const isTokenExpired = now >= payload.exp;
}, if (isTokenExpired) {
onStatus: (status) => { refetchCollabToken();
if (status.status === "connected") { }
setYjsConnectionStatus(status.status); },
} onStatus: (status) => {
}, if (status.status === "connected") {
}); setYjsConnectionStatus(status.status);
}
provider.on("synced", () => { },
setRemoteSynced(true); });
}); remote.on("synced", () => setRemoteSynced(true));
remote.on("disconnect", () => {
provider.on("disconnect", () => { setYjsConnectionStatus(WebSocketStatus.Disconnected);
setYjsConnectionStatus(WebSocketStatus.Disconnected); });
}); providersRef.current = { local, remote };
setProvidersReady(true);
return provider; } else {
}, [ydoc, pageId, collabQuery?.token]); setProvidersReady(true);
}
useLayoutEffect(() => { // Only destroy on final unmount
remoteProvider.connect();
return () => { return () => {
setRemoteSynced(false); providersRef.current?.remote.destroy();
setLocalSynced(false); providersRef.current?.local.destroy();
remoteProvider.destroy(); providersRef.current = null;
localProvider.destroy();
}; };
}, [remoteProvider, localProvider]); }, [pageId]);
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const remoteProvider = providersRef.current.remote;
if (
isIdle &&
documentState === "hidden" &&
remoteProvider.status === WebSocketStatus.Connected
) {
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
remoteProvider.status === WebSocketStatus.Disconnected
) {
resetIdle();
remoteProvider.connect();
setTimeout(() => setIsCollabReady(true), 500);
}
}, [isIdle, documentState, providersReady, resetIdle]);
const extensions = useMemo(() => { const extensions = useMemo(() => {
if (!remoteProvider || !currentUser?.user) return mainExtensions;
return [ return [
...mainExtensions, ...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user), ...collabExtensions(remoteProvider, currentUser?.user),
]; ];
}, [ydoc, pageId, remoteProvider, currentUser?.user]); }, [remoteProvider, currentUser?.user]);
const editor = useEditor( const editor = useEditor(
{ {
@@ -202,7 +239,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson); debouncedUpdateContent(editorJson);
}, },
}, },
[pageId, editable, remoteProvider?.status], [pageId, editable, remoteProvider],
); );
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => { const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
@@ -255,29 +292,6 @@ export default function PageEditor({
} }
}, [remoteProvider?.status]); }, [remoteProvider?.status]);
useEffect(() => {
if (
isIdle &&
documentState === "hidden" &&
remoteProvider?.status === WebSocketStatus.Connected
) {
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
remoteProvider?.status === WebSocketStatus.Disconnected
) {
resetIdle();
remoteProvider.connect();
setTimeout(() => {
setIsCollabReady(true);
}, 600);
}
}, [isIdle, documentState, remoteProvider]);
const isSynced = isLocalSynced && isRemoteSynced; const isSynced = isLocalSynced && isRemoteSynced;
useEffect(() => { useEffect(() => {
@@ -294,21 +308,48 @@ export default function PageEditor({
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]); }, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
useEffect(() => { useEffect(() => {
// honor user default page edit mode preference // Only honor user default page edit mode preference and permissions
if (userPageEditMode && editor && editable && isSynced) { if (editor) {
if (userPageEditMode === PageEditMode.Edit) { if (userPageEditMode && editable) {
editor.setEditable(true); if (userPageEditMode === PageEditMode.Edit) {
} else if (userPageEditMode === PageEditMode.Read) { editor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
editor.setEditable(false);
}
} else {
editor.setEditable(false); editor.setEditable(false);
} }
} }
}, [userPageEditMode, editor, editable, isSynced]); }, [userPageEditMode, editor, editable]);
return isCollabReady ? ( const hasConnectedOnceRef = useRef(false);
<div> const [showStatic, setShowStatic] = useState(true);
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
remoteProvider?.status === WebSocketStatus.Connected
) {
hasConnectedOnceRef.current = true;
setShowStatic(false);
}
}, [remoteProvider?.status]);
if (showStatic) {
return (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
/>
);
}
return (
<div style={{ position: "relative" }}>
<div ref={menuContainerRef}> <div ref={menuContainerRef}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
{editor && editor.isEditable && ( {editor && editor.isEditable && (
<div> <div>
<EditorBubbleMenu editor={editor} /> <EditorBubbleMenu editor={editor} />
@@ -322,21 +363,12 @@ export default function PageEditor({
<LinkMenu editor={editor} appendTo={menuContainerRef} /> <LinkMenu editor={editor} appendTo={menuContainerRef} />
</div> </div>
)} )}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />} {showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
</div> </div>
<div <div
onClick={() => editor.commands.focus("end")} onClick={() => editor.commands.focus("end")}
style={{ paddingBottom: "20vh" }} style={{ paddingBottom: "20vh" }}
></div> ></div>
</div> </div>
) : (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
></EditorProvider>
); );
} }