From 531d2151a1019da01ed2e4a3af77022c50c1d6b6 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:56:13 +0100 Subject: [PATCH] fix: destroy collab room providers synchronously on unmount The react provider wrapper defers provider.destroy() by a timeout on unmount, so a room for the same document mounting in the same commit attaches while the old provider is still registered on the shared socket and crashes with 'Cannot attach two providers with the same effective name'. A local CollabRoom replaces it: synchronous destroy keeps unmount-before-mount ordering, and attach evicts any provider a previously interrupted teardown leaked so the crash cannot wedge a document until reload. --- .../src/features/editor/collab-room.tsx | 109 ++++++++++++++++++ .../src/features/editor/page-editor.tsx | 6 +- 2 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 apps/client/src/features/editor/collab-room.tsx diff --git a/apps/client/src/features/editor/collab-room.tsx b/apps/client/src/features/editor/collab-room.tsx new file mode 100644 index 000000000..07afe1202 --- /dev/null +++ b/apps/client/src/features/editor/collab-room.tsx @@ -0,0 +1,109 @@ +import React, { + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + HocuspocusProvider, + onAuthenticationFailedParameters, + onStatelessParameters, +} from "@hocuspocus/provider"; +import { + HocuspocusContext, + HocuspocusRoomContext, +} from "@hocuspocus/provider-react"; + +type CollabRoomProps = { + name: string; + token: string; + flushDelay?: number; + onStateless?: (data: onStatelessParameters) => void; + onAuthenticationFailed?: (data: onAuthenticationFailedParameters) => void; + children: React.ReactNode; +}; + +/** + * Replaces the library HocuspocusRoom, whose unmount defers provider.destroy() + * by a timeout (a StrictMode grace we don't need): a room for the same + * document mounting in the same commit then attaches while the old provider is + * still registered on the shared socket and crashes with "Cannot attach two + * providers with the same effective name". Destroying synchronously preserves + * unmount-before-mount ordering, and attach evicts whatever a previously + * interrupted teardown left registered. + */ +export default function CollabRoom({ + name, + token, + flushDelay, + onStateless, + onAuthenticationFailed, + children, +}: CollabRoomProps) { + const context = useContext(HocuspocusContext); + if (!context) { + throw new Error( + "CollabRoom must be used within HocuspocusProviderWebsocketComponent", + ); + } + const { websocketProvider } = context; + + const [provider, setProvider] = useState( + () => + new HocuspocusProvider({ name, websocketProvider, token, flushDelay }), + ); + + useEffect(() => { + if ( + provider.configuration.name !== name || + provider.configuration.token !== token || + provider.configuration.websocketProvider !== websocketProvider + ) { + provider.destroy(); + setProvider( + new HocuspocusProvider({ name, websocketProvider, token, flushDelay }), + ); + } + }, [name, token, websocketProvider]); + + useEffect(() => { + const providerMap = websocketProvider.configuration.providerMap; + const existing = providerMap.get(provider.effectiveName); + if (existing && existing !== provider) { + try { + existing.destroy(); + } catch { + // a broken teardown is exactly why it leaked; the delete below recovers + } + providerMap.delete(provider.effectiveName); + } + provider.attach(); + return () => provider.destroy(); + }, [provider]); + + const handlersRef = useRef({ onStateless, onAuthenticationFailed }); + handlersRef.current = { onStateless, onAuthenticationFailed }; + + useEffect(() => { + const statelessListener = (data: onStatelessParameters) => + handlersRef.current.onStateless?.(data); + const authenticationFailedListener = ( + data: onAuthenticationFailedParameters, + ) => handlersRef.current.onAuthenticationFailed?.(data); + provider.on("stateless", statelessListener); + provider.on("authenticationFailed", authenticationFailedListener); + return () => { + provider.off("stateless", statelessListener); + provider.off("authenticationFailed", authenticationFailedListener); + }; + }, [provider]); + + const contextValue = useMemo(() => ({ provider }), [provider]); + + return ( + + {children} + + ); +} diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index c926a7317..5e805e8b7 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -14,10 +14,10 @@ import { } from "@hocuspocus/provider"; import { HocuspocusProviderWebsocketComponent, - HocuspocusRoom, useHocuspocusEvent, useHocuspocusProvider, } from "@hocuspocus/provider-react"; +import CollabRoom from "@/features/editor/collab-room.tsx"; import { Editor, EditorContent, @@ -145,7 +145,7 @@ export default function PageEditor({ {collabQuery?.token ? ( - - + ) : (