Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/provider-react/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@hocuspocus/provider-react",
"version": "3.4.6-rc.2",
"description": "React bindings for Hocuspocus provider",
"homepage": "https://hocuspocus.dev",
"keywords": ["hocuspocus", "websocket", "provider", "react", "yjs"],
"license": "MIT",
"type": "module",
"main": "dist/hocuspocus-provider-react.cjs",
"module": "dist/hocuspocus-provider-react.esm.js",
"types": "dist/index.d.ts",
"exports": {
"source": {
"import": "./src/index.ts"
},
"default": {
"import": "./dist/hocuspocus-provider-react.esm.js",
"require": "./dist/hocuspocus-provider-react.cjs",
"types": "./dist/index.d.ts"
}
},
"files": ["src", "dist"],
"peerDependencies": {
"@hocuspocus/provider": "^3.4.6-rc.2",
"react": "^18.0.0 || ^19.0.0",
"yjs": "^13.6.8"
},
"devDependencies": {
"@types/react": "^19.0.0",
"react": "^19.0.0"
},
"repository": {
"url": "https://github.com/ueberdosis/hocuspocus"
}
}
75 changes: 75 additions & 0 deletions packages/provider-react/src/HocuspocusProviderComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"use client";

import { HocuspocusProviderWebsocket } from "@hocuspocus/provider";
import { useEffect, useMemo, useRef } from "react";

import { HocuspocusContext } from "./context.ts";
import type { HocuspocusProviderComponentProps } from "./types.ts";

/**
* HocuspocusProviderComponent manages the WebSocket connection that is shared across all rooms.
*
* This component creates a single WebSocket connection that can be used by multiple
* HocuspocusRoom components, preventing connection overhead when switching between documents.
*
* @example
* ```tsx
* <HocuspocusProviderComponent url="ws://localhost:1234">
* <HocuspocusRoom name="document-1">
* <Editor />
* </HocuspocusRoom>
* </HocuspocusProviderComponent>
* ```
*/
export function HocuspocusProviderComponent({
children,
url,
websocketProvider: externalWebsocketProvider,
}: HocuspocusProviderComponentProps) {
const websocketRef = useRef<HocuspocusProviderWebsocket | null>(null);
const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Create WebSocket provider once on mount
if (!websocketRef.current && !externalWebsocketProvider) {
websocketRef.current = new HocuspocusProviderWebsocket({
url: url ?? "",
});
}

const websocketProvider =
externalWebsocketProvider ??
(websocketRef.current as HocuspocusProviderWebsocket);

// Cleanup on unmount with deferred destruction to handle StrictMode double-mount
useEffect(() => {
if (destroyTimeoutRef.current) {
clearTimeout(destroyTimeoutRef.current);
destroyTimeoutRef.current = null;
}

return () => {
// Only destroy if we created the websocket (not externally provided)
if (!externalWebsocketProvider) {
destroyTimeoutRef.current = setTimeout(() => {
if (websocketRef.current) {
websocketRef.current.destroy();
websocketRef.current = null;
}
}, 0);
}
};
}, [externalWebsocketProvider]);

const contextValue = useMemo(
() => ({
websocketProvider,
}),
[websocketProvider],
);

return (
<HocuspocusContext.Provider value={contextValue}>
{children}
</HocuspocusContext.Provider>
);
}
116 changes: 116 additions & 0 deletions packages/provider-react/src/HocuspocusRoom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"use client";

import { HocuspocusProvider } from "@hocuspocus/provider";
import { useContext, useEffect, useMemo, useRef } from "react";

import { HocuspocusContext, HocuspocusRoomContext } from "./context.ts";
import type { HocuspocusRoomProps } from "./types.ts";

/**
* HocuspocusRoom manages the connection to a specific document.
*
* It uses the shared WebSocket from HocuspocusProviderComponent and creates a document-specific
* provider that connects on mount and disconnects on unmount.
*
* This component handles React's StrictMode gracefully by using deferred destruction,
* preventing unnecessary reconnections during development double-mounts.
*
* @example
* ```tsx
* <HocuspocusProviderComponent url="ws://localhost:1234">
* <HocuspocusRoom name="document-1">
* <Editor />
* </HocuspocusRoom>
* </HocuspocusProviderComponent>
* ```
*/
export function HocuspocusRoom({
children,
name,
document,
token,
}: HocuspocusRoomProps) {
const hocuspocusContext = useContext(HocuspocusContext);

if (!hocuspocusContext) {
throw new Error(
"HocuspocusRoom must be used within a HocuspocusProviderComponent",
);
}

const { websocketProvider } = hocuspocusContext;

const providerRef = useRef<HocuspocusProvider | null>(null);
const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Store current props in a ref to access in cleanup without triggering re-creation
const propsRef = useRef({ name, document, token });
propsRef.current = { name, document, token };

// Create or retrieve provider
// We use a ref to prevent recreation on every render
if (!providerRef.current) {
providerRef.current = new HocuspocusProvider({
name,
websocketProvider,
document,
token,
});
}

const provider = providerRef.current;

useEffect(() => {
// Cancel any pending destruction (handles StrictMode double-mount)
if (destroyTimeoutRef.current) {
clearTimeout(destroyTimeoutRef.current);
destroyTimeoutRef.current = null;
}

// Attach the provider to the websocket so it starts syncing
provider.attach();

return () => {
// Deferred destruction - wait for potential remount in StrictMode
// Using setTimeout(0) allows React to remount before we destroy
destroyTimeoutRef.current = setTimeout(() => {
if (providerRef.current) {
providerRef.current.destroy();
providerRef.current = null;
}
}, 0);
};
}, []);

// Handle document name changes - need to recreate provider
useEffect(() => {
// Skip on initial mount since we already created the provider
if (
providerRef.current &&
providerRef.current.configuration.name !== name
) {
// Name changed, need to recreate provider
providerRef.current.destroy();
providerRef.current = new HocuspocusProvider({
name,
websocketProvider,
document: propsRef.current.document,
token: propsRef.current.token,
});
providerRef.current.attach();
}
}, [name, websocketProvider]);

const contextValue = useMemo(
() => ({
provider,
}),
[provider],
);

return (
<HocuspocusRoomContext.Provider value={contextValue}>
{children}
</HocuspocusRoomContext.Provider>
);
}
18 changes: 18 additions & 0 deletions packages/provider-react/src/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createContext } from "react";

import type {
HocuspocusContextValue,
HocuspocusRoomContextValue,
} from "./types.ts";

/**
* Context for the WebSocket connection shared across rooms
*/
export const HocuspocusContext =
createContext<HocuspocusContextValue | null>(null);

/**
* Context for the room/document provider
*/
export const HocuspocusRoomContext =
createContext<HocuspocusRoomContextValue | null>(null);
4 changes: 4 additions & 0 deletions packages/provider-react/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { useHocuspocusAwareness } from "./useHocuspocusAwareness.ts";
export { useHocuspocusConnectionStatus } from "./useHocuspocusConnectionStatus.ts";
export { useHocuspocusProvider } from "./useHocuspocusProvider.ts";
export { useHocuspocusSyncStatus } from "./useHocuspocusSyncStatus.ts";
80 changes: 80 additions & 0 deletions packages/provider-react/src/hooks/useHocuspocusAwareness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useCallback, useRef, useSyncExternalStore } from "react";

import type { CollabUser } from "../types.ts";
import { useHocuspocusProvider } from "./useHocuspocusProvider.ts";

/**
* Subscribe to the list of connected users in the document.
*
* This hook uses the Yjs awareness protocol to track users currently
* connected to the document.
*
* @returns Array of user objects with their awareness state
*
* @example
* ```tsx
* function UserList() {
* const users = useHocuspocusAwareness()
*
* return (
* <div className="avatars">
* {users.map(user => (
* <div
* key={user.clientId}
* style={{ backgroundColor: user.color }}
* title={user.name}
* >
* {user.name?.[0]}
* </div>
* ))}
* </div>
* )
* }
* ```
*/
export function useHocuspocusAwareness(): CollabUser[] {
const provider = useHocuspocusProvider();

// Cache the last snapshot to avoid unnecessary array allocations
const cacheRef = useRef<{
users: CollabUser[];
json: string;
} | null>(null);

const subscribe = useCallback(
(onStoreChange: () => void) => {
provider.awareness?.on("change", onStoreChange);
return () => {
provider.awareness?.off("change", onStoreChange);
};
},
[provider],
);

const getSnapshot = useCallback(() => {
const awareness = provider.awareness;
if (!awareness) {
return [];
}

const users: CollabUser[] = [];
awareness.getStates().forEach((state, clientId) => {
users.push({
clientId,
...state,
});
});

const json = JSON.stringify(users);

// Return cached value if unchanged to preserve referential equality
if (cacheRef.current && cacheRef.current.json === json) {
return cacheRef.current.users;
}

cacheRef.current = { users, json };
return users;
}, [provider]);

return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
62 changes: 62 additions & 0 deletions packages/provider-react/src/hooks/useHocuspocusConnectionStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useCallback, useRef, useSyncExternalStore } from "react";

import type { ConnectionStatus } from "../types.ts";
import { useHocuspocusProvider } from "./useHocuspocusProvider.ts";

/**
* Subscribe to the connection status of the collaboration provider.
*
* This hook uses React's useSyncExternalStore for optimal integration with
* concurrent rendering features.
*
* @returns The current connection status: 'connecting', 'connected', or 'disconnected'
*
* @example
* ```tsx
* function ConnectionIndicator() {
* const status = useHocuspocusConnectionStatus()
*
* return (
* <div className={`status-${status}`}>
* {status === 'connected' ? 'Online' : status === 'connecting' ? 'Connecting...' : 'Offline'}
* </div>
* )
* }
* ```
*/
export function useHocuspocusConnectionStatus(): ConnectionStatus {
const provider = useHocuspocusProvider();
const statusRef = useRef<ConnectionStatus>(
provider.configuration.websocketProvider.status as ConnectionStatus,
);

const subscribe = useCallback(
(onStoreChange: () => void) => {
const handleStatus = (data: { status: ConnectionStatus }) => {
statusRef.current = data.status;
onStoreChange();
};

provider.on("status", handleStatus);

// Sync initial status in case it changed between render and subscribe
const currentStatus = provider.configuration.websocketProvider
.status as ConnectionStatus;
if (statusRef.current !== currentStatus) {
statusRef.current = currentStatus;
onStoreChange();
}

return () => {
provider.off("status", handleStatus);
};
},
[provider],
);

const getSnapshot = useCallback((): ConnectionStatus => {
return statusRef.current;
}, []);

return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
Loading