From aacad485859d45510f3f34efae1bb9aa32bfacdf Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 28 Feb 2026 21:54:16 -0500 Subject: [PATCH] fix: Flashing of sidebar on initial load (#11607) * fix: Flashing of sidebar on initial load * refactor * refactor * Remove toggleComments * fix: Skip animation on page load * feedback --- app/actions/definitions/documents.tsx | 2 +- app/components/AuthenticatedLayout.tsx | 75 +++--------- app/components/Layout.tsx | 9 +- app/components/RightSidebarContext.tsx | 57 +++++++++ app/components/Sidebar/Right.tsx | 14 ++- .../components/Comments/CommentForm.tsx | 40 ++++--- .../components/Comments/CommentThreadItem.tsx | 22 ++-- .../Document/components/Comments/Comments.tsx | 4 +- app/scenes/Document/components/DataLoader.tsx | 3 + .../Document/components/DocumentMeta.tsx | 2 +- app/scenes/Document/components/Editor.tsx | 6 +- .../Document/components/SidebarLayout.tsx | 46 +++++--- .../Document/hooks/useDocumentSidebar.tsx | 109 ++++++++++++++++++ app/stores/UiStore.ts | 13 +-- 14 files changed, 271 insertions(+), 131 deletions(-) create mode 100644 app/components/RightSidebarContext.tsx create mode 100644 app/scenes/Document/hooks/useDocumentSidebar.tsx diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 8192223506..4d80718569 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -1381,7 +1381,7 @@ export const openDocumentComments = createAction({ return; } - stores.ui.toggleComments(); + stores.ui.set({ rightSidebar: "comments" }); }, }); diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx index 7bd5a65438..bb91b5a451 100644 --- a/app/components/AuthenticatedLayout.tsx +++ b/app/components/AuthenticatedLayout.tsx @@ -1,17 +1,10 @@ -import { AnimatePresence } from "framer-motion"; import { observer } from "mobx-react"; import * as React from "react"; -import { - Switch, - Route, - useLocation, - matchPath, - Redirect, -} from "react-router-dom"; -import { TeamPreference } from "@shared/types"; +import { Switch, Route, Redirect } from "react-router-dom"; import ErrorSuspended from "~/scenes/Errors/ErrorSuspended"; import Layout from "~/components/Layout"; import RegisterKeyDown from "~/components/RegisterKeyDown"; +import { RightSidebarProvider } from "~/components/RightSidebarContext"; import Sidebar from "~/components/Sidebar"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import { usePostLoginPath } from "~/hooks/useLastVisitedPath"; @@ -23,8 +16,6 @@ import { searchPath, newDocumentPath, settingsPath, - matchDocumentHistory, - matchDocumentSlug as slug, } from "~/utils/routeHelpers"; import { DocumentContextProvider } from "./DocumentContext"; import Fade from "./Fade"; @@ -32,12 +23,6 @@ import NotificationBadge from "./NotificationBadge"; import { PortalContext } from "./Portal"; import CommandBar from "./CommandBar"; -const DocumentComments = lazyWithRetry( - () => import("~/scenes/Document/components/Comments/Comments") -); -const DocumentHistory = lazyWithRetry( - () => import("~/scenes/Document/components/History") -); const SettingsSidebar = lazyWithRetry( () => import("~/components/Sidebar/Settings") ); @@ -48,9 +33,7 @@ type Props = { const AuthenticatedLayout: React.FC = ({ children }: Props) => { const { ui, auth } = useStores(); - const location = useLocation(); const layoutRef = React.useRef(null); - const can = usePolicy(ui.activeDocumentId); const canCollection = usePolicy(ui.activeCollectionId); const team = useCurrentTeam(); const [spendPostLoginPath] = usePostLoginPath(); @@ -92,50 +75,20 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => { ); - const showHistory = - !!matchPath(location.pathname, { - path: matchDocumentHistory, - }) && can.listRevisions; - const showComments = - !showHistory && - can.comment && - ui.activeDocumentId && - ui.commentsExpanded && - !!team.getPreference(TeamPreference.Commenting); - - const sidebarRight = ( - - {(showHistory || showComments) && ( - - - {showHistory && } - {showComments && } - - - )} - - ); - return ( - - - - - - {children} - - - - + + + + + + + {children} + + + + + ); }; diff --git a/app/components/Layout.tsx b/app/components/Layout.tsx index bb2e1f786a..641da3882b 100644 --- a/app/components/Layout.tsx +++ b/app/components/Layout.tsx @@ -1,3 +1,4 @@ +import { AnimatePresence } from "framer-motion"; import { observer } from "mobx-react"; import * as React from "react"; import { Helmet } from "react-helmet-async"; @@ -7,6 +8,7 @@ import breakpoint from "styled-components-breakpoint"; import { s } from "@shared/styles"; import Flex from "~/components/Flex"; import { LoadingIndicatorBar } from "~/components/LoadingIndicator"; +import { useRightSidebarContent } from "~/components/RightSidebarContext"; import SkipNavContent from "~/components/SkipNavContent"; import SkipNavLink from "~/components/SkipNavLink"; import env from "~/env"; @@ -19,16 +21,15 @@ type Props = { title?: string; /** Left sidebar content. */ sidebar?: React.ReactNode; - /** Right sidebar content. */ - sidebarRight?: React.ReactNode; }; const Layout = React.forwardRef(function Layout_( - { title, children, sidebar, sidebarRight }: Props, + { title, children, sidebar }: Props, ref: React.RefObject ) { const { ui } = useStores(); const sidebarCollapsed = !sidebar || ui.sidebarIsClosed; + const sidebarRight = useRightSidebarContent(); return ( @@ -61,7 +62,7 @@ const Layout = React.forwardRef(function Layout_( {children} - {sidebarRight} + {sidebarRight} ); diff --git a/app/components/RightSidebarContext.tsx b/app/components/RightSidebarContext.tsx new file mode 100644 index 0000000000..ee531ac1cb --- /dev/null +++ b/app/components/RightSidebarContext.tsx @@ -0,0 +1,57 @@ +import * as React from "react"; + +type SetSidebarFn = (content: React.ReactNode) => void; + +const RightSidebarSetterContext = React.createContext( + null +); +const RightSidebarContentContext = React.createContext(null); + +/** + * Provider that holds right sidebar content state. Wrap at the layout level + * so that scenes can set sidebar content via the setter hook. + */ +export function RightSidebarProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [content, setContent] = React.useState(null); + + return ( + + + {children} + + + ); +} + +/** + * Returns a stable setter function to set the right sidebar content. + * Used by scenes (e.g. Document) to populate the sidebar. + */ +export function useSetRightSidebar(): SetSidebarFn { + const setter = React.useContext(RightSidebarSetterContext); + if (!setter) { + throw new Error( + "useSetRightSidebar must be used within a RightSidebarProvider" + ); + } + return setter; +} + +/** + * Returns the current right sidebar content. Used by Layout to render + * the sidebar. + */ +export function useRightSidebarContent(): React.ReactNode { + return React.useContext(RightSidebarContentContext); +} + +/** + * Context indicating whether the Right sidebar wrapper is already rendered + * by an ancestor. When true, SidebarLayout skips rendering its own Right + * wrapper to avoid duplicate animated containers. + */ +export const RightSidebarWrappedContext = React.createContext(false); diff --git a/app/components/Sidebar/Right.tsx b/app/components/Sidebar/Right.tsx index 6c700c5fca..59df8e18e1 100644 --- a/app/components/Sidebar/Right.tsx +++ b/app/components/Sidebar/Right.tsx @@ -14,9 +14,11 @@ import { sidebarAppearDuration } from "~/styles/animations"; interface Props extends React.HTMLAttributes { children: React.ReactNode; border?: boolean; + /** When true, skip the entrance animation and render at full width immediately. */ + skipInitialAnimation?: boolean; } -function Right({ children, border, className }: Props) { +function Right({ children, border, className, skipInitialAnimation }: Props) { const theme = useTheme(); const { ui } = useStores(); const [isResizing, setResizing] = React.useState(false); @@ -77,10 +79,12 @@ function Right({ children, border, className }: Props) { ); const animationProps = { - initial: { - width: 0, - opacity: 0.9, - }, + initial: skipInitialAnimation + ? false + : { + width: 0, + opacity: 0.9, + }, animate: { transition: isResizing ? { duration: 0 } diff --git a/app/scenes/Document/components/Comments/CommentForm.tsx b/app/scenes/Document/components/Comments/CommentForm.tsx index 422134aa61..1688aff140 100644 --- a/app/scenes/Document/components/Comments/CommentForm.tsx +++ b/app/scenes/Document/components/Comments/CommentForm.tsx @@ -312,25 +312,27 @@ function CommentForm({ {highlightedText && ( {highlightedText} )} - + }> + + {(inputFocused || draft) && ( diff --git a/app/scenes/Document/components/Comments/CommentThreadItem.tsx b/app/scenes/Document/components/Comments/CommentThreadItem.tsx index 31cc86ca5a..a98800ad0b 100644 --- a/app/scenes/Document/components/Comments/CommentThreadItem.tsx +++ b/app/scenes/Document/components/Comments/CommentThreadItem.tsx @@ -233,16 +233,18 @@ function CommentThreadItem({ {highlightedText} )} - + + + {isEditing && ( diff --git a/app/scenes/Document/components/Comments/Comments.tsx b/app/scenes/Document/components/Comments/Comments.tsx index 6055c0f305..d83de37ba4 100644 --- a/app/scenes/Document/components/Comments/Comments.tsx +++ b/app/scenes/Document/components/Comments/Comments.tsx @@ -48,7 +48,7 @@ function Comments() { const isAtBottom = useRef(true); const [showJumpToRecentBtn, setShowJumpToRecentBtn] = useState(false); - useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false })); + useKeyDown("Escape", () => document && ui.set({ rightSidebar: null })); // Account for the resolved status of the comment changing useEffect(() => { @@ -203,7 +203,7 @@ function Comments() { /> } - onClose={() => ui.set({ commentsExpanded: false })} + onClose={() => ui.set({ rightSidebar: null })} scrollable={false} > {content} diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx index 6ffae83a5c..3ce76dc8ff 100644 --- a/app/scenes/Document/components/DataLoader.tsx +++ b/app/scenes/Document/components/DataLoader.tsx @@ -27,6 +27,7 @@ import { } from "~/utils/errors"; import history from "~/utils/history"; import { matchDocumentEdit, settingsPath } from "~/utils/routeHelpers"; +import useDocumentSidebar from "../hooks/useDocumentSidebar"; import Loading from "./Loading"; import MarkAsViewed from "./MarkAsViewed"; @@ -89,6 +90,8 @@ function DataLoader({ match, children }: Props) { const location = useLocation(); const missingPolicy = !can || Object.keys(can).length === 0; + useDocumentSidebar(); + React.useEffect(() => { async function fetchDocument() { try { diff --git a/app/scenes/Document/components/DocumentMeta.tsx b/app/scenes/Document/components/DocumentMeta.tsx index 0acc17a73f..38d6eefcee 100644 --- a/app/scenes/Document/components/DocumentMeta.tsx +++ b/app/scenes/Document/components/DocumentMeta.tsx @@ -60,7 +60,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) { pathname: documentPath(document as Document), state: { sidebarContext }, }} - onClick={() => ui.toggleComments()} + onClick={() => ui.set({ rightSidebar: "comments" })} > {commentsCount diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index edf7d30c09..4b3d04e23b 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -101,7 +101,7 @@ function DocumentEditor(props: Props, ref: React.RefObject) { ) { setFocusedCommentId(focusedComment.id); } - ui.set({ commentsExpanded: true }); + ui.set({ rightSidebar: "comments" }); } }, [focusedComment, ui, document.id, params]); @@ -250,7 +250,9 @@ function DocumentEditor(props: Props, ref: React.RefObject) { commentingEnabled && can.comment ? handleRemoveComment : undefined } onOpenCommentsSidebar={ - commentingEnabled ? ui.toggleComments : undefined + commentingEnabled + ? () => ui.set({ rightSidebar: "comments" }) + : undefined } onInit={handleInit} onDestroy={handleDestroy} diff --git a/app/scenes/Document/components/SidebarLayout.tsx b/app/scenes/Document/components/SidebarLayout.tsx index 4cc5fac5a9..32119ce9cf 100644 --- a/app/scenes/Document/components/SidebarLayout.tsx +++ b/app/scenes/Document/components/SidebarLayout.tsx @@ -6,17 +6,18 @@ import styled from "styled-components"; import { s, ellipsis } from "@shared/styles"; import Button from "~/components/Button"; import Flex from "~/components/Flex"; +import { PortalContext } from "~/components/Portal"; +import { RightSidebarWrappedContext } from "~/components/RightSidebarContext"; import Scrollable from "~/components/Scrollable"; -import Tooltip from "~/components/Tooltip"; -import useMobile from "~/hooks/useMobile"; -import { draggableOnDesktop } from "~/styles"; import RightSidebar from "~/components/Sidebar/Right"; +import Tooltip from "~/components/Tooltip"; import { Drawer, DrawerContent, DrawerTitle, } from "~/components/primitives/Drawer"; -import { PortalContext } from "~/components/Portal"; +import useMobile from "~/hooks/useMobile"; +import { draggableOnDesktop } from "~/styles"; type Props = Omit, "title"> & { /* The title of the sidebar */ @@ -24,7 +25,7 @@ type Props = Omit, "title"> & { /* The content of the sidebar */ children: React.ReactNode; /* Called when the sidebar is closed */ - onClose: () => void; + onClose?: () => void; /* Whether the sidebar should be scrollable */ scrollable?: boolean; }; @@ -32,6 +33,7 @@ type Props = Omit, "title"> & { function SidebarLayout({ title, onClose, children, scrollable = true }: Props) { const { t } = useTranslation(); const isMobile = useMobile(); + const isWrapped = React.useContext(RightSidebarWrappedContext); const [drawerElement, setDrawerElement] = React.useState(null); @@ -43,17 +45,21 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) { children ); - return isMobile ? ( - - - {title} - - {content} - - - - ) : ( - + if (isMobile) { + return ( + + + {title} + + {content} + + + + ); + } + + const inner = ( + <>
{title} @@ -66,8 +72,14 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
{content} -
+ ); + + if (isWrapped) { + return inner; + } + + return {inner}; } const ForwardIcon = styled(BackIcon)` diff --git a/app/scenes/Document/hooks/useDocumentSidebar.tsx b/app/scenes/Document/hooks/useDocumentSidebar.tsx new file mode 100644 index 0000000000..5ec656eac0 --- /dev/null +++ b/app/scenes/Document/hooks/useDocumentSidebar.tsx @@ -0,0 +1,109 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { Route, matchPath, useLocation } from "react-router-dom"; +import { + RightSidebarWrappedContext, + useSetRightSidebar, +} from "~/components/RightSidebarContext"; +import RightSidebar from "~/components/Sidebar/Right"; +import PlaceholderText from "~/components/PlaceholderText"; +import useMobile from "~/hooks/useMobile"; +import useStores from "~/hooks/useStores"; +import lazyWithRetry from "~/utils/lazyWithRetry"; +import { matchDocumentHistory, matchDocumentSlug } from "~/utils/routeHelpers"; +import SidebarLayout from "~/scenes/Document/components/SidebarLayout"; + +const DocumentComments = lazyWithRetry( + () => import("~/scenes/Document/components/Comments/Comments") +); +const DocumentHistory = lazyWithRetry( + () => import("~/scenes/Document/components/History") +); + +interface DocumentSidebarContentProps { + skipInitialAnimation?: boolean; +} + +/** + * Stable component that reads `ui.rightSidebar` and renders the appropriate + * sidebar content. On desktop, wraps content in a single Right sidebar that + * stays mounted across panel switches to avoid re-triggering the open/close + * animation. + */ +const DocumentSidebarContent = observer(function DocumentSidebarContent({ + skipInitialAnimation, +}: DocumentSidebarContentProps) { + const { ui } = useStores(); + const isMobile = useMobile(); + + const inner = ( + + }> + {null} + + } + > + {ui.rightSidebar === "comments" && } + {ui.rightSidebar === "history" && } + + + ); + + if (isMobile) { + return inner; + } + + return ( + + + {inner} + + + ); +}); + +/** + * Manages the right sidebar for the Document scene. Syncs the history route + * to store state, sets a stable component into the sidebar context when open, + * and clears it when closed or on unmount. + */ +export default function useDocumentSidebar() { + const { ui } = useStores(); + const location = useLocation(); + const setSidebar = useSetRightSidebar(); + const isHistoryRoute = !!matchPath(location.pathname, { + path: matchDocumentHistory, + }); + const isOpen = ui.rightSidebar !== null; + const isInitialOpenRef = React.useRef(isOpen); + + React.useEffect(() => { + if (isHistoryRoute) { + ui.set({ rightSidebar: "history" }); + } else if (ui.rightSidebar === "history") { + ui.set({ rightSidebar: null }); + } + }, [isHistoryRoute, ui]); + + React.useEffect(() => { + if (isOpen) { + setSidebar( + + ); + isInitialOpenRef.current = false; + } else { + setSidebar(null); + } + }, [isOpen, setSidebar]); + + React.useEffect( + () => () => { + setSidebar(null); + }, + [setSidebar] + ); +} diff --git a/app/stores/UiStore.ts b/app/stores/UiStore.ts index 4a069eda7a..a517e6861b 100644 --- a/app/stores/UiStore.ts +++ b/app/stores/UiStore.ts @@ -28,7 +28,7 @@ export enum SystemTheme { type PersistedData = Pick< UiStore, | "languagePromptDismissed" - | "commentsExpanded" + | "rightSidebar" | "theme" | "sidebarWidth" | "sidebarRightWidth" @@ -78,7 +78,7 @@ class UiStore { sidebarCollapsed = false; @observable - commentsExpanded = false; + rightSidebar: "comments" | "history" | null = null; @observable sidebarIsResizing = false; @@ -111,7 +111,7 @@ class UiStore { this.sidebarRightWidth = data.sidebarRightWidth || defaultTheme.sidebarRightWidth; this.tocVisible = data.tocVisible; - this.commentsExpanded = !!data.commentsExpanded; + this.rightSidebar = data.rightSidebar ?? null; this.theme = data.theme || Theme.System; // system theme listeners @@ -340,11 +340,6 @@ class UiStore { this.persist(); }; - @action - toggleComments = () => { - this.set({ commentsExpanded: !this.commentsExpanded }); - }; - @action toggleCollapsedSidebar = () => { sidebarHidden = false; @@ -433,7 +428,7 @@ class UiStore { sidebarWidth: this.sidebarWidth, sidebarRightWidth: this.sidebarRightWidth, languagePromptDismissed: this.languagePromptDismissed, - commentsExpanded: this.commentsExpanded, + rightSidebar: this.rightSidebar, theme: this.theme, }; }