fix: pages loading repeatedly after reconnect (#4539)

* fix: loading bug resolved

* fix: better state management while disconnecting and reconnecting

* fix: connected state after synced
This commit is contained in:
M. Palanikannan
2025-10-23 18:37:58 +05:30
committed by GitHub
parent 2a99c54158
commit 4708f56e76
7 changed files with 141 additions and 111 deletions
@@ -1,10 +1,10 @@
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
import { CollaborativeDocumentEditorWithRef } from "@plane/editor";
import type {
CollaborationState,
EditorRefApi,
EditorTitleRefApi,
TAIMenuProps,
@@ -57,7 +57,6 @@ type Props = {
config: TEditorBodyConfig;
editorReady: boolean;
editorForwardRef: React.RefObject<EditorRefApi>;
handleConnectionStatus: Dispatch<SetStateAction<boolean>>;
handleEditorReady: (status: boolean) => void;
handleOpenNavigationPane: () => void;
handlers: TEditorBodyHandlers;
@@ -71,14 +70,13 @@ type Props = {
// Extended editor extensions configuration
extendedEditorProps: TExtendedEditorExtensionsConfig;
isFetchingFallbackBinary?: boolean;
hasServerConnectionFailed?: boolean;
onCollaborationStateChange?: (state: CollaborationState) => void;
};
export const PageEditorBody: React.FC<Props> = observer((props) => {
const {
config,
editorForwardRef,
handleConnectionStatus,
handleEditorReady,
handleOpenNavigationPane,
handlers,
@@ -91,7 +89,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
customRealtimeEventHandlers,
extendedEditorProps,
isFetchingFallbackBinary,
hasServerConnectionFailed,
onCollaborationStateChange,
} = props;
// states
@@ -156,10 +154,14 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
handlers,
});
// Set syncing status on initial render
// Set syncing status when page changes and reset collaboration state
useEffect(() => {
setSyncingStatus("syncing");
}, [setSyncingStatus]);
onCollaborationStateChange?.({
connectionStatus: "connecting",
syncStatus: "syncing",
});
}, [pageId, setSyncingStatus, onCollaborationStateChange]);
const getAIMenu = useCallback(
({ isOpen, onClose }: TAIMenuProps) => (
@@ -174,26 +176,24 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
[editorRef, workspaceId, workspaceSlug]
);
const handleServerConnect = useCallback(() => {
handleConnectionStatus(false);
}, [handleConnectionStatus]);
const handleServerError = useCallback(() => {
handleConnectionStatus(true);
setSyncingStatus("error");
}, [handleConnectionStatus, setSyncingStatus]);
const handleServerSynced = useCallback(() => {
setSyncingStatus("synced");
}, [setSyncingStatus]);
const serverHandler: TServerHandler = useMemo(
() => ({
onConnect: handleServerConnect,
onServerError: handleServerError,
onServerSynced: handleServerSynced,
onStateChange: (state) => {
// Pass full state to parent
onCollaborationStateChange?.(state);
// Update local syncing status for UI
if (state.connectionStatus === "disconnected") {
setSyncingStatus("error");
} else if (state.connectionStatus === "connected" && state.syncStatus === "synced") {
setSyncingStatus("synced");
} else {
// Handles "connecting", "reconnecting", "connected but not synced", etc.
setSyncingStatus("syncing");
}
},
}),
[handleServerConnect, handleServerError, handleServerSynced]
[setSyncingStatus, onCollaborationStateChange]
);
const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => {
@@ -320,7 +320,6 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
}}
extendedEditorProps={extendedEditorProps}
isFetchingFallbackBinary={isFetchingFallbackBinary}
hasServerConnectionFailed={hasServerConnectionFailed}
/>
</div>
</div>
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import type { CollaborationState, EditorRefApi } from "@plane/editor";
import { TOAST_TYPE, updateToast } from "@plane/propel/toast";
import type { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types";
// hooks
@@ -19,10 +19,10 @@ import type { TPageInstance } from "@/store/pages/base-page";
import { PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, PageNavigationPaneRoot } from "../navigation-pane";
import { PageVersionsOverlay } from "../version";
import { PagesVersionEditor } from "../version/editor";
import { ContentLimitBanner } from "./content-limit-banner";
import { PageEditorBody } from "./editor-body";
import type { TEditorBodyConfig, TEditorBodyHandlers } from "./editor-body";
import { PageEditorToolbarRoot } from "./toolbar";
import { ContentLimitBanner } from "./content-limit-banner";
export type TPageRootHandlers = {
create: (payload: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
@@ -57,23 +57,21 @@ export const PageRoot = observer((props: TPageRootProps) => {
workspaceSlug,
customRealtimeEventHandlers,
} = props;
// states
const [editorReady, setEditorReady] = useState(false);
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
const [collaborationState, setCollaborationState] = useState<CollaborationState | null>(null);
const [showContentTooLargeBanner, setShowContentTooLargeBanner] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
// derived values
const { isNestedPagesEnabled } = usePageStore(storeType);
const {
isContentEditable,
editor: { setEditorRef },
} = page;
// page fallback
const { isFetchingFallbackBinary } = usePageFallback({
editorRef,
fetchPageDescription: handlers.fetchDescriptionBinary,
hasConnectionFailed,
collaborationState,
updatePageDescription: handlers.updateDescription,
});
@@ -156,7 +154,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
[version, workspaceSlug, page, handlers, editorRef, isNestedPagesEnabled]
);
// reset editor ref on unmount
useEffect(
() => () => {
setEditorRef(null);
@@ -186,7 +183,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
customRealtimeEventHandlers={mergedCustomEventHandlers}
editorReady={editorReady}
editorForwardRef={editorRef}
handleConnectionStatus={setHasConnectionFailed}
handleEditorReady={handleEditorReady}
handleOpenNavigationPane={handleOpenNavigationPane}
handlers={handlers}
@@ -198,7 +194,7 @@ export const PageRoot = observer((props: TPageRootProps) => {
workspaceSlug={workspaceSlug}
extendedEditorProps={extendedEditorProps}
isFetchingFallbackBinary={isFetchingFallbackBinary}
hasServerConnectionFailed={hasConnectionFailed}
onCollaborationStateChange={setCollaborationState}
/>
</div>
<PageNavigationPaneRoot
+6 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { EditorRefApi } from "@plane/editor";
import type { EditorRefApi, CollaborationState } from "@plane/editor";
// plane editor
import { getBinaryDataFromDocumentEditorHTMLString } from "@plane/editor";
// plane propel
@@ -12,16 +12,19 @@ import useAutoSave from "@/hooks/use-auto-save";
type TArgs = {
editorRef: React.RefObject<EditorRefApi>;
fetchPageDescription: () => Promise<ArrayBuffer>;
hasConnectionFailed: boolean;
collaborationState: CollaborationState | null;
updatePageDescription: (data: TDocumentPayload) => Promise<void>;
};
export const usePageFallback = (args: TArgs) => {
const { editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription } = args;
const { editorRef, fetchPageDescription, collaborationState, updatePageDescription } = args;
const hasShownFallbackToast = useRef(false);
const [isFetchingFallbackBinary, setIsFetchingFallbackBinary] = useState(false);
// Derive connection failure from collaboration state
const hasConnectionFailed = collaborationState?.connectionStatus === "disconnected";
const handleUpdateDescription = useCallback(async () => {
if (!hasConnectionFailed) return;
const editor = editorRef.current;
@@ -49,11 +49,16 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
titleRef,
updatePageProperties,
isFetchingFallbackBinary,
hasServerConnectionFailed,
} = props;
// use document editor
const { editor, titleEditor, isEditorContentReady, isContentInIndexedDb } = useCollaborativeEditor({
const {
editor,
titleEditor,
isEditorContentReady,
isContentInIndexedDb,
hasServerConnectionFailed: connectionFailed
} = useCollaborativeEditor({
disabledExtensions,
editable,
editorClassName,
@@ -90,7 +95,7 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
if (!editor || !titleEditor) return null;
const shouldWaitForFallbackBinary = isFetchingFallbackBinary && !isContentInIndexedDb && hasServerConnectionFailed;
const shouldWaitForFallbackBinary = isFetchingFallbackBinary && !isContentInIndexedDb && connectionFailed;
const isLoading = !isEditorContentReady || shouldWaitForFallbackBinary || pageRestorationInProgress;
return (
@@ -13,6 +13,7 @@ import { useRealtimeEvents } from "@/hooks/use-realtime-events";
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import { TCollaborativeEditorHookProps } from "@/types";
import type { CollaborationState, ConnectionStatus, SyncStatus } from "@/types/collaboration";
// local imports
import { useEditorNavigation } from "./use-editor-navigation";
import { useTitleEditor } from "./use-title-editor";
@@ -62,13 +63,20 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
user,
} = props;
// State machine for collaboration status
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("initial");
const [syncStatus, setSyncStatus] = useState<SyncStatus>("syncing");
const [collaborationError, setCollaborationError] = useState<CollaborationState["error"]>();
// Sync states for loading management
const [isContentInIndexedDb, setIsContentInIndexedDb] = useState(false);
const [hasServerSynced, setHasServerSynced] = useState(false);
const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false);
const [isIndexedDbSynced, setIsIndexedDbSynced] = useState(false);
const [isEditorContentReady, setIsEditorContentReady] = useState(false);
// Derived values for backward compatibility
const hasServerSynced = syncStatus === "synced";
const hasServerConnectionFailed = connectionStatus === "disconnected";
// Connection tracking (ref avoids closure issues in provider callbacks)
const connectionRef = useRef({
connectionAttempts: 0,
@@ -76,51 +84,71 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
forceCloseReceived: false,
});
// Constants
const maxConnectionAttempts = 3;
// Create navigation handlers
const { mainNavigationExtension, titleNavigationExtension, setMainEditor, setTitleEditor } = useEditorNavigation();
// Reset force close state when document ID changes
useEffect(() => {
setConnectionStatus("initial");
setSyncStatus("syncing");
setCollaborationError(undefined);
setIsContentInIndexedDb(false);
setIsIndexedDbSynced(false);
setIsEditorContentReady(false);
connectionRef.current.connectionAttempts = 0;
connectionRef.current.forceCloseReceived = false;
connectionRef.current.isPermanentlyStopped = false;
}, [id]);
// Initialize Hocuspocus provider for real-time collaboration
useEffect(() => {
const state: CollaborationState = {
connectionStatus,
syncStatus,
error: collaborationError,
};
serverHandler.onStateChange(state);
}, [connectionStatus, syncStatus, collaborationError, serverHandler]);
const provider = useMemo(
() =>
new HocuspocusProvider({
name: id,
// using user id as a token to verify the user on the server
token: JSON.stringify(user),
url: realtimeConfig.url,
// NOTE: maxAttempts config is broken in Hocuspocus (see GitHub issue #762)
// We handle retry limiting manually in the onClose handler below
onAuthenticationFailed: () => {
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
setConnectionStatus("disconnected");
setCollaborationError({ type: "auth-failed", message: "Authentication failed" });
},
onConnect: () => {
// If we've permanently stopped, reject this connection (zombie reconnection from Hocuspocus)
if (connectionRef.current.isPermanentlyStopped) {
provider?.disconnect();
return;
}
// Reset retry counter on successful connection
connectionRef.current.connectionAttempts = 0;
setConnectionStatus("connected");
setCollaborationError(undefined);
},
onStatus: (status) => {
if (status.status === "disconnected") {
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
if (status.status === "connecting") {
const isReconnecting = connectionRef.current.connectionAttempts > 0;
setConnectionStatus(isReconnecting ? "reconnecting" : "connecting");
setSyncStatus("syncing"); // Reset sync status during connection attempts
} else if (status.status === "disconnected") {
setConnectionStatus("disconnected");
setSyncStatus("syncing"); // Reset sync status when disconnected
setCollaborationError({ type: "network-error", message: "Connection lost" });
} else if (status.status === "connected") {
setConnectionStatus("connected");
setCollaborationError(undefined);
}
},
onSynced: () => {
// Reset retry counter on successful sync (stable connection)
connectionRef.current.connectionAttempts = 0;
serverHandler?.onServerSynced?.();
setConnectionStatus("connected"); // Defensive: synced implies connected
setSyncStatus("synced");
setCollaborationError(undefined);
const workspaceSlug = new URLSearchParams(realtimeConfig.url).get("workspaceSlug");
const projectId = new URLSearchParams(realtimeConfig.url).get("projectId");
provider.sendStateless(
@@ -130,77 +158,70 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
projectId: projectId,
})
);
setHasServerSynced(true);
},
}),
[id, realtimeConfig.url, serverHandler, user]
[id, realtimeConfig.url, user]
);
// Initialize local persistence using IndexedDB
const localProvider = useMemo(
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
[id, provider]
);
// Helper function to permanently stop the provider and prevent all reconnection attempts
const permanentlyStopProvider = useCallback(() => {
const wsProvider = provider.configuration?.websocketProvider;
// Set flag FIRST to prevent any close event handlers from running
connectionRef.current.isPermanentlyStopped = true;
// Disable reconnection in WebSocket provider
if (wsProvider) {
wsProvider.shouldConnect = false;
}
// Destroy the provider completely (kills all internal timers and listeners)
try {
provider.destroy();
} catch (error) {
console.error(`Error destroying provider:`, error);
console.error(`Error destroying provider:`, error);
}
}, [provider]);
// Handle connection close events - check close code to determine behavior
useEffect(() => {
const handleClose = (event: { event?: { code?: number; reason?: string } }) => {
const closeCode = event.event?.code;
const conn = connectionRef.current;
// CRITICAL: If we've already permanently stopped, ignore ALL close events
if (conn.isPermanentlyStopped) {
return;
}
// Forced close can be detected in two ways:
// 1. Close code 4000-4003 (if transmitted correctly by WebSocket)
// 2. force_close message received before close event (backup method)
const isForcedClose = isForcedCloseCode(closeCode) || conn.forceCloseReceived;
if (isForcedClose) {
// Trigger fallback mechanism (show error UI to user)
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
setConnectionStatus("disconnected");
setSyncStatus("syncing"); // Reset sync status on forced close
setCollaborationError({
type: "forced-close",
code: closeCode || 0,
message: "Server forced connection close",
});
// Permanently stop the provider (destroys all timers)
permanentlyStopProvider();
// Reset values
conn.connectionAttempts = 0;
conn.forceCloseReceived = false;
} else {
// Increment connection attempts counter
conn.connectionAttempts++;
// Always trigger fallback
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
// Stop after max attempts
if (conn.connectionAttempts >= maxConnectionAttempts) {
// Permanently stop the provider (destroys all timers and prevents zombie reconnections)
setConnectionStatus("disconnected");
setSyncStatus("syncing"); // Reset sync status after max retries
setCollaborationError({
type: "max-retries",
message: `Failed to connect after ${maxConnectionAttempts} attempts`,
});
permanentlyStopProvider();
} else {
setConnectionStatus("reconnecting");
setSyncStatus("syncing"); // Reset sync status during reconnection
setCollaborationError({ type: "network-error", message: "Connection lost, reconnecting..." });
}
}
};
@@ -210,7 +231,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
return () => {
provider?.off("close", handleClose);
};
}, [provider, serverHandler, id, maxConnectionAttempts, permanentlyStopProvider]);
}, [provider, maxConnectionAttempts, permanentlyStopProvider]);
useEffect(() => {
if (!localProvider) return;
@@ -234,7 +255,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
};
}, [localProvider, id]);
// Clear IndexedDB if document is truly empty after both server and IndexedDB have synced
useEffect(() => {
if (!isIndexedDbSynced || !hasServerSynced || !localProvider) return;
const docLength = localProvider.doc.share.get("default")?._length;
@@ -245,7 +265,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
}
}, [isIndexedDbSynced, hasServerSynced, localProvider, id]);
// Mark editor content as ready when sync process completes
useEffect(() => {
if (!isIndexedDbSynced) {
return;
@@ -262,20 +281,15 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
}
}, [isIndexedDbSynced, isContentInIndexedDb, hasServerSynced, hasServerConnectionFailed, id]);
// Clean up providers on unmount
useEffect(
() => () => {
// CRITICAL: Set permanently stopped flag BEFORE destroying
// This prevents the onClose handler from processing the unmount close event
connectionRef.current.isPermanentlyStopped = true;
provider?.destroy();
localProvider?.destroy();
},
[provider, localProvider]
);
// Initialize main document editor
const editor = useEditor({
disabledExtensions,
extendedEditorProps,
@@ -285,13 +299,11 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
editorClassName,
enableHistory: false,
extensions: [
// Core extensions
SideMenuExtension({
aiEnabled: !disabledExtensions?.includes("ai"),
dragDropEnabled,
}),
HeadingListExtension,
// Collaboration extension
Collaboration.configure({
document: provider.document,
field: "default",
@@ -306,7 +318,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
provider,
userDetails: user,
}),
// Navigation extension for keyboard shortcuts
mainNavigationExtension,
],
fileHandler,
@@ -324,12 +335,10 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
tabIndex,
});
// Create setter function for realtime events hook
const setForceCloseReceived = useCallback((value: boolean) => {
connectionRef.current.forceCloseReceived = value;
}, []);
// Use the new hook for realtime events
useRealtimeEvents({
editor,
provider,
@@ -338,7 +347,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
setForceCloseReceived,
});
// Initialize title editor
const titleEditor = useTitleEditor({
id,
editable,
@@ -346,19 +354,15 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
titleRef,
updatePageProperties,
extensions: [
// Collaboration extension for title field
Collaboration.configure({
document: provider.document,
field: "title",
}),
// Navigation extension for keyboard shortcuts
titleNavigationExtension,
],
extendedEditorProps,
});
// Connect editors for navigation once they're initialized
useEffect(() => {
if (editor && titleEditor) {
setMainEditor(editor);
@@ -369,6 +373,9 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
return {
editor,
titleEditor,
connectionStatus,
syncStatus,
collaborationError,
hasServerSynced,
hasServerConnectionFailed,
isContentInIndexedDb,
@@ -1,5 +1,26 @@
export type ConnectionStatus =
| "initial"
| "connecting"
| "connected"
| "reconnecting"
| "disconnected";
export type SyncStatus =
| "syncing"
| "synced";
export type CollaborationError =
| { type: "auth-failed"; message: string }
| { type: "network-error"; message: string }
| { type: "forced-close"; code: number; message: string }
| { type: "max-retries"; message: string };
export type CollaborationState = {
connectionStatus: ConnectionStatus;
syncStatus: SyncStatus;
error?: CollaborationError;
};
export type TServerHandler = {
onConnect?: () => void;
onServerError?: () => void;
onServerSynced?: () => void;
onStateChange: (state: CollaborationState) => void;
};
+1 -2
View File
@@ -198,7 +198,7 @@ export type ICollaborativeDocumentEditorProps = Omit<IEditorProps, "initialValue
dragDropEnabled?: boolean;
editable: boolean;
realtimeConfig: TRealtimeConfig;
serverHandler?: TServerHandler;
serverHandler: TServerHandler;
user: TUserDetails;
updatePageProperties?: <T extends keyof EventToPayloadMap>(
pageIds: string | string[],
@@ -210,7 +210,6 @@ export type ICollaborativeDocumentEditorProps = Omit<IEditorProps, "initialValue
titleRef?: React.MutableRefObject<EditorTitleRefApi | null>;
extendedDocumentEditorProps?: ICollaborativeDocumentEditorPropsExtended;
isFetchingFallbackBinary?: boolean;
hasServerConnectionFailed?: boolean;
};
export type IDocumentEditorProps = Omit<IEditorProps, "initialValue" | "onEnterKeyPress" | "value"> & {