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 ? (
-
-
+
) : (