feat: disconnect collab websocket on idle tabs (#848)

* disconnect real-time collab if user is idle
* log yjs document disconnect and unload in dev mode
* no longer set editor to read-only mode on collab websocket disconnection
* treat delayed collab websocket "connecting" state as disconnected
* increase maxDebounce to 45 seconds
* add reset handle to useIdle hook
This commit is contained in:
Philip Okugbe 2025-03-08 18:16:23 +00:00 committed by GitHub
parent dd52eb15ca
commit fd36076ae7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 119 additions and 12 deletions

View File

@ -41,7 +41,9 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { isCloud } from "@/lib/config.ts";
import { useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { FIVE_MINUTES } from "@/lib/constants.ts";
interface PageEditorProps {
pageId: string;
@ -69,6 +71,8 @@ export default function PageEditor({
const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`;
const { data } = useCollabToken();
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const localProvider = useMemo(() => {
const provider = new IndexeddbPersistence(documentName, ydoc);
@ -87,6 +91,7 @@ export default function PageEditor({
document: ydoc,
token: data?.token,
connect: false,
preserveConnection: false,
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
@ -126,6 +131,7 @@ export default function PageEditor({
extensions,
editable,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
scrollThreshold: 80,
scrollMargin: 80,
@ -158,7 +164,7 @@ export default function PageEditor({
}
},
},
[pageId, editable, remoteProvider],
[pageId, editable, remoteProvider?.status],
);
const handleActiveCommentEvent = (event) => {
@ -188,16 +194,31 @@ export default function PageEditor({
}, [pageId]);
useEffect(() => {
if (isCloud()) return;
if (editable) {
if (yjsConnectionStatus === WebSocketStatus.Connected) {
editor.setEditable(true);
} else {
// disable edits if connection fails
editor.setEditable(false);
}
if (remoteProvider?.status === WebSocketStatus.Connecting) {
const timeout = setTimeout(() => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
}, 5000);
return () => clearTimeout(timeout);
}
}, [yjsConnectionStatus]);
}, [remoteProvider?.status]);
useEffect(() => {
if (
isIdle &&
documentState === "hidden" &&
remoteProvider?.status === WebSocketStatus.Connected
) {
remoteProvider.disconnect();
}
if (
documentState === "visible" &&
remoteProvider?.status === WebSocketStatus.Disconnected
) {
remoteProvider.connect();
resetIdle();
}
}, [isIdle, documentState, remoteProvider?.status]);
const isSynced = isLocalSynced && isRemoteSynced;

View File

@ -0,0 +1,58 @@
// Mantine Idle hook to support reset handle - MIT
//src: https://github.com/mantinedev/mantine/blob/06018d0beff22caa7b36d796e56ad597cc5c23f7/packages/%40mantine/hooks/src/use-idle/use-idle.ts
import { useEffect, useRef, useState } from "react";
const DEFAULT_EVENTS: (keyof DocumentEventMap)[] = [
"keypress",
"mousemove",
"touchmove",
"click",
"scroll",
];
const DEFAULT_OPTIONS = {
events: DEFAULT_EVENTS,
initialState: true,
};
export function useIdle(
timeout: number,
options?: Partial<{
events: (keyof DocumentEventMap)[];
initialState: boolean;
}>,
) {
const { events, initialState } = { ...DEFAULT_OPTIONS, ...options };
const [idle, setIdle] = useState<boolean>(initialState);
const timer = useRef<number>(-1);
const reset = () => {
setIdle(false);
if (timer.current) {
window.clearTimeout(timer.current);
}
timer.current = window.setTimeout(() => {
setIdle(true);
}, timeout);
};
useEffect(() => {
const handleEvents = () => {
reset();
};
events.forEach((event) => document.addEventListener(event, handleEvents));
// Start the timer immediately instead of waiting for the first event to happen
timer.current = window.setTimeout(() => {
setIdle(true);
}, timeout);
return () => {
events.forEach((event) =>
document.removeEventListener(event, handleEvents),
);
};
}, [timeout, events]);
return { isIdle: idle, resetIdle: reset };
}

View File

@ -1,2 +1,4 @@
export const INTERNAL_LINK_REGEX =
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
export const FIVE_MINUTES = 5 * 60 * 1000;

View File

@ -13,6 +13,7 @@
"start:debug": "cross-env NODE_ENV=development nest start --debug --watch",
"start:prod": "cross-env NODE_ENV=production node dist/main",
"collab:prod": "cross-env NODE_ENV=production node dist/collaboration/server/collab-main",
"collab:dev": "cross-env NODE_ENV=development node dist/collaboration/server/collab-main",
"email:dev": "email dev -p 5019 -d ./src/integrations/transactional/emails",
"migration:create": "tsx src/database/migrate.ts create",
"migration:up": "tsx src/database/migrate.ts up",

View File

@ -11,6 +11,7 @@ import {
parseRedisUrl,
RedisConfig,
} from '../common/helpers';
import { LoggerExtension } from './extensions/logger.extension';
@Injectable()
export class CollaborationGateway {
@ -20,17 +21,19 @@ export class CollaborationGateway {
constructor(
private authenticationExtension: AuthenticationExtension,
private persistenceExtension: PersistenceExtension,
private loggerExtension: LoggerExtension,
private environmentService: EnvironmentService,
) {
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
this.hocuspocus = HocuspocusServer.configure({
debounce: 10000,
maxDebounce: 20000,
maxDebounce: 45000,
unloadImmediately: false,
extensions: [
this.authenticationExtension,
this.persistenceExtension,
this.loggerExtension,
...(this.environmentService.isCollabDisableRedis()
? []
: [

View File

@ -8,12 +8,14 @@ import { IncomingMessage } from 'http';
import { WebSocket } from 'ws';
import { TokenModule } from '../core/auth/token.module';
import { HistoryListener } from './listeners/history.listener';
import { LoggerExtension } from './extensions/logger.extension';
@Module({
providers: [
CollaborationGateway,
AuthenticationExtension,
PersistenceExtension,
LoggerExtension,
HistoryListener,
],
exports: [CollaborationGateway],

View File

@ -0,0 +1,19 @@
import {
Extension,
onDisconnectPayload,
onLoadDocumentPayload,
} from '@hocuspocus/server';
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class LoggerExtension implements Extension {
private readonly logger = new Logger('Collab' + LoggerExtension.name);
async onDisconnect(data: onDisconnectPayload) {
this.logger.debug(`User disconnected from "${data.documentName}".`);
}
async afterUnloadDocument(data: onLoadDocumentPayload) {
this.logger.debug('Unloaded ' + data.documentName + ' from memory');
}
}

View File

@ -7,6 +7,7 @@
"build": "nx run-many -t build",
"start": "pnpm --filter ./apps/server run start:prod",
"collab": "pnpm --filter ./apps/server run collab:prod",
"collab:dev": "pnpm --filter ./apps/server run collab:dev",
"server:build": "nx run server:build",
"client:build": "nx run client:build",
"editor-ext:build": "nx run @docmost/editor-ext:build",