diff --git a/apps/client/src/ee/billing/components/billing-details.tsx b/apps/client/src/ee/billing/components/billing-details.tsx index e8534457..f3010802 100644 --- a/apps/client/src/ee/billing/components/billing-details.tsx +++ b/apps/client/src/ee/billing/components/billing-details.tsx @@ -154,7 +154,7 @@ export default function BillingDetails() { Current Tier - For up to {billing.tieredUpTo} users + For {billing.tieredUpTo} users {/*billing.tieredFlatAmount && ( diff --git a/apps/client/src/ee/billing/components/billing-plans.tsx b/apps/client/src/ee/billing/components/billing-plans.tsx index 5a287bba..8d5f28d3 100644 --- a/apps/client/src/ee/billing/components/billing-plans.tsx +++ b/apps/client/src/ee/billing/components/billing-plans.tsx @@ -155,7 +155,7 @@ export default function BillingPlans() { )} - for up to {planSelectedTier.upTo} users + For {planSelectedTier.upTo} users diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 37b28d7f..ec937ff9 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -1,7 +1,6 @@ import "@/features/editor/styles/index.css"; import React, { useEffect, - useLayoutEffect, useMemo, useRef, useState, @@ -72,7 +71,11 @@ export default function PageEditor({ const [, setAsideState] = useAtom(asideStateAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); - const ydoc = useMemo(() => new Y.Doc(), [pageId]); + const ydocRef = useRef(null); + if (!ydocRef.current) { + ydocRef.current = new Y.Doc(); + } + const ydoc = ydocRef.current; const [isLocalSynced, setLocalSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false); const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( @@ -89,66 +92,100 @@ export default function PageEditor({ const userPageEditMode = currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; - const localProvider = useMemo(() => { - const provider = new IndexeddbPersistence(documentName, ydoc); + // Providers only created once per pageId + const providersRef = useRef<{ + local: IndexeddbPersistence; + remote: HocuspocusProvider; + } | null>(null); + const [providersReady, setProvidersReady] = useState(false); - provider.on("synced", () => { - setLocalSynced(true); - }); + const localProvider = providersRef.current?.local; + const remoteProvider = providersRef.current?.remote; - return provider; - }, [pageId, ydoc]); + // Track when collaborative provider is ready and synced + const [collabReady, setCollabReady] = useState(false); + useEffect(() => { + if ( + remoteProvider?.status === WebSocketStatus.Connected && + isLocalSynced && + isRemoteSynced + ) { + setCollabReady(true); + } + }, [remoteProvider?.status, isLocalSynced, isRemoteSynced]); - const remoteProvider = useMemo(() => { - const provider = new HocuspocusProvider({ - name: documentName, - url: collaborationURL, - document: ydoc, - token: collabQuery?.token, - connect: false, - preserveConnection: false, - onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => { - const payload = jwtDecode(collabQuery?.token); - const now = Date.now().valueOf() / 1000; - const isTokenExpired = now >= payload.exp; - if (isTokenExpired) { - refetchCollabToken(); - } - }, - onStatus: (status) => { - if (status.status === "connected") { - setYjsConnectionStatus(status.status); - } - }, - }); - - provider.on("synced", () => { - setRemoteSynced(true); - }); - - provider.on("disconnect", () => { - setYjsConnectionStatus(WebSocketStatus.Disconnected); - }); - - return provider; - }, [ydoc, pageId, collabQuery?.token]); - - useLayoutEffect(() => { - remoteProvider.connect(); + useEffect(() => { + if (!providersRef.current) { + const local = new IndexeddbPersistence(documentName, ydoc); + local.on("synced", () => setLocalSynced(true)); + const remote = new HocuspocusProvider({ + name: documentName, + url: collaborationURL, + document: ydoc, + token: collabQuery?.token, + connect: true, + preserveConnection: false, + onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => { + const payload = jwtDecode(collabQuery?.token); + const now = Date.now().valueOf() / 1000; + const isTokenExpired = now >= payload.exp; + if (isTokenExpired) { + refetchCollabToken(); + } + }, + onStatus: (status) => { + if (status.status === "connected") { + setYjsConnectionStatus(status.status); + } + }, + }); + remote.on("synced", () => setRemoteSynced(true)); + remote.on("disconnect", () => { + setYjsConnectionStatus(WebSocketStatus.Disconnected); + }); + providersRef.current = { local, remote }; + setProvidersReady(true); + } else { + setProvidersReady(true); + } + // Only destroy on final unmount return () => { - setRemoteSynced(false); - setLocalSynced(false); - remoteProvider.destroy(); - localProvider.destroy(); + providersRef.current?.remote.destroy(); + providersRef.current?.local.destroy(); + providersRef.current = null; }; - }, [remoteProvider, localProvider]); + }, [pageId]); + + // Only connect/disconnect on tab/idle, not destroy + useEffect(() => { + if (!providersReady || !providersRef.current) return; + const remoteProvider = providersRef.current.remote; + if ( + isIdle && + documentState === "hidden" && + remoteProvider.status === WebSocketStatus.Connected + ) { + remoteProvider.disconnect(); + setIsCollabReady(false); + return; + } + if ( + documentState === "visible" && + remoteProvider.status === WebSocketStatus.Disconnected + ) { + resetIdle(); + remoteProvider.connect(); + setTimeout(() => setIsCollabReady(true), 500); + } + }, [isIdle, documentState, providersReady, resetIdle]); const extensions = useMemo(() => { + if (!remoteProvider || !currentUser?.user) return mainExtensions; return [ ...mainExtensions, ...collabExtensions(remoteProvider, currentUser?.user), ]; - }, [ydoc, pageId, remoteProvider, currentUser?.user]); + }, [remoteProvider, currentUser?.user]); const editor = useEditor( { @@ -202,7 +239,7 @@ export default function PageEditor({ debouncedUpdateContent(editorJson); }, }, - [pageId, editable, remoteProvider?.status], + [pageId, editable, remoteProvider], ); const debouncedUpdateContent = useDebouncedCallback((newContent: any) => { @@ -255,29 +292,6 @@ export default function PageEditor({ } }, [remoteProvider?.status]); - useEffect(() => { - if ( - isIdle && - documentState === "hidden" && - remoteProvider?.status === WebSocketStatus.Connected - ) { - remoteProvider.disconnect(); - setIsCollabReady(false); - return; - } - - if ( - documentState === "visible" && - remoteProvider?.status === WebSocketStatus.Disconnected - ) { - resetIdle(); - remoteProvider.connect(); - setTimeout(() => { - setIsCollabReady(true); - }, 600); - } - }, [isIdle, documentState, remoteProvider]); - const isSynced = isLocalSynced && isRemoteSynced; useEffect(() => { @@ -294,21 +308,48 @@ export default function PageEditor({ }, [isRemoteSynced, isLocalSynced, remoteProvider?.status]); useEffect(() => { - // honor user default page edit mode preference - if (userPageEditMode && editor && editable && isSynced) { - if (userPageEditMode === PageEditMode.Edit) { - editor.setEditable(true); - } else if (userPageEditMode === PageEditMode.Read) { + // Only honor user default page edit mode preference and permissions + if (editor) { + if (userPageEditMode && editable) { + if (userPageEditMode === PageEditMode.Edit) { + editor.setEditable(true); + } else if (userPageEditMode === PageEditMode.Read) { + editor.setEditable(false); + } + } else { editor.setEditable(false); } } - }, [userPageEditMode, editor, editable, isSynced]); + }, [userPageEditMode, editor, editable]); - return isCollabReady ? ( - + const hasConnectedOnceRef = useRef(false); + const [showStatic, setShowStatic] = useState(true); + + useEffect(() => { + if ( + !hasConnectedOnceRef.current && + remoteProvider?.status === WebSocketStatus.Connected + ) { + hasConnectedOnceRef.current = true; + setShowStatic(false); + } + }, [remoteProvider?.status]); + + if (showStatic) { + return ( + + ); + } + + return ( + - {editor && editor.isEditable && ( @@ -322,21 +363,12 @@ export default function PageEditor({ )} - {showCommentPopup && } - editor.commands.focus("end")} style={{ paddingBottom: "20vh" }} > - ) : ( - ); }