mirror of
https://github.com/facebook/docusaurus.git
synced 2025-06-13 16:23:34 +02:00
fix(v2): introduce useCollapsible to fix collapsible animation perf issues (#5116)
* fix(v2): avoid slowdown transition with huge sidebar items * move useCollapsible to theme-common * @docusaurus/theme-classic => watch mode should include type-checking * refactor useCollapsible => encapsulate more behavior / state / ref inside it, making code simpler for component using it * useCollapseAnimation => animate DOM properties directly instead of using React inline styles => optimize perf from 4 render per click to 1 render per click * add missing items in deps array * rename ref to collapsibleRef * lint Co-authored-by: slorber <lorber.sebastien@gmail.com>
This commit is contained in:
parent
ef70de18dd
commit
237d1a31f5
5 changed files with 201 additions and 54 deletions
|
@ -15,7 +15,7 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --noEmit && yarn babel:lib && yarn babel:lib-next && yarn prettier:lib-next",
|
"build": "tsc --noEmit && yarn babel:lib && yarn babel:lib-next && yarn prettier:lib-next",
|
||||||
"watch": "concurrently -n \"lib,lib-next\" --kill-others \"yarn babel:lib --watch\" \"yarn babel:lib-next --watch\"",
|
"watch": "concurrently --names \"lib,lib-next,tsc\" --kill-others \"yarn babel:lib --watch\" \"yarn babel:lib-next --watch\" \"yarn tsc --watch\"",
|
||||||
"babel:lib": "cross-env BABEL_ENV=lib babel src -d lib --extensions \".tsx,.ts\" --ignore \"**/*.d.ts\" --copy-files",
|
"babel:lib": "cross-env BABEL_ENV=lib babel src -d lib --extensions \".tsx,.ts\" --ignore \"**/*.d.ts\" --copy-files",
|
||||||
"babel:lib-next": "cross-env BABEL_ENV=lib-next babel src -d lib-next --extensions \".tsx,.ts\" --ignore \"**/*.d.ts\" --copy-files",
|
"babel:lib-next": "cross-env BABEL_ENV=lib-next babel src -d lib-next --extensions \".tsx,.ts\" --ignore \"**/*.d.ts\" --copy-files",
|
||||||
"prettier": "prettier --config ../../.prettierrc --ignore-path ../../.prettierignore --write \"**/*.{js,ts,jsx,tsc}\"",
|
"prettier": "prettier --config ../../.prettierrc --ignore-path ../../.prettierignore --write \"**/*.{js,ts,jsx,tsc}\"",
|
||||||
|
|
|
@ -5,13 +5,14 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {useState, useCallback, useEffect, useRef, memo} from 'react';
|
import React, {useState, useCallback, useEffect, memo} from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
useThemeConfig,
|
useThemeConfig,
|
||||||
isSamePath,
|
isSamePath,
|
||||||
usePrevious,
|
usePrevious,
|
||||||
useAnnouncementBar,
|
useAnnouncementBar,
|
||||||
|
useCollapsible,
|
||||||
} from '@docusaurus/theme-common';
|
} from '@docusaurus/theme-common';
|
||||||
import useLockBodyScroll from '@theme/hooks/useLockBodyScroll';
|
import useLockBodyScroll from '@theme/hooks/useLockBodyScroll';
|
||||||
import useWindowSize, {windowSizes} from '@theme/hooks/useWindowSize';
|
import useWindowSize, {windowSizes} from '@theme/hooks/useWindowSize';
|
||||||
|
@ -79,25 +80,22 @@ function DocSidebarItemCategory({
|
||||||
const isActive = isActiveSidebarItem(item, activePath);
|
const isActive = isActiveSidebarItem(item, activePath);
|
||||||
const wasActive = usePrevious(isActive);
|
const wasActive = usePrevious(isActive);
|
||||||
|
|
||||||
// active categories are always initialized as expanded
|
const {
|
||||||
// the default (item.collapsed) is only used for non-active categories
|
collapsed,
|
||||||
const [collapsed, setCollapsed] = useState(() => {
|
setCollapsed,
|
||||||
if (!collapsible) {
|
getToggleProps,
|
||||||
return false;
|
getCollapsibleProps,
|
||||||
}
|
} = useCollapsible({
|
||||||
return isActive ? false : item.collapsed;
|
// active categories are always initialized as expanded
|
||||||
|
// the default (item.collapsed) is only used for non-active categories
|
||||||
|
initialState: () => {
|
||||||
|
if (!collapsible) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isActive ? false : item.collapsed;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const menuListRef = useRef<HTMLUListElement>(null);
|
|
||||||
const [menuListHeight, setMenuListHeight] = useState<string | undefined>(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
const handleMenuListHeight = (calc = true) => {
|
|
||||||
setMenuListHeight(
|
|
||||||
calc ? `${menuListRef.current?.scrollHeight}px` : undefined,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// If we navigate to a category, it should automatically expand itself
|
// If we navigate to a category, it should automatically expand itself
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const justBecameActive = isActive && !wasActive;
|
const justBecameActive = isActive && !wasActive;
|
||||||
|
@ -106,19 +104,6 @@ function DocSidebarItemCategory({
|
||||||
}
|
}
|
||||||
}, [isActive, wasActive, collapsed]);
|
}, [isActive, wasActive, collapsed]);
|
||||||
|
|
||||||
const handleItemClick = useCallback(
|
|
||||||
(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!menuListHeight) {
|
|
||||||
handleMenuListHeight();
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => setCollapsed((state) => !state), 100);
|
|
||||||
},
|
|
||||||
[menuListHeight],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -135,22 +120,13 @@ function DocSidebarItemCategory({
|
||||||
'menu__link--active': collapsible && isActive,
|
'menu__link--active': collapsible && isActive,
|
||||||
[styles.menuLinkText]: !collapsible,
|
[styles.menuLinkText]: !collapsible,
|
||||||
})}
|
})}
|
||||||
onClick={collapsible ? handleItemClick : undefined}
|
|
||||||
href={collapsible ? '#' : undefined}
|
href={collapsible ? '#' : undefined}
|
||||||
|
{...getToggleProps()}
|
||||||
{...props}>
|
{...props}>
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
<ul
|
|
||||||
className="menu__list"
|
<ul className="menu__list" {...getCollapsibleProps()}>
|
||||||
ref={menuListRef}
|
|
||||||
style={{
|
|
||||||
height: menuListHeight,
|
|
||||||
}}
|
|
||||||
onTransitionEnd={() => {
|
|
||||||
if (!collapsed) {
|
|
||||||
handleMenuListHeight(false);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<DocSidebarItems
|
<DocSidebarItems
|
||||||
items={items}
|
items={items}
|
||||||
tabIndex={collapsed ? '-1' : '0'}
|
tabIndex={collapsed ? '-1' : '0'}
|
||||||
|
|
|
@ -112,13 +112,3 @@
|
||||||
line-height: 0.9;
|
line-height: 0.9;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.menu__list) :global(.menu__list) {
|
|
||||||
overflow-y: hidden;
|
|
||||||
will-change: height;
|
|
||||||
transition: height var(--ifm-transition-fast) linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.menu__list-item--collapsed) :global(.menu__list) {
|
|
||||||
height: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
|
@ -37,6 +37,12 @@ export {useLocationChange} from './utils/useLocationChange';
|
||||||
|
|
||||||
export {usePrevious} from './utils/usePrevious';
|
export {usePrevious} from './utils/usePrevious';
|
||||||
|
|
||||||
|
export {useCollapsible} from './utils/useCollapsible';
|
||||||
|
export type {
|
||||||
|
UseCollapsibleConfig,
|
||||||
|
UseCollapsibleReturns,
|
||||||
|
} from './utils/useCollapsible';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useDocsPreferredVersion,
|
useDocsPreferredVersion,
|
||||||
useDocsPreferredVersionByPluginId,
|
useDocsPreferredVersionByPluginId,
|
||||||
|
|
175
packages/docusaurus-theme-common/src/utils/useCollapsible.ts
Normal file
175
packages/docusaurus-theme-common/src/utils/useCollapsible.ts
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
RefObject,
|
||||||
|
Dispatch,
|
||||||
|
SetStateAction,
|
||||||
|
TransitionEvent,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Lex111: Dynamic transition duration is used in Material deisign, this technique is good for a large number of items.
|
||||||
|
https://material.io/archive/guidelines/motion/duration-easing.html#duration-easing-dynamic-durations
|
||||||
|
https://github.com/mui-org/material-ui/blob/e724d98eba018e55e1a684236a2037e24bcf050c/packages/material-ui/src/styles/createTransitions.js#L40-L43
|
||||||
|
*/
|
||||||
|
function getAutoHeightDuration(height: number) {
|
||||||
|
const constant = height / 36;
|
||||||
|
return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollapsibleAnimationConfig = {
|
||||||
|
duration?: number;
|
||||||
|
easing?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DefaultAnimationEasing = 'ease-in-out';
|
||||||
|
|
||||||
|
export type UseCollapsibleConfig = {
|
||||||
|
initialState: boolean | (() => boolean);
|
||||||
|
animation?: CollapsibleAnimationConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseCollapsibleReturns = {
|
||||||
|
collapsed: boolean;
|
||||||
|
setCollapsed: Dispatch<SetStateAction<boolean>>;
|
||||||
|
toggleCollapsed: () => void;
|
||||||
|
|
||||||
|
getToggleProps(): {
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
getCollapsibleProps(): {
|
||||||
|
ref: RefObject<any>; // any because TS is a pain for HTML element refs, see https://twitter.com/sebastienlorber/status/1412784677795110914
|
||||||
|
onTransitionEnd: (e: TransitionEvent) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const CollapsedStyles = {
|
||||||
|
display: 'none',
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: '0px',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ExpandedStyles = {
|
||||||
|
display: 'block',
|
||||||
|
overflow: 'visible',
|
||||||
|
height: 'auto',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function applyCollapsedStyle(el: HTMLElement, collapsed: boolean) {
|
||||||
|
const collapsedStyles = collapsed ? CollapsedStyles : ExpandedStyles;
|
||||||
|
el.style.display = collapsedStyles.display;
|
||||||
|
el.style.overflow = collapsedStyles.overflow;
|
||||||
|
el.style.height = collapsedStyles.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCollapseAnimation({
|
||||||
|
collapsibleRef,
|
||||||
|
collapsed,
|
||||||
|
animation,
|
||||||
|
}: {
|
||||||
|
collapsibleRef: RefObject<HTMLElement>;
|
||||||
|
collapsed: boolean;
|
||||||
|
animation?: CollapsibleAnimationConfig;
|
||||||
|
}) {
|
||||||
|
const mounted = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = collapsibleRef.current!;
|
||||||
|
|
||||||
|
function getTransitionStyles() {
|
||||||
|
const height = el.scrollHeight;
|
||||||
|
const duration = animation?.duration ?? getAutoHeightDuration(height);
|
||||||
|
const easing = animation?.easing ?? DefaultAnimationEasing;
|
||||||
|
return {
|
||||||
|
transition: `height ${duration}ms ${easing}`,
|
||||||
|
height: `${height}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTransitionStyles() {
|
||||||
|
const transitionStyles = getTransitionStyles();
|
||||||
|
el.style.transition = transitionStyles.transition;
|
||||||
|
el.style.height = transitionStyles.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On mount, we just apply styles, no animated transition
|
||||||
|
if (!mounted.current) {
|
||||||
|
applyCollapsedStyle(el, collapsed);
|
||||||
|
mounted.current = true;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.style.willChange = 'height';
|
||||||
|
|
||||||
|
function startAnimation(): () => void {
|
||||||
|
// When collapsing
|
||||||
|
if (collapsed) {
|
||||||
|
applyTransitionStyles();
|
||||||
|
const animationFrame = requestAnimationFrame(() => {
|
||||||
|
el.style.height = CollapsedStyles.height;
|
||||||
|
el.style.overflow = CollapsedStyles.overflow;
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(animationFrame);
|
||||||
|
}
|
||||||
|
// When expanding
|
||||||
|
else {
|
||||||
|
el.style.display = 'block';
|
||||||
|
const animationFrame = requestAnimationFrame(() => {
|
||||||
|
applyTransitionStyles();
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(animationFrame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return startAnimation();
|
||||||
|
}, [collapsibleRef, collapsed, animation]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
This hook encapsulate the animated collapsible behavior
|
||||||
|
You have to apply the getToggleProps + getCollapsibleProps wire everything
|
||||||
|
Similar to other solutions in the React ecosystem, like Downshift for Selects
|
||||||
|
*/
|
||||||
|
export function useCollapsible({
|
||||||
|
initialState,
|
||||||
|
animation,
|
||||||
|
}: UseCollapsibleConfig): UseCollapsibleReturns {
|
||||||
|
const collapsibleRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState(initialState ?? false);
|
||||||
|
|
||||||
|
const toggleCollapsed = useCallback(() => {
|
||||||
|
setCollapsed((expanded) => !expanded);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useCollapseAnimation({collapsibleRef, collapsed, animation});
|
||||||
|
|
||||||
|
return {
|
||||||
|
collapsed,
|
||||||
|
setCollapsed,
|
||||||
|
toggleCollapsed,
|
||||||
|
|
||||||
|
getToggleProps: () => ({
|
||||||
|
onClick: toggleCollapsed,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getCollapsibleProps: () => ({
|
||||||
|
ref: collapsibleRef,
|
||||||
|
onTransitionEnd: (e) => {
|
||||||
|
if (e.propertyName === 'height') {
|
||||||
|
applyCollapsedStyle(collapsibleRef.current!, collapsed);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue