Skip to content

Commit 66de275

Browse files
committed
feat: first draft of @hocuspocus/provide-react package
1 parent ad398a8 commit 66de275

File tree

15 files changed

+661
-36
lines changed

15 files changed

+661
-36
lines changed

package-lock.json

Lines changed: 75 additions & 35 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"name": "@hocuspocus/provider-react",
3+
"version": "3.4.6-rc.2",
4+
"description": "React bindings for Hocuspocus provider",
5+
"homepage": "https://hocuspocus.dev",
6+
"keywords": ["hocuspocus", "websocket", "provider", "react", "yjs"],
7+
"license": "MIT",
8+
"type": "module",
9+
"main": "dist/hocuspocus-provider-react.cjs",
10+
"module": "dist/hocuspocus-provider-react.esm.js",
11+
"types": "dist/index.d.ts",
12+
"exports": {
13+
"source": {
14+
"import": "./src/index.ts"
15+
},
16+
"default": {
17+
"import": "./dist/hocuspocus-provider-react.esm.js",
18+
"require": "./dist/hocuspocus-provider-react.cjs",
19+
"types": "./dist/index.d.ts"
20+
}
21+
},
22+
"files": ["src", "dist"],
23+
"peerDependencies": {
24+
"@hocuspocus/provider": "^3.4.6-rc.2",
25+
"react": "^18.0.0 || ^19.0.0",
26+
"yjs": "^13.6.8"
27+
},
28+
"devDependencies": {
29+
"@types/react": "^18.0.0",
30+
"react": "^18.0.0"
31+
},
32+
"repository": {
33+
"url": "https://github.com/ueberdosis/hocuspocus"
34+
}
35+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { HocuspocusProviderWebsocket } from "@hocuspocus/provider";
2+
import { useEffect, useMemo, useRef } from "react";
3+
4+
import { HocuspocusContext } from "./context.ts";
5+
import type { HocuspocusProviderComponentProps } from "./types.ts";
6+
7+
/**
8+
* HocuspocusProviderComponent manages the WebSocket connection that is shared across all rooms.
9+
*
10+
* This component creates a single WebSocket connection that can be used by multiple
11+
* HocuspocusRoom components, preventing connection overhead when switching between documents.
12+
*
13+
* @example
14+
* ```tsx
15+
* <HocuspocusProviderComponent url="ws://localhost:1234">
16+
* <HocuspocusRoom name="document-1">
17+
* <Editor />
18+
* </HocuspocusRoom>
19+
* </HocuspocusProviderComponent>
20+
* ```
21+
*/
22+
export function HocuspocusProviderComponent({
23+
children,
24+
url,
25+
websocketProvider: externalWebsocketProvider,
26+
}: HocuspocusProviderComponentProps) {
27+
const websocketRef = useRef<HocuspocusProviderWebsocket | null>(null);
28+
29+
// Create WebSocket provider once on mount
30+
if (!websocketRef.current && !externalWebsocketProvider) {
31+
websocketRef.current = new HocuspocusProviderWebsocket({
32+
url: url ?? "",
33+
});
34+
}
35+
36+
const websocketProvider =
37+
externalWebsocketProvider ??
38+
(websocketRef.current as HocuspocusProviderWebsocket);
39+
40+
// Cleanup on unmount
41+
useEffect(() => {
42+
return () => {
43+
// Only destroy if we created the websocket (not externally provided)
44+
if (!externalWebsocketProvider && websocketRef.current) {
45+
websocketRef.current.destroy();
46+
websocketRef.current = null;
47+
}
48+
};
49+
}, [externalWebsocketProvider]);
50+
51+
const contextValue = useMemo(
52+
() => ({
53+
websocketProvider,
54+
}),
55+
[websocketProvider],
56+
);
57+
58+
return (
59+
<HocuspocusContext.Provider value={contextValue}>
60+
{children}
61+
</HocuspocusContext.Provider>
62+
);
63+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { HocuspocusProvider } from "@hocuspocus/provider";
2+
import { useContext, useEffect, useMemo, useRef } from "react";
3+
4+
import { HocuspocusContext, HocuspocusRoomContext } from "./context.ts";
5+
import type { HocuspocusRoomProps } from "./types.ts";
6+
7+
/**
8+
* HocuspocusRoom manages the connection to a specific document.
9+
*
10+
* It uses the shared WebSocket from HocuspocusProviderComponent and creates a document-specific
11+
* provider that connects on mount and disconnects on unmount.
12+
*
13+
* This component handles React's StrictMode gracefully by using deferred destruction,
14+
* preventing unnecessary reconnections during development double-mounts.
15+
*
16+
* @example
17+
* ```tsx
18+
* <HocuspocusProviderComponent url="ws://localhost:1234">
19+
* <HocuspocusRoom name="document-1">
20+
* <Editor />
21+
* </HocuspocusRoom>
22+
* </HocuspocusProviderComponent>
23+
* ```
24+
*/
25+
export function HocuspocusRoom({
26+
children,
27+
name,
28+
document,
29+
token,
30+
}: HocuspocusRoomProps) {
31+
const hocuspocusContext = useContext(HocuspocusContext);
32+
33+
if (!hocuspocusContext) {
34+
throw new Error(
35+
"HocuspocusRoom must be used within a HocuspocusProviderComponent",
36+
);
37+
}
38+
39+
const { websocketProvider } = hocuspocusContext;
40+
41+
const providerRef = useRef<HocuspocusProvider | null>(null);
42+
const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
43+
44+
// Store current props in a ref to access in cleanup without triggering re-creation
45+
const propsRef = useRef({ name, document, token });
46+
propsRef.current = { name, document, token };
47+
48+
// Create or retrieve provider
49+
// We use a ref to prevent recreation on every render
50+
if (!providerRef.current) {
51+
providerRef.current = new HocuspocusProvider({
52+
name,
53+
websocketProvider,
54+
document,
55+
token,
56+
});
57+
}
58+
59+
const provider = providerRef.current;
60+
61+
useEffect(() => {
62+
// Cancel any pending destruction (handles StrictMode double-mount)
63+
if (destroyTimeoutRef.current) {
64+
clearTimeout(destroyTimeoutRef.current);
65+
destroyTimeoutRef.current = null;
66+
}
67+
68+
return () => {
69+
// Deferred destruction - wait for potential remount in StrictMode
70+
// Using setTimeout(0) allows React to remount before we destroy
71+
destroyTimeoutRef.current = setTimeout(() => {
72+
if (providerRef.current) {
73+
providerRef.current.destroy();
74+
providerRef.current = null;
75+
}
76+
}, 0);
77+
};
78+
}, []);
79+
80+
// Handle document name changes - need to recreate provider
81+
useEffect(() => {
82+
// Skip on initial mount since we already created the provider
83+
if (
84+
providerRef.current &&
85+
providerRef.current.configuration.name !== name
86+
) {
87+
// Name changed, need to recreate provider
88+
providerRef.current.destroy();
89+
providerRef.current = new HocuspocusProvider({
90+
name,
91+
websocketProvider,
92+
document: propsRef.current.document,
93+
token: propsRef.current.token,
94+
});
95+
}
96+
}, [name, websocketProvider]);
97+
98+
const contextValue = useMemo(
99+
() => ({
100+
provider,
101+
}),
102+
[provider],
103+
);
104+
105+
return (
106+
<HocuspocusRoomContext.Provider value={contextValue}>
107+
{children}
108+
</HocuspocusRoomContext.Provider>
109+
);
110+
}

0 commit comments

Comments
 (0)