perf: Sidebar virtualization and re-render optimization (#12443)

* perf: Prevent action context invalidation on location change

* PR feedback

* virtualization

* fix: Initial visiblity incorrect

* PR feedback
This commit is contained in:
Tom Moor
2026-05-24 08:57:43 -04:00
committed by GitHub
parent 08c0390295
commit 9e725d618d
5 changed files with 465 additions and 233 deletions
+85 -67
View File
@@ -1,8 +1,12 @@
import { observer } from "mobx-react";
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import { useEffect, useState, useCallback, useMemo } from "react";
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import {
DragActiveProvider,
SidebarScrollProvider,
} from "./components/DragActiveContext";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled from "styled-components";
@@ -67,78 +71,92 @@ function AppSidebar() {
[dndArea]
);
// Scrollable reads ref.current internally for its shadow/ResizeObserver
// logic, so we must pass an object ref — a callback ref would leave those
// reads undefined. We mirror the attached node into state so the
// SidebarScrollProvider can re-render descendants with the scroll element.
const scrollRef = useRef<HTMLDivElement>(null);
const [scrollArea, setScrollArea] = useState<HTMLElement | null>(null);
useEffect(() => {
setScrollArea(scrollRef.current);
}, []);
return (
<Sidebar hidden={!ui.readyToShow} ref={handleSidebarRef}>
{dndArea && (
<DndProvider backend={HTML5Backend} options={html5Options}>
<DragPlaceholder />
<DragActiveProvider>
<DragPlaceholder />
<TeamMenu>
<SidebarButton
title={team.name}
image={<TeamLogo model={team} size={24} alt={t("Logo")} />}
>
{isMobile ? null : (
<Tooltip
content={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
>
<ToggleButton
position="bottom"
image={<SidebarIcon />}
aria-label={
ui.sidebarCollapsed
? t("Expand sidebar")
: t("Collapse sidebar")
}
style={{ paddingInline: 4 }}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
}}
/>
</Tooltip>
)}
</SidebarButton>
</TeamMenu>
<Overflow>
<Section>
<SidebarLink
to={homePath()}
icon={<HomeIcon />}
exact={false}
label={t("Home")}
/>
<SidebarLink
to={searchPath()}
icon={<SearchIcon />}
label={t("Search")}
exact={false}
onClick={handleSearchClick}
/>
{can.createDocument && <DraftsLink />}
</Section>
</Overflow>
<Scrollable flex shadow>
<Section>
<Starred />
</Section>
<Section>
<SharedWithMe />
</Section>
<Section>
<Collections />
</Section>
{can.createDocument && (
<Section auto>
<ArchiveLink />
<TeamMenu>
<SidebarButton
title={team.name}
image={<TeamLogo model={team} size={24} alt={t("Logo")} />}
>
{isMobile ? null : (
<Tooltip
content={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
>
<ToggleButton
position="bottom"
image={<SidebarIcon />}
aria-label={
ui.sidebarCollapsed
? t("Expand sidebar")
: t("Collapse sidebar")
}
style={{ paddingInline: 4 }}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
}}
/>
</Tooltip>
)}
</SidebarButton>
</TeamMenu>
<Overflow>
<Section>
<SidebarLink
to={homePath()}
icon={<HomeIcon />}
exact={false}
label={t("Home")}
/>
<SidebarLink
to={searchPath()}
icon={<SearchIcon />}
label={t("Search")}
exact={false}
onClick={handleSearchClick}
/>
{can.createDocument && <DraftsLink />}
</Section>
)}
<Section>
{can.createDocument && <TrashLink />}
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>
</Overflow>
<Scrollable flex shadow ref={scrollRef}>
<SidebarScrollProvider value={scrollArea}>
<Section>
<Starred />
</Section>
<Section>
<SharedWithMe />
</Section>
<Section>
<Collections />
</Section>
{can.createDocument && (
<Section auto>
<ArchiveLink />
</Section>
)}
<Section>
{can.createDocument && <TrashLink />}
<SidebarAction action={inviteUser} />
</Section>
</SidebarScrollProvider>
</Scrollable>
</DragActiveProvider>
</DndProvider>
)}
<HistoryNavigation />
@@ -16,6 +16,7 @@ import type { RefHandle } from "~/components/EditableTitle";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import useOnScreen from "~/hooks/useOnScreen";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
@@ -25,6 +26,7 @@ import {
useDropToReorderDocument,
useDropToReparentDocument,
} from "../hooks/useDragAndDrop";
import { useIsDragActive, useSidebarScrollElement } from "./DragActiveContext";
import { useSidebarExpansion } from "./SidebarExpansionContext";
import DocumentRow from "./DocumentRow";
import DropCursor from "./DropCursor";
@@ -44,34 +46,28 @@ type Props = {
parentId?: string;
};
const DocumentLink = observer(function DocumentLinkInner({
node,
collection,
membership,
activeDocument,
prefetchDocument,
isDraft,
depth,
index,
parentId,
}: Props) {
// Approximate rendered row height; used to reserve space for unmounted rows so
// the scroll container stays the right height and IntersectionObserver triggers
// correctly as the user scrolls.
const ROW_HEIGHT = 30;
// Pre-mount rows just outside the viewport so scrolling stays smooth and drop
// targets exist a screen ahead when a drag starts.
const ROOT_MARGIN = "300px 0px";
const DocumentLink = observer(function DocumentLink(props: Props) {
const { node, collection, activeDocument } = props;
const { documents } = useStores();
const { t } = useTranslation();
const history = useHistory();
const can = usePolicy(node.id);
const canUpdate = can.update;
const expansion = useSidebarExpansion();
const expanded = expansion.isExpanded(node.id);
const isActiveDocument = activeDocument && activeDocument.id === node.id;
const hasChildDocuments =
!!node.children.length || activeDocument?.parentDocumentId === node.id;
const document = documents.get(node.id);
const { fetchChildDocuments } = documents;
const [isEditing, setIsEditing] = React.useState(false);
const editableTitleRef = React.useRef<RefHandle>(null);
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
const expansion = useSidebarExpansion();
const expanded = expansion.isExpanded(node.id);
const { fetchChildDocuments } = documents;
// Keep expansion/data effects on the outer so they run regardless of whether
// the heavy row content is currently mounted.
React.useEffect(() => {
if (expanded && !hasChildDocuments) {
expansion.collapse(node.id);
@@ -93,6 +89,105 @@ const DocumentLink = observer(function DocumentLinkInner({
isActiveDocument,
]);
const insertDraftChild = !!(
activeDocument?.isDraft &&
activeDocument?.isActive &&
activeDocument?.parentDocumentId === node.id
);
const draftNavNode = insertDraftChild
? activeDocument?.asNavigationNode
: undefined;
const nodeChildren = React.useMemo(
() =>
collection && draftNavNode
? sortNavigationNodes(
[draftNavNode, ...node.children],
collection.sort,
false
)
: node.children,
[draftNavNode, collection, node.children]
);
// Visibility gate: only mount the heavy inner content when scrolled near the
// viewport, but keep it mounted while a drag is in progress so the dragged
// source (or a drop target the user is heading toward) isn't yanked.
const scrollRoot = useSidebarScrollElement();
const placeholderRef = React.useRef<HTMLDivElement>(null);
const observerOptions = React.useMemo(
() => ({ root: scrollRoot, rootMargin: ROOT_MARGIN }),
[scrollRoot]
);
const isOnScreen = useOnScreen(placeholderRef, observerOptions);
const isDragActive = useIsDragActive();
const [mounted, setMounted] = React.useState(false);
// Flip mount state during render (not in an effect) so the first paint
// already contains the row content when the placeholder is on screen,
// avoiding a blank frame.
if (isOnScreen && !mounted) {
setMounted(true);
} else if (!isOnScreen && !isDragActive && mounted) {
setMounted(false);
}
return (
<>
<div ref={placeholderRef} style={{ minHeight: ROW_HEIGHT }}>
{mounted ? (
<DocumentLinkInner {...props} hasChildren={nodeChildren.length > 0} />
) : null}
</div>
<Folder expanded={expanded}>
{nodeChildren.map((childNode, childIndex) => (
<DocumentLink
key={childNode.id}
collection={collection}
membership={props.membership}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={props.prefetchDocument}
isDraft={childNode.isDraft}
depth={props.depth + 1}
index={childIndex}
parentId={node.id}
/>
))}
</Folder>
</>
);
});
type InnerProps = Props & {
hasChildren: boolean;
};
const DocumentLinkInner = observer(function DocumentLinkInner({
node,
collection,
membership,
prefetchDocument,
isDraft,
depth,
index,
parentId,
hasChildren,
}: InnerProps) {
const { documents } = useStores();
const { t } = useTranslation();
const history = useHistory();
const can = usePolicy(node.id);
const canUpdate = can.update;
const document = documents.get(node.id);
const [isEditing, setIsEditing] = React.useState(false);
const editableTitleRef = React.useRef<RefHandle>(null);
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
const expansion = useSidebarExpansion();
const expanded = expansion.isExpanded(node.id);
const handleDisclosureClick = React.useCallback(
(ev?: React.MouseEvent<HTMLElement>) => {
if (expanded) {
@@ -226,30 +321,7 @@ const DocumentLink = observer(function DocumentLinkInner({
};
});
const insertDraftChild = !!(
activeDocument?.isDraft &&
activeDocument?.isActive &&
activeDocument?.parentDocumentId === node.id
);
const draftNavNode = insertDraftChild
? activeDocument?.asNavigationNode
: undefined;
const nodeChildren = React.useMemo(
() =>
collection && draftNavNode
? sortNavigationNodes(
[draftNavNode, ...node.children],
collection.sort,
false
)
: node.children,
[draftNavNode, collection, node.children]
);
const title = document?.title || node.title || t("Untitled");
const hasChildren = nodeChildren.length > 0;
const handleNewDoc = React.useCallback(
async (input: string) => {
@@ -348,24 +420,7 @@ const DocumentLink = observer(function DocumentLinkInner({
contextAction={contextMenuAction}
isActiveOverride={isActiveCheck}
onClickIntent={handlePrefetch}
>
<Folder expanded={expanded && !isDragging}>
{nodeChildren.map((childNode, childIndex) => (
<DocumentLink
key={childNode.id}
collection={collection}
membership={membership}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={childIndex}
parentId={node.id}
/>
))}
</Folder>
</DocumentRow>
/>
);
});
@@ -0,0 +1,47 @@
import * as React from "react";
import { useDragLayer } from "react-dnd";
const DragActiveContext = React.createContext(false);
const SidebarScrollContext = React.createContext<HTMLElement | null>(null);
/**
* Provides the sidebar's scroll container so descendants can use it as the
* IntersectionObserver root when deciding whether to render heavy content.
*/
export const SidebarScrollProvider = SidebarScrollContext.Provider;
/**
* Returns the sidebar scroll container element, or null if not within a
* SidebarScrollProvider.
*/
export function useSidebarScrollElement(): HTMLElement | null {
return React.useContext(SidebarScrollContext);
}
/**
* Subscribes once to react-dnd's drag state and exposes a boolean via context.
*
* Visibility-gated sidebar rows read this to keep their inner content mounted
* for the duration of a drag, so that scrolling away from the dragged source
* (or a drop target the user is heading toward) does not unmount it mid-drag.
*/
export function DragActiveProvider({
children,
}: {
children: React.ReactNode;
}) {
const isDragging = useDragLayer((monitor) => monitor.isDragging());
return (
<DragActiveContext.Provider value={isDragging}>
{children}
</DragActiveContext.Provider>
);
}
/**
* Returns whether any react-dnd drag is currently active.
*/
export function useIsDragActive(): boolean {
return React.useContext(DragActiveContext);
}
+154 -88
View File
@@ -1,8 +1,8 @@
import { observer } from "mobx-react";
import type { ReactNode } from "react";
import React, { createContext, useContext } from "react";
import React, { createContext, useCallback, useContext, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import { useHistory } from "react-router";
import useStores from "~/hooks/useStores";
import type Model from "~/models/base/Model";
import type Policy from "~/models/Policy";
@@ -55,98 +55,159 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
const parentContext = useContext(ActionContext);
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
const { activeModels: valueModels, ...overrides } = value;
// Create the base context if we don't have a parent context
const baseContext: ActionContextType = parentContext ?? {
isMenu: false,
isCommandBar: false,
isButton: false,
// Use history (stable reference) and read location lazily via a getter so
// navigation does not invalidate the context value. Action perform/visible
// callbacks see the current location at call time via history.location,
// which react-router updates on every navigation.
const history = useHistory();
// Legacy (backward compatibility)
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
const {
activeModels: valueModels,
isMenu,
isCommandBar,
isButton,
sidebarContext,
event,
} = value;
getActiveModels: <T extends Model>(
modelClass: new (...args: never[]) => T
): T[] => stores.ui.getActiveModels<T>(modelClass),
// Track membership of stores.ui.activeModels so memos invalidate when it changes.
// Reading inside the observer-wrapped render keeps MobX subscriptions intact.
const activeModelsKey = Array.from(stores.ui.activeModels.keys()).join(",");
const activeCollectionIdFromStore = stores.ui.activeCollectionId ?? undefined;
const activeDocumentIdFromStore = stores.ui.activeDocumentId ?? undefined;
const currentUserId = stores.auth.user?.id;
const currentTeamId = stores.auth.team?.id;
getActiveModel: <T extends Model>(
modelClass: new (...args: never[]) => T
): T | undefined => stores.ui.getActiveModels<T>(modelClass)[0],
const getActiveModels = useCallback(
<T extends Model>(modelClass: new (...args: never[]) => T): T[] => {
if (valueModels && valueModels.length > 0) {
const matching = valueModels.filter(
(model): model is T => model instanceof modelClass
);
if (matching.length > 0) {
return matching;
}
}
if (parentContext) {
return parentContext.getActiveModels(modelClass);
}
return stores.ui.getActiveModels<T>(modelClass);
},
[valueModels, parentContext, stores]
);
getActivePolicies: <T extends Model>(
modelClass: new (...args: never[]) => T
): Policy[] =>
stores.ui
.getActiveModels<T>(modelClass)
const getActiveModel = useCallback(
<T extends Model>(modelClass: new (...args: never[]) => T): T | undefined =>
getActiveModels(modelClass)[0],
[getActiveModels]
);
const getActivePolicies = useCallback(
<T extends Model>(modelClass: new (...args: never[]) => T): Policy[] =>
getActiveModels(modelClass)
.map((node) => stores.policies.get(node.id))
.filter((policy): policy is Policy => policy !== undefined),
[getActiveModels, stores]
);
isModelActive: (model: Model): boolean => stores.ui.isModelActive(model),
activeModels: new Set(stores.ui.activeModels.values()),
const allActiveModels = useMemo(() => {
const base = parentContext
? parentContext.activeModels
: new Set(stores.ui.activeModels.values());
if (valueModels && valueModels.length > 0) {
return new Set([...base, ...valueModels]);
}
return base;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentContext, stores, valueModels, activeModelsKey]);
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
location,
const isModelActive = useCallback(
(model: Model): boolean => allActiveModels.has(model),
[allActiveModels]
);
const contextValue = useMemo<ActionContextType>(() => {
const baseContext: ActionContextType = parentContext ?? {
isMenu: false,
isCommandBar: false,
isButton: false,
// Legacy (backward compatibility)
activeCollectionId: activeCollectionIdFromStore,
activeDocumentId: activeDocumentIdFromStore,
getActiveModels,
getActiveModel,
getActivePolicies,
isModelActive,
activeModels: allActiveModels,
currentUserId,
currentTeamId,
// Consumers reading `ctx.location` get the current location at access time.
location: history.location,
stores,
t,
};
// Derive legacy IDs from value models, falling back to base context
const activeCollectionId =
valueModels?.find(
(m) => (m.constructor as typeof Model).modelName === "Collection"
)?.id ?? baseContext.activeCollectionId;
const activeDocumentId =
valueModels?.find(
(m) => (m.constructor as typeof Model).modelName === "Document"
)?.id ?? baseContext.activeDocumentId;
const result = {
...baseContext,
...(isMenu !== undefined ? { isMenu } : {}),
...(isCommandBar !== undefined ? { isCommandBar } : {}),
...(isButton !== undefined ? { isButton } : {}),
...(sidebarContext !== undefined ? { sidebarContext } : {}),
...(event !== undefined ? { event } : {}),
activeCollectionId,
activeDocumentId,
getActiveModels,
getActiveModel,
getActivePolicies,
isModelActive,
activeModels: allActiveModels,
};
// Define `location` as a getter so reads always return the current
// location without invalidating this memo on navigation.
Object.defineProperty(result, "location", {
get: () => history.location,
enumerable: true,
configurable: true,
});
return result;
}, [
parentContext,
stores,
t,
};
// Override model accessors when models are provided in value
const getActiveModels =
valueModels && valueModels.length > 0
? <T extends Model>(modelClass: new (...args: never[]) => T): T[] => {
const matching = valueModels.filter(
(model): model is T => model instanceof modelClass
);
return matching.length > 0
? matching
: baseContext.getActiveModels(modelClass);
}
: baseContext.getActiveModels;
const getActiveModel = <T extends Model>(
modelClass: new (...args: never[]) => T
): T | undefined => getActiveModels(modelClass)[0];
const getActivePolicies = <T extends Model>(
modelClass: new (...args: never[]) => T
): Policy[] =>
getActiveModels(modelClass)
.map((node) => stores.policies.get(node.id))
.filter((policy): policy is Policy => policy !== undefined);
const allActiveModels =
valueModels && valueModels.length > 0
? new Set([...baseContext.activeModels, ...valueModels])
: baseContext.activeModels;
const isModelActive = (model: Model): boolean => allActiveModels.has(model);
// Derive legacy IDs from value models, falling back to base context
const activeCollectionId =
valueModels?.find(
(m) => (m.constructor as typeof Model).modelName === "Collection"
)?.id ?? baseContext.activeCollectionId;
const activeDocumentId =
valueModels?.find(
(m) => (m.constructor as typeof Model).modelName === "Document"
)?.id ?? baseContext.activeDocumentId;
const contextValue: ActionContextType = {
...baseContext,
...overrides,
activeCollectionId,
activeDocumentId,
history,
valueModels,
isMenu,
isCommandBar,
isButton,
sidebarContext,
event,
activeCollectionIdFromStore,
activeDocumentIdFromStore,
currentUserId,
currentTeamId,
getActiveModels,
getActiveModel,
getActivePolicies,
isModelActive,
activeModels: allActiveModels,
};
allActiveModels,
]);
return (
<ActionContext.Provider value={contextValue}>
@@ -173,15 +234,20 @@ export default function useActionContext(
): ActionContextType {
const contextValue = useContext(ActionContext);
// If we have a context value from a provider, use it as the base
if (contextValue) {
return {
...contextValue,
...overrides,
};
if (!contextValue) {
throw new Error(
"useActionContext must be used within an ActionContextProvider"
);
}
throw new Error(
"useActionContext must be used within an ActionContextProvider"
);
// Short-circuit when no overrides are provided so consumers get a stable
// reference and don't re-render unnecessarily.
if (!overrides || Object.keys(overrides).length === 0) {
return contextValue;
}
return {
...contextValue,
...overrides,
};
}
+61 -15
View File
@@ -2,34 +2,80 @@ import * as React from "react";
const isSupported = "IntersectionObserver" in window;
// Parses a rootMargin string ("10px 20px" / "10px" / "10px 20px 30px 40px")
// into [top, right, bottom, left] in pixels. Percentages are not supported in
// the synchronous fast path and fall back to 0.
function parseRootMargin(
rootMargin: string | undefined
): [number, number, number, number] {
if (!rootMargin) {
return [0, 0, 0, 0];
}
const parts = rootMargin
.split(/\s+/)
.map((p) => (p.endsWith("px") ? parseFloat(p) : 0));
const [t = 0, r = t, b = t, l = r] = parts;
return [t, r, b, l];
}
/**
* Hook to return if a given ref is visible on screen.
*
* @returns boolean if the node is visible
*/
export default function useOnScreen(ref: React.RefObject<HTMLElement>) {
export default function useOnScreen(
ref: React.RefObject<HTMLElement>,
options?: IntersectionObserverInit
) {
const root = options?.root;
const rootMargin = options?.rootMargin;
const threshold = Array.isArray(options?.threshold)
? options?.threshold.join(",")
: options?.threshold;
const [isIntersecting, setIntersecting] = React.useState(!isSupported);
React.useEffect(() => {
React.useLayoutEffect(() => {
const element = ref.current;
let observer: IntersectionObserver | undefined;
if (isSupported) {
observer = new IntersectionObserver(([entry]) => {
// Update our state when observer callback fires
setIntersecting(entry.isIntersecting);
});
if (!element) {
return undefined;
}
if (element) {
observer?.observe(element);
// Synchronous initial check so the first paint is correct.
const [mt, mr, mb, ml] = parseRootMargin(rootMargin);
const rect = element.getBoundingClientRect();
const rootRect =
root instanceof Element
? root.getBoundingClientRect()
: {
top: 0,
left: 0,
bottom: window.innerHeight,
right: window.innerWidth,
};
const initialVisible =
rect.bottom >= rootRect.top - mt &&
rect.top <= rootRect.bottom + mb &&
rect.right >= rootRect.left - ml &&
rect.left <= rootRect.right + mr;
setIntersecting(initialVisible);
if (!isSupported) {
return undefined;
}
const observer = new IntersectionObserver(([entry]) => {
setIntersecting(entry.isIntersecting);
}, options);
observer.observe(element);
return () => {
if (element) {
observer?.unobserve(element);
}
observer.unobserve(element);
};
}, [ref]);
// Re-create when option primitives change; options object identity ignored
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref, root, rootMargin, threshold]);
return isIntersecting;
}