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
This commit is contained in:
Tom Moor
2026-02-28 21:54:16 -05:00
committed by GitHub
parent 8dbbcb6dce
commit aacad48585
14 changed files with 271 additions and 131 deletions
+1 -1
View File
@@ -1381,7 +1381,7 @@ export const openDocumentComments = createAction({
return;
}
stores.ui.toggleComments();
stores.ui.set({ rightSidebar: "comments" });
},
});
+14 -61
View File
@@ -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<HTMLDivElement>(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) => {
</Fade>
);
const showHistory =
!!matchPath(location.pathname, {
path: matchDocumentHistory,
}) && can.listRevisions;
const showComments =
!showHistory &&
can.comment &&
ui.activeDocumentId &&
ui.commentsExpanded &&
!!team.getPreference(TeamPreference.Commenting);
const sidebarRight = (
<AnimatePresence
initial={false}
key={ui.activeDocumentId ? "active" : "inactive"}
>
{(showHistory || showComments) && (
<Route path={`/doc/${slug}`}>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
</Route>
)}
</AnimatePresence>
);
return (
<DocumentContextProvider>
<PortalContext.Provider value={layoutRef.current}>
<Layout
title={team.name}
sidebar={sidebar}
sidebarRight={sidebarRight}
ref={layoutRef}
>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<NotificationBadge />
</Layout>
</PortalContext.Provider>
<RightSidebarProvider>
<PortalContext.Provider value={layoutRef.current}>
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<NotificationBadge />
</Layout>
</PortalContext.Provider>
</RightSidebarProvider>
</DocumentContextProvider>
);
};
+5 -4
View File
@@ -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<HTMLDivElement>
) {
const { ui } = useStores();
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
const sidebarRight = useRightSidebarContent();
return (
<Container column auto ref={ref}>
@@ -61,7 +62,7 @@ const Layout = React.forwardRef(function Layout_(
{children}
</Content>
{sidebarRight}
<AnimatePresence initial={false}>{sidebarRight}</AnimatePresence>
</Container>
</Container>
);
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react";
type SetSidebarFn = (content: React.ReactNode) => void;
const RightSidebarSetterContext = React.createContext<SetSidebarFn | null>(
null
);
const RightSidebarContentContext = React.createContext<React.ReactNode>(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<React.ReactNode>(null);
return (
<RightSidebarSetterContext.Provider value={setContent}>
<RightSidebarContentContext.Provider value={content}>
{children}
</RightSidebarContentContext.Provider>
</RightSidebarSetterContext.Provider>
);
}
/**
* 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);
+9 -5
View File
@@ -14,9 +14,11 @@ import { sidebarAppearDuration } from "~/styles/animations";
interface Props extends React.HTMLAttributes<HTMLDivElement> {
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 }
@@ -312,25 +312,27 @@ function CommentForm({
{highlightedText && (
<HighlightedText>{highlightedText}</HighlightedText>
)}
<CommentEditor
key={`${forceRender}`}
ref={mergeRefs([editorRef, handleMounted])}
defaultValue={draft}
onChange={handleChange}
onSave={handleSave}
onFocus={handleFocus}
onBlur={handleBlur}
onUpArrowAtStart={handleUpArrowAtStart}
maxLength={CommentValidation.maxLength}
placeholder={
placeholder ||
// isNew is only the case for comments that exist in draft state,
// they are marks in the document, but not yet saved to the db.
(thread?.isNew
? `${t("Add a comment")}`
: `${t("Add a reply")}`)
}
/>
<React.Suspense fallback={<div style={{ height: 24 }} />}>
<CommentEditor
key={`${forceRender}`}
ref={mergeRefs([editorRef, handleMounted])}
defaultValue={draft}
onChange={handleChange}
onSave={handleSave}
onFocus={handleFocus}
onBlur={handleBlur}
onUpArrowAtStart={handleUpArrowAtStart}
maxLength={CommentValidation.maxLength}
placeholder={
placeholder ||
// isNew is only the case for comments that exist in draft state,
// they are marks in the document, but not yet saved to the db.
(thread?.isNew
? `${t("Add a comment")}`
: `${t("Add a reply")}`)
}
/>
</React.Suspense>
{(inputFocused || draft) && (
<Flex justify="space-between" reverse={dir === "rtl"} gap={8}>
<HStack>
@@ -233,16 +233,18 @@ function CommentThreadItem({
<HighlightedText>{highlightedText}</HighlightedText>
)}
<Body ref={formRef} onSubmit={handleSubmit}>
<StyledCommentEditor
key={String(isEditing)}
readOnly={!isEditing}
value={comment.data}
defaultValue={data}
onChange={handleChange}
onSave={handleSave}
onCancel={handleCancel}
autoFocus
/>
<React.Suspense fallback={null}>
<StyledCommentEditor
key={String(isEditing)}
readOnly={!isEditing}
value={comment.data}
defaultValue={data}
onChange={handleChange}
onSave={handleSave}
onCancel={handleCancel}
autoFocus
/>
</React.Suspense>
{isEditing && (
<Flex align="flex-end" gap={8}>
<ButtonSmall type="submit" borderOnHover>
@@ -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() {
/>
</Flex>
}
onClose={() => ui.set({ commentsExpanded: false })}
onClose={() => ui.set({ rightSidebar: null })}
scrollable={false}
>
{content}
@@ -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<LocationState>();
const missingPolicy = !can || Object.keys(can).length === 0;
useDocumentSidebar();
React.useEffect(() => {
async function fetchDocument() {
try {
@@ -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" })}
>
<CommentIcon size={18} />
{commentsCount
+4 -2
View File
@@ -101,7 +101,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
) {
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<any>) {
commentingEnabled && can.comment ? handleRemoveComment : undefined
}
onOpenCommentsSidebar={
commentingEnabled ? ui.toggleComments : undefined
commentingEnabled
? () => ui.set({ rightSidebar: "comments" })
: undefined
}
onInit={handleInit}
onDestroy={handleDestroy}
@@ -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<React.HTMLAttributes<HTMLDivElement>, "title"> & {
/* The title of the sidebar */
@@ -24,7 +25,7 @@ type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "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<React.HTMLAttributes<HTMLDivElement>, "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<HTMLDivElement | null>(null);
@@ -43,17 +45,21 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
children
);
return isMobile ? (
<Drawer onClose={onClose} defaultOpen>
<DrawerContent ref={setDrawerElement}>
<DrawerTitle>{title}</DrawerTitle>
<PortalContext.Provider value={drawerElement}>
{content}
</PortalContext.Provider>
</DrawerContent>
</Drawer>
) : (
<RightSidebar>
if (isMobile) {
return (
<Drawer onClose={onClose} defaultOpen>
<DrawerContent ref={setDrawerElement}>
<DrawerTitle>{title}</DrawerTitle>
<PortalContext.Provider value={drawerElement}>
{content}
</PortalContext.Provider>
</DrawerContent>
</Drawer>
);
}
const inner = (
<>
<Header>
<Title>{title}</Title>
<Tooltip content={t("Close")} shortcut="Esc">
@@ -66,8 +72,14 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
</Tooltip>
</Header>
{content}
</RightSidebar>
</>
);
if (isWrapped) {
return inner;
}
return <RightSidebar>{inner}</RightSidebar>;
}
const ForwardIcon = styled(BackIcon)`
@@ -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 = (
<Route path={`/doc/${matchDocumentSlug}`}>
<React.Suspense
fallback={
<SidebarLayout title={<PlaceholderText width={100} />}>
{null}
</SidebarLayout>
}
>
{ui.rightSidebar === "comments" && <DocumentComments />}
{ui.rightSidebar === "history" && <DocumentHistory />}
</React.Suspense>
</Route>
);
if (isMobile) {
return inner;
}
return (
<RightSidebar skipInitialAnimation={skipInitialAnimation}>
<RightSidebarWrappedContext.Provider value={true}>
{inner}
</RightSidebarWrappedContext.Provider>
</RightSidebar>
);
});
/**
* 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(
<DocumentSidebarContent
skipInitialAnimation={isInitialOpenRef.current}
/>
);
isInitialOpenRef.current = false;
} else {
setSidebar(null);
}
}, [isOpen, setSidebar]);
React.useEffect(
() => () => {
setSidebar(null);
},
[setSidebar]
);
}
+4 -9
View File
@@ -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,
};
}