snapshot of https://github.com/ueberdosis/hocuspocus.git @ cf3cfa355b4d3bb61c6b6c80071f71783e32470e (2026-09-15)
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
This commit is contained in:
138
packages/provider-react/CHANGELOG.md
Normal file
138
packages/provider-react/CHANGELOG.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [4.7.0](https://github.com/ueberdosis/hocuspocus/compare/v4.6.0...v4.7.0) (2026-09-09)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.6.0](https://github.com/ueberdosis/hocuspocus/compare/v4.5.0...v4.6.0) (2026-08-10)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.5.0](https://github.com/ueberdosis/hocuspocus/compare/v4.4.0...v4.5.0) (2026-08-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update packages via audit --fix ([1dc9ca0](https://github.com/ueberdosis/hocuspocus/commit/1dc9ca0ff35f1033136473d134cee8cb6b336281))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.4.0](https://github.com/ueberdosis/hocuspocus/compare/v4.3.0...v4.4.0) (2026-07-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add `flushDelay` option for batching updates to reduce websocket traffic during heavy editing ([#1118](https://github.com/ueberdosis/hocuspocus/issues/1118)) ([75594c0](https://github.com/ueberdosis/hocuspocus/commit/75594c05d57d48f2f70d4c9440c28b8226bf95ac))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.3.0](https://github.com/ueberdosis/hocuspocus/compare/v4.2.0...v4.3.0) (2026-06-18)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.2.0](https://github.com/ueberdosis/hocuspocus/compare/v4.1.2...v4.2.0) (2026-06-12)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [4.1.2](https://github.com/ueberdosis/hocuspocus/compare/v4.1.1...v4.1.2) (2026-06-11)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [4.1.1](https://github.com/ueberdosis/hocuspocus/compare/v4.1.0...v4.1.1) (2026-06-10)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.1.0](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0...v4.1.0) (2026-05-20)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.7...v4.0.0) (2026-04-23)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.7](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.6...v4.0.0-rc.7) (2026-04-22)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.6](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.5...v4.0.0-rc.6) (2026-04-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* react provider in strict mode ([4f27b7f](https://github.com/ueberdosis/hocuspocus/commit/4f27b7f0e57423b08fabb59cfa6d0a2cc67bc996))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.5](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.4...v4.0.0-rc.5) (2026-04-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* adds package readmes ([c7d63f7](https://github.com/ueberdosis/hocuspocus/commit/c7d63f79980de3c34e63b60073f22774bf27ac13))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.4](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.3...v4.0.0-rc.4) (2026-04-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* publishConfig for provider-react ([376765f](https://github.com/ueberdosis/hocuspocus/commit/376765f6623259c79940da67a1f970ac9a1e84b1))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.3](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.2...v4.0.0-rc.3) (2026-04-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* first draft of @hocuspocus/provider-react package ([#1059](https://github.com/ueberdosis/hocuspocus/issues/1059)) ([cbc9977](https://github.com/ueberdosis/hocuspocus/commit/cbc99770096e8c674dae304c7e1a16e0fbec827b))
|
||||
70
packages/provider-react/README.md
Normal file
70
packages/provider-react/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# @hocuspocus/provider-react
|
||||
[](https://www.npmjs.com/package/@hocuspocus/provider-react)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/provider-react)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
React bindings for the [Hocuspocus provider](../provider). Wraps the provider in components and hooks so React handles the lifecycle — including StrictMode double-mounts. Built on `useSyncExternalStore`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @hocuspocus/provider @hocuspocus/provider-react yjs
|
||||
```
|
||||
|
||||
Requires React 18 or 19.
|
||||
|
||||
## Usage
|
||||
|
||||
Wrap your collaborative subtree with `HocuspocusProviderWebsocketComponent` (shared WebSocket) and one or more `HocuspocusRoom`s (one per document). Inside the room, hooks give you the provider and its state:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
HocuspocusProviderWebsocketComponent,
|
||||
HocuspocusRoom,
|
||||
useHocuspocusProvider,
|
||||
useHocuspocusConnectionStatus,
|
||||
} from "@hocuspocus/provider-react"
|
||||
|
||||
function Editor() {
|
||||
const provider = useHocuspocusProvider()
|
||||
const status = useHocuspocusConnectionStatus()
|
||||
// wire provider.document / provider.awareness into your editor
|
||||
return <div>{status}</div>
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<HocuspocusProviderWebsocketComponent url="ws://127.0.0.1:1234">
|
||||
<HocuspocusRoom name="example-document" token="super-secret-token">
|
||||
<Editor />
|
||||
</HocuspocusRoom>
|
||||
</HocuspocusProviderWebsocketComponent>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
To use with Tiptap, pass `provider.document` into the `Collaboration` extension and `provider` into `CollaborationCaret` — see the [collaborative editing guide](https://tiptap.dev/docs/hocuspocus/guides/collaborative-editing) for a full example.
|
||||
|
||||
## Exports
|
||||
|
||||
**Components**
|
||||
|
||||
- `HocuspocusProviderWebsocketComponent` — manages the shared WebSocket
|
||||
- `HocuspocusRoom` — creates a per-document provider on the shared socket; StrictMode-safe
|
||||
|
||||
**Hooks** (must be used inside a `HocuspocusRoom`)
|
||||
|
||||
- `useHocuspocusProvider()` — the `HocuspocusProvider` instance
|
||||
- `useHocuspocusConnectionStatus()` — `'connecting' | 'connected' | 'disconnected'`
|
||||
- `useHocuspocusSyncStatus()` — `'synced' | 'syncing'`
|
||||
- `useHocuspocusAwareness()` — array of connected users' awareness state
|
||||
- `useHocuspocusEvent(name, handler)` — subscribe to any provider event
|
||||
|
||||
## Documentation
|
||||
|
||||
Full components, hooks, and patterns reference: [tiptap.dev/docs/hocuspocus/provider/react](https://tiptap.dev/docs/hocuspocus/provider/react).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).
|
||||
47
packages/provider-react/package.json
Normal file
47
packages/provider-react/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@hocuspocus/provider-react",
|
||||
"version": "4.7.0",
|
||||
"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": "workspace:^",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"react": "^19.2.4"
|
||||
},
|
||||
"repository": {
|
||||
"url": "https://github.com/ueberdosis/hocuspocus"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { HocuspocusProviderWebsocket } from "@hocuspocus/provider";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import { HocuspocusContext } from "./context.ts";
|
||||
import type { HocuspocusProviderWebsocketComponentProps } from "./types.ts";
|
||||
|
||||
/**
|
||||
* HocuspocusProviderWebsocketComponent 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
|
||||
* <HocuspocusProviderWebsocketComponent url="ws://localhost:1234">
|
||||
* <HocuspocusRoom name="document-1">
|
||||
* <Editor />
|
||||
* </HocuspocusRoom>
|
||||
* </HocuspocusProviderWebsocketComponent>
|
||||
* ```
|
||||
*/
|
||||
export function HocuspocusProviderWebsocketComponent({
|
||||
children,
|
||||
url,
|
||||
websocketProvider: externalWebsocketProvider,
|
||||
}: HocuspocusProviderWebsocketComponentProps) {
|
||||
const websocketRef = useRef<HocuspocusProviderWebsocket | null>(null);
|
||||
const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Create WebSocket provider once on mount.
|
||||
// Safe in StrictMode: useRef persists across double-renders, so the instance
|
||||
// is created only once. The deferred destruction in the effect below ensures
|
||||
// the second mount cancels the pending destroy before it fires.
|
||||
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>
|
||||
);
|
||||
}
|
||||
168
packages/provider-react/src/HocuspocusRoom.tsx
Normal file
168
packages/provider-react/src/HocuspocusRoom.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { HocuspocusContext, HocuspocusRoomContext } from "./context.ts";
|
||||
import type { HocuspocusProviderEvents, HocuspocusRoomProps } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Mapping from HocuspocusRoom `on*` prop names to provider event names.
|
||||
*/
|
||||
const EVENT_PROP_MAP: Record<string, keyof HocuspocusProviderEvents> = {
|
||||
onOpen: "open",
|
||||
onConnect: "connect",
|
||||
onClose: "close",
|
||||
onDisconnect: "disconnect",
|
||||
onStatus: "status",
|
||||
onSynced: "synced",
|
||||
onUnsyncedChanges: "unsyncedChanges",
|
||||
onMessage: "message",
|
||||
onOutgoingMessage: "outgoingMessage",
|
||||
onStateless: "stateless",
|
||||
onAuthenticated: "authenticated",
|
||||
onAuthenticationFailed: "authenticationFailed",
|
||||
onAwarenessUpdate: "awarenessUpdate",
|
||||
onAwarenessChange: "awarenessChange",
|
||||
onDestroy: "destroy",
|
||||
};
|
||||
|
||||
/**
|
||||
* HocuspocusRoom manages the connection to a specific document.
|
||||
*
|
||||
* It uses the shared WebSocket from HocuspocusProviderWebsocketComponent 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
|
||||
* <HocuspocusProviderWebsocketComponent url="ws://localhost:1234">
|
||||
* <HocuspocusRoom
|
||||
* name="document-1"
|
||||
* onAuthenticationFailed={(data) => console.error(data.reason)}
|
||||
* >
|
||||
* <Editor />
|
||||
* </HocuspocusRoom>
|
||||
* </HocuspocusProviderWebsocketComponent>
|
||||
* ```
|
||||
*/
|
||||
export function HocuspocusRoom({
|
||||
children,
|
||||
name,
|
||||
document,
|
||||
token,
|
||||
sessionAwareness,
|
||||
flushDelay,
|
||||
...eventHandlers
|
||||
}: HocuspocusRoomProps) {
|
||||
const hocuspocusContext = useContext(HocuspocusContext);
|
||||
|
||||
if (!hocuspocusContext) {
|
||||
throw new Error(
|
||||
"HocuspocusRoom must be used within a HocuspocusProviderWebsocketComponent",
|
||||
);
|
||||
}
|
||||
|
||||
const { websocketProvider } = hocuspocusContext;
|
||||
|
||||
const [provider, setProvider] = useState(
|
||||
() =>
|
||||
new HocuspocusProvider({
|
||||
name,
|
||||
websocketProvider,
|
||||
document,
|
||||
token,
|
||||
sessionAwareness,
|
||||
flushDelay,
|
||||
}),
|
||||
);
|
||||
|
||||
const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Recreate provider when name, document, or token changes.
|
||||
// Only compare `document`/`token` when the caller actually provided them —
|
||||
// otherwise the provider's constructor-initialized defaults (auto-created
|
||||
// Y.Doc, null token) will never match the undefined prop, causing a destroy
|
||||
// on every render (and in StrictMode, a stray CloseMessage for the current
|
||||
// doc once the provider is already attached).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: provider.configuration holds the previous values we compare against — not a reactive dependency
|
||||
useEffect(() => {
|
||||
const shouldRecreate =
|
||||
provider.configuration.name !== name ||
|
||||
(document !== undefined &&
|
||||
provider.configuration.document !== document) ||
|
||||
(token !== undefined && provider.configuration.token !== token) ||
|
||||
provider.configuration.websocketProvider !== websocketProvider;
|
||||
|
||||
if (shouldRecreate) {
|
||||
provider.destroy();
|
||||
setProvider(
|
||||
new HocuspocusProvider({
|
||||
name,
|
||||
websocketProvider,
|
||||
document,
|
||||
token,
|
||||
sessionAwareness,
|
||||
flushDelay,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [name, document, token, websocketProvider]);
|
||||
|
||||
// Attach/detach lifecycle with deferred destruction for StrictMode
|
||||
useEffect(() => {
|
||||
if (destroyTimeoutRef.current) {
|
||||
clearTimeout(destroyTimeoutRef.current);
|
||||
destroyTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
provider.attach();
|
||||
|
||||
return () => {
|
||||
destroyTimeoutRef.current = setTimeout(() => {
|
||||
provider.destroy();
|
||||
}, 0);
|
||||
};
|
||||
}, [provider]);
|
||||
|
||||
// Wire up on* event handler props with stable refs
|
||||
const handlersRef = useRef(eventHandlers);
|
||||
handlersRef.current = eventHandlers;
|
||||
|
||||
useEffect(() => {
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
for (const [propName, eventName] of Object.entries(EVENT_PROP_MAP)) {
|
||||
const listener = (...args: unknown[]) => {
|
||||
const handler = handlersRef.current[
|
||||
propName as keyof typeof handlersRef.current
|
||||
] as ((...a: unknown[]) => void) | undefined;
|
||||
handler?.(...args);
|
||||
};
|
||||
provider.on(eventName, listener);
|
||||
cleanups.push(() => provider.off(eventName, listener));
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const cleanup of cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}, [provider]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
provider,
|
||||
}),
|
||||
[provider],
|
||||
);
|
||||
|
||||
return (
|
||||
<HocuspocusRoomContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</HocuspocusRoomContext.Provider>
|
||||
);
|
||||
}
|
||||
18
packages/provider-react/src/context.ts
Normal file
18
packages/provider-react/src/context.ts
Normal file
@@ -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);
|
||||
5
packages/provider-react/src/hooks/index.ts
Normal file
5
packages/provider-react/src/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { useHocuspocusAwareness } from "./useHocuspocusAwareness.ts";
|
||||
export { useHocuspocusConnectionStatus } from "./useHocuspocusConnectionStatus.ts";
|
||||
export { useHocuspocusEvent } from "./useHocuspocusEvent.ts";
|
||||
export { useHocuspocusProvider } from "./useHocuspocusProvider.ts";
|
||||
export { useHocuspocusSyncStatus } from "./useHocuspocusSyncStatus.ts";
|
||||
89
packages/provider-react/src/hooks/useHocuspocusAwareness.ts
Normal file
89
packages/provider-react/src/hooks/useHocuspocusAwareness.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useCallback, useEffect, 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();
|
||||
|
||||
const versionRef = useRef(0);
|
||||
const cacheRef = useRef<{ users: CollabUser[]; version: number }>({
|
||||
users: [],
|
||||
version: -1,
|
||||
});
|
||||
|
||||
// Reset cache when provider changes so we don't serve stale users
|
||||
// from a previous provider instance.
|
||||
useEffect(() => {
|
||||
versionRef.current++;
|
||||
cacheRef.current = { users: [], version: -1 };
|
||||
}, [provider]);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => {
|
||||
const onChange = () => {
|
||||
versionRef.current++;
|
||||
onStoreChange();
|
||||
};
|
||||
provider.awareness?.on("change", onChange);
|
||||
return () => {
|
||||
provider.awareness?.off("change", onChange);
|
||||
};
|
||||
},
|
||||
[provider],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
const awareness = provider.awareness;
|
||||
if (!awareness) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Return cached value if awareness hasn't changed since last snapshot
|
||||
if (cacheRef.current.version === versionRef.current) {
|
||||
return cacheRef.current.users;
|
||||
}
|
||||
|
||||
const users: CollabUser[] = [];
|
||||
awareness.getStates().forEach((state, clientId) => {
|
||||
users.push({
|
||||
clientId,
|
||||
...state,
|
||||
});
|
||||
});
|
||||
|
||||
cacheRef.current = { users, version: versionRef.current };
|
||||
return users;
|
||||
}, [provider]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback, 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 subscribe = useCallback(
|
||||
(onStoreChange: () => void) => {
|
||||
provider.on("status", onStoreChange);
|
||||
return () => {
|
||||
provider.off("status", onStoreChange);
|
||||
};
|
||||
},
|
||||
[provider],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(
|
||||
(): ConnectionStatus =>
|
||||
provider.configuration.websocketProvider.status as ConnectionStatus,
|
||||
[provider],
|
||||
);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
52
packages/provider-react/src/hooks/useHocuspocusEvent.ts
Normal file
52
packages/provider-react/src/hooks/useHocuspocusEvent.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { HocuspocusProviderEvents } from "../types.ts";
|
||||
import { useHocuspocusProvider } from "./useHocuspocusProvider.ts";
|
||||
|
||||
/**
|
||||
* Subscribe to events from the HocuspocusProvider for the current room.
|
||||
*
|
||||
* The handler is stored in a ref so the subscription stays stable even if
|
||||
* the handler identity changes between renders.
|
||||
*
|
||||
* Must be used within a HocuspocusRoom component.
|
||||
*
|
||||
* @param event - The event name to subscribe to
|
||||
* @param handler - Callback invoked when the event fires
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function AuthGuard() {
|
||||
* useHocuspocusEvent('authenticationFailed', (data) => {
|
||||
* console.error('Auth failed:', data.reason)
|
||||
* redirectToLogin()
|
||||
* })
|
||||
*
|
||||
* useHocuspocusEvent('close', (data) => {
|
||||
* console.log('Connection closed', data.event)
|
||||
* })
|
||||
*
|
||||
* return null
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHocuspocusEvent<E extends keyof HocuspocusProviderEvents>(
|
||||
event: E,
|
||||
handler: HocuspocusProviderEvents[E] extends undefined
|
||||
? () => void
|
||||
: (data: HocuspocusProviderEvents[E]) => void,
|
||||
): void {
|
||||
const provider = useHocuspocusProvider();
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (...args: unknown[]) => {
|
||||
(handlerRef.current as (...a: unknown[]) => void)(...args);
|
||||
};
|
||||
provider.on(event, listener);
|
||||
return () => {
|
||||
provider.off(event, listener);
|
||||
};
|
||||
}, [provider, event]);
|
||||
}
|
||||
37
packages/provider-react/src/hooks/useHocuspocusProvider.ts
Normal file
37
packages/provider-react/src/hooks/useHocuspocusProvider.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { HocuspocusRoomContext } from "../context.ts";
|
||||
|
||||
/**
|
||||
* Access the HocuspocusProvider instance for the current room.
|
||||
*
|
||||
* Must be used within a HocuspocusRoom component.
|
||||
*
|
||||
* @returns The HocuspocusProvider instance
|
||||
* @throws Error if used outside of HocuspocusRoom
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function Editor() {
|
||||
* const provider = useHocuspocusProvider()
|
||||
*
|
||||
* const editor = useEditor({
|
||||
* extensions: [
|
||||
* Collaboration.configure({ document: provider.document }),
|
||||
* CollaborationCursor.configure({ provider }),
|
||||
* ],
|
||||
* })
|
||||
*
|
||||
* return <EditorContent editor={editor} />
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHocuspocusProvider() {
|
||||
const context = useContext(HocuspocusRoomContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useHocuspocusProvider must be used within a HocuspocusRoom");
|
||||
}
|
||||
|
||||
return context.provider;
|
||||
}
|
||||
51
packages/provider-react/src/hooks/useHocuspocusSyncStatus.ts
Normal file
51
packages/provider-react/src/hooks/useHocuspocusSyncStatus.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
import type { SyncStatus } from "../types.ts";
|
||||
import { useHocuspocusProvider } from "./useHocuspocusProvider.ts";
|
||||
|
||||
/**
|
||||
* Subscribe to the sync status of local changes.
|
||||
*
|
||||
* This hook indicates whether local changes have been synced with the server.
|
||||
*
|
||||
* @returns The current sync status: 'synced' or 'syncing'
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function SaveIndicator() {
|
||||
* const syncStatus = useHocuspocusSyncStatus()
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {syncStatus === 'syncing' ? 'Saving...' : 'All changes saved'}
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHocuspocusSyncStatus(): SyncStatus {
|
||||
const provider = useHocuspocusProvider();
|
||||
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => {
|
||||
provider.on("synced", onStoreChange);
|
||||
provider.on("unsyncedChanges", onStoreChange);
|
||||
|
||||
return () => {
|
||||
provider.off("synced", onStoreChange);
|
||||
provider.off("unsyncedChanges", onStoreChange);
|
||||
};
|
||||
},
|
||||
[provider],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback((): SyncStatus => {
|
||||
// Check if there are unsynced changes
|
||||
if (provider.unsyncedChanges > 0) {
|
||||
return "syncing";
|
||||
}
|
||||
return "synced";
|
||||
}, [provider]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
29
packages/provider-react/src/index.ts
Normal file
29
packages/provider-react/src/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
// Contexts
|
||||
export { HocuspocusContext, HocuspocusRoomContext } from "./context.ts";
|
||||
|
||||
// Components
|
||||
export { HocuspocusProviderWebsocketComponent } from "./HocuspocusProviderWebsocketComponent.tsx";
|
||||
export { HocuspocusRoom } from "./HocuspocusRoom.tsx";
|
||||
|
||||
// Hooks
|
||||
export {
|
||||
useHocuspocusAwareness,
|
||||
useHocuspocusConnectionStatus,
|
||||
useHocuspocusEvent,
|
||||
useHocuspocusProvider,
|
||||
useHocuspocusSyncStatus,
|
||||
} from "./hooks/index.ts";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
CollabUser,
|
||||
ConnectionStatus,
|
||||
HocuspocusContextValue,
|
||||
HocuspocusProviderEvents,
|
||||
HocuspocusProviderWebsocketComponentProps,
|
||||
HocuspocusRoomContextValue,
|
||||
HocuspocusRoomProps,
|
||||
SyncStatus,
|
||||
} from "./types.ts";
|
||||
155
packages/provider-react/src/types.ts
Normal file
155
packages/provider-react/src/types.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type {
|
||||
HocuspocusProvider,
|
||||
HocuspocusProviderWebsocket,
|
||||
onAuthenticatedParameters,
|
||||
onAuthenticationFailedParameters,
|
||||
onAwarenessChangeParameters,
|
||||
onAwarenessUpdateParameters,
|
||||
onCloseParameters,
|
||||
onDisconnectParameters,
|
||||
onMessageParameters,
|
||||
onOpenParameters,
|
||||
onOutgoingMessageParameters,
|
||||
onStatelessParameters,
|
||||
onStatusParameters,
|
||||
onSyncedParameters,
|
||||
onUnsyncedChangesParameters,
|
||||
} from "@hocuspocus/provider";
|
||||
import type { ReactNode } from "react";
|
||||
import type * as Y from "yjs";
|
||||
|
||||
/**
|
||||
* Configuration for HocuspocusProvider component.
|
||||
*
|
||||
* Provide either a `url` to create a managed WebSocket connection,
|
||||
* or a `websocketProvider` instance for full control — but not both.
|
||||
*/
|
||||
export type HocuspocusProviderWebsocketComponentProps = {
|
||||
children: ReactNode;
|
||||
} & (
|
||||
| {
|
||||
/**
|
||||
* URL of your @hocuspocus/server instance
|
||||
*/
|
||||
url: string;
|
||||
websocketProvider?: never;
|
||||
}
|
||||
| {
|
||||
url?: never;
|
||||
/**
|
||||
* Provide your own websocket instance for full control
|
||||
*/
|
||||
websocketProvider: HocuspocusProviderWebsocket;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Map of HocuspocusProvider event names to their payload types.
|
||||
* Used by `useHocuspocusEvent` and the `HocuspocusRoom` event handler props.
|
||||
*/
|
||||
export interface HocuspocusProviderEvents {
|
||||
open: onOpenParameters;
|
||||
connect: undefined;
|
||||
close: onCloseParameters;
|
||||
disconnect: onDisconnectParameters;
|
||||
status: onStatusParameters;
|
||||
synced: onSyncedParameters;
|
||||
unsyncedChanges: onUnsyncedChangesParameters;
|
||||
message: onMessageParameters;
|
||||
outgoingMessage: onOutgoingMessageParameters;
|
||||
stateless: onStatelessParameters;
|
||||
authenticated: onAuthenticatedParameters;
|
||||
authenticationFailed: onAuthenticationFailedParameters;
|
||||
awarenessUpdate: onAwarenessUpdateParameters;
|
||||
awarenessChange: onAwarenessChangeParameters;
|
||||
destroy: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for HocuspocusRoom component
|
||||
*/
|
||||
export interface HocuspocusRoomProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* The document name (required)
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Optional: bring your own Y.Doc
|
||||
*/
|
||||
document?: Y.Doc;
|
||||
/**
|
||||
* JWT token or function that returns a promise resolving to a token
|
||||
*/
|
||||
token?: string | (() => Promise<string>) | (() => string);
|
||||
/**
|
||||
* Enable session-aware multiplexing. When true, the provider embeds a unique
|
||||
* sessionId in the documentName field of every message, allowing multiple
|
||||
* providers with the same document name on a single WebSocket connection.
|
||||
*
|
||||
* Only set this to `true` when connecting to a v4 server that does
|
||||
* support session awareness.
|
||||
*
|
||||
* Default: false
|
||||
*/
|
||||
sessionAwareness?: boolean;
|
||||
|
||||
/**
|
||||
* Batch outgoing document and awareness updates over a short window (in
|
||||
* milliseconds) instead of sending one message per change, to reduce the
|
||||
* number of websocket messages during heavy editing. The added latency is
|
||||
* capped at `flushDelay`, so keep it small (e.g. 500).
|
||||
*
|
||||
* Default: false (send every change immediately)
|
||||
*/
|
||||
flushDelay?: false | number;
|
||||
|
||||
// Event handlers — all optional, called when the provider emits the corresponding event.
|
||||
onOpen?: (data: onOpenParameters) => void;
|
||||
onConnect?: () => void;
|
||||
onClose?: (data: onCloseParameters) => void;
|
||||
onDisconnect?: (data: onDisconnectParameters) => void;
|
||||
onStatus?: (data: onStatusParameters) => void;
|
||||
onSynced?: (data: onSyncedParameters) => void;
|
||||
onUnsyncedChanges?: (data: onUnsyncedChangesParameters) => void;
|
||||
onMessage?: (data: onMessageParameters) => void;
|
||||
onOutgoingMessage?: (data: onOutgoingMessageParameters) => void;
|
||||
onStateless?: (data: onStatelessParameters) => void;
|
||||
onAuthenticated?: (data: onAuthenticatedParameters) => void;
|
||||
onAuthenticationFailed?: (data: onAuthenticationFailedParameters) => void;
|
||||
onAwarenessUpdate?: (data: onAwarenessUpdateParameters) => void;
|
||||
onAwarenessChange?: (data: onAwarenessChangeParameters) => void;
|
||||
onDestroy?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context value for the WebSocket provider
|
||||
*/
|
||||
export interface HocuspocusContextValue {
|
||||
websocketProvider: HocuspocusProviderWebsocket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context value for the room/document provider
|
||||
*/
|
||||
export interface HocuspocusRoomContextValue {
|
||||
provider: HocuspocusProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection status for the collaboration provider
|
||||
*/
|
||||
export type ConnectionStatus = "connecting" | "connected" | "disconnected";
|
||||
|
||||
/**
|
||||
* Sync status indicating whether local changes are synced with server
|
||||
*/
|
||||
export type SyncStatus = "synced" | "syncing";
|
||||
|
||||
/**
|
||||
* User information from awareness
|
||||
*/
|
||||
export interface CollabUser {
|
||||
clientId: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
6
packages/provider-react/tsconfig.json
Normal file
6
packages/provider-react/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user