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