feat: Update collaboration connection for HocusPocus v3

This commit is contained in:
Arek Nawo
2026-01-09 01:01:48 +01:00
parent 974bcea690
commit f671e7a3b9
+70 -103
View File
@@ -11,8 +11,9 @@ import * as Y from "yjs";
import { import {
HocuspocusProvider, HocuspocusProvider,
onStatusParameters, onStatusParameters,
onAuthenticationFailedParameters,
WebSocketStatus, WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
} from "@hocuspocus/provider"; } from "@hocuspocus/provider";
import { import {
EditorContent, EditorContent,
@@ -89,140 +90,127 @@ 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 ydocRef = useRef<Y.Doc | null>(null);
if (!ydocRef.current) {
ydocRef.current = new Y.Doc();
}
const ydoc = ydocRef.current;
const [isLocalSynced, setIsLocalSynced] = useState(false); const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false); const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom, yjsConnectionStatusAtom
); );
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`;
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false }); const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility(); const documentState = useDocumentVisibility();
const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug); const slugId = extractPageSlugId(pageSlug);
const userPageEditMode = const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback( const canScroll = useCallback(
() => isComponentMounted.current && editorCreated.current, () => isComponentMounted.current && editorCreated.current,
[isComponentMounted, editorCreated], [isComponentMounted, editorCreated]
); );
const { handleScrollTo } = useEditorScroll({ canScroll }); const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId // Providers only created once per pageId
const providersRef = useRef<{ const providersRef = useRef<{
local: IndexeddbPersistence; local: IndexeddbPersistence;
remote: HocuspocusProvider; remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null); } | null>(null);
const [providersReady, setProvidersReady] = useState(false); const [providersReady, setProvidersReady] = useState(false);
const localProvider = providersRef.current?.local;
const remoteProvider = providersRef.current?.remote;
useEffect(() => { useEffect(() => {
if (!providersRef.current) { if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc); const local = new IndexeddbPersistence(documentName, ydoc);
local.on("synced", () => setIsLocalSynced(true)); const socket = new HocuspocusProviderWebsocket({
const remote = new HocuspocusProvider({
name: documentName,
url: collaborationURL, url: collaborationURL,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
remote.configuration.websocketProvider.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
remote.configuration.websocketProvider.connect();
}, 100);
}
});
}
},
}); });
const onLocalSyncedHandler = () => {
remote.on("synced", () => setRemoteSynced(true)); setIsLocalSynced(true);
};
const handleSocketStatus = (status: onStatusParameters) => { const onStatusHandler = (event: onStatusParameters) => {
if (status.status === "connected") { setYjsConnectionStatus(event.status);
setYjsConnectionStatus(WebSocketStatus.Connected); };
} else if (status.status === "disconnected") { const onSyncedHandler = (event: onSyncedParameters) => {
setYjsConnectionStatus(WebSocketStatus.Disconnected); setIsRemoteSynced(event.state);
};
const onAuthenticationFailedHandler = () => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
remote.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
remote.connect();
}, 100);
}
});
} }
}; };
remote.configuration.websocketProvider.on("status", handleSocketStatus); const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
});
providersRef.current = { local, remote }; local.on("synced", onLocalSyncedHandler);
providersRef.current = { socket, local, remote };
setProvidersReady(true); setProvidersReady(true);
} else { } else {
setProvidersReady(true); setProvidersReady(true);
} }
// Only destroy on final unmount // Only destroy on final unmount
return () => { return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy(); providersRef.current?.remote.destroy();
providersRef.current?.local.destroy(); providersRef.current?.local.destroy();
providersRef.current = null; providersRef.current = null;
}; };
}, [pageId]); }, [pageId]);
/*
useEffect(() => {
// Handle token updates by reconnecting with new token
if (providersRef.current?.remote && collabQuery?.token) {
const currentToken = providersRef.current.remote.configuration.token;
if (currentToken !== collabQuery.token) {
// Token has changed, need to reconnect with new token
providersRef.current.remote.disconnect();
providersRef.current.remote.configuration.token = collabQuery.token;
providersRef.current.remote.connect();
}
}
}, [collabQuery?.token]);
*/
// Only connect/disconnect on tab/idle, not destroy // Only connect/disconnect on tab/idle, not destroy
useEffect(() => { useEffect(() => {
if (!providersReady || !providersRef.current) return; if (!providersReady || !providersRef.current) return;
const remoteProvider = providersRef.current.remote; const remoteProvider = providersRef.current.remote;
if ( if (
isIdle && isIdle &&
documentState === "hidden" && documentState === "hidden" &&
remoteProvider.configuration.websocketProvider.status === yjsConnectionStatus === WebSocketStatus.Connected
WebSocketStatus.Connected
) { ) {
remoteProvider.configuration.websocketProvider.disconnect(); remoteProvider.disconnect();
setIsCollabReady(false);
return; return;
} }
if ( if (
documentState === "visible" && documentState === "visible" &&
remoteProvider.configuration.websocketProvider.status === yjsConnectionStatus === WebSocketStatus.Disconnected
WebSocketStatus.Disconnected
) { ) {
resetIdle(); resetIdle();
remoteProvider.configuration.websocketProvider.connect(); remoteProvider.connect();
setTimeout(() => setIsCollabReady(true), 500);
} }
}, [isIdle, documentState, providersReady, resetIdle]); }, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
const extensions = useMemo(() => { const extensions = useMemo(() => {
if (!remoteProvider || !currentUser?.user) return mainExtensions; if (!providersReady || !providersRef.current || !currentUser?.user) {
return mainExtensions;
}
const remoteProvider = providersRef.current.remote;
return [ return [
...mainExtensions, ...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user), ...collabExtensions(remoteProvider, currentUser?.user),
]; ];
}, [remoteProvider, currentUser?.user]); }, [providersReady, currentUser?.user]);
const editor = useEditor( const editor = useEditor(
{ {
@@ -287,7 +275,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson); debouncedUpdateContent(editorJson);
}, },
}, },
[pageId, editable, remoteProvider], [pageId, editable, extensions]
); );
const editorIsEditable = useEditorState({ const editorIsEditable = useEditorState({
@@ -332,7 +320,7 @@ export default function PageEditor({
return () => { return () => {
document.removeEventListener( document.removeEventListener(
"ACTIVE_COMMENT_EVENT", "ACTIVE_COMMENT_EVENT",
handleActiveCommentEvent, handleActiveCommentEvent
); );
}; };
}, []); }, []);
@@ -343,38 +331,17 @@ export default function PageEditor({
setAsideState({ tab: "", isAsideOpen: false }); setAsideState({ tab: "", isAsideOpen: false });
}, [pageId]); }, [pageId]);
useEffect(() => {
if (
remoteProvider?.configuration.websocketProvider.status ===
WebSocketStatus.Connecting
) {
const timeout = setTimeout(() => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
}, 5000);
return () => clearTimeout(timeout);
}
}, [remoteProvider?.configuration.websocketProvider.status]);
const isSynced = isLocalSynced && isRemoteSynced; const isSynced = isLocalSynced && isRemoteSynced;
useEffect(() => { useEffect(() => {
const collabReadyTimeout = setTimeout(() => { const timeout = setTimeout(() => {
if ( if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
!isCollabReady && setYjsConnectionStatus(WebSocketStatus.Disconnected);
isSynced &&
remoteProvider?.configuration.websocketProvider.status ===
WebSocketStatus.Connected
) {
setIsCollabReady(true);
} }
}, 500); }, 7500);
return () => clearTimeout(collabReadyTimeout);
}, [
isRemoteSynced,
isLocalSynced,
remoteProvider?.configuration.websocketProvider.status,
]);
return () => clearTimeout(timeout);
}, [yjsConnectionStatus, isSynced]);
useEffect(() => { useEffect(() => {
// Only honor user default page edit mode preference and permissions // Only honor user default page edit mode preference and permissions
if (editor) { if (editor) {
@@ -396,13 +363,13 @@ export default function PageEditor({
useEffect(() => { useEffect(() => {
if ( if (
!hasConnectedOnceRef.current && !hasConnectedOnceRef.current &&
remoteProvider?.configuration.websocketProvider.status === yjsConnectionStatus === WebSocketStatus.Connected &&
WebSocketStatus.Connected isSynced
) { ) {
hasConnectedOnceRef.current = true; hasConnectedOnceRef.current = true;
setShowStatic(false); setShowStatic(false);
} }
}, [remoteProvider?.configuration.websocketProvider.status]); }, [yjsConnectionStatus, isSynced]);
if (showStatic) { if (showStatic) {
return ( return (