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:
Alexey Pyltsyn 2021-07-07 19:50:13 +03:00 committed by GitHub
parent ef70de18dd
commit 237d1a31f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 201 additions and 54 deletions

View file

@ -15,7 +15,7 @@
"license": "MIT",
"scripts": {
"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-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}\"",

View file

@ -5,13 +5,14 @@
* 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 {
useThemeConfig,
isSamePath,
usePrevious,
useAnnouncementBar,
useCollapsible,
} from '@docusaurus/theme-common';
import useLockBodyScroll from '@theme/hooks/useLockBodyScroll';
import useWindowSize, {windowSizes} from '@theme/hooks/useWindowSize';
@ -79,25 +80,22 @@ function DocSidebarItemCategory({
const isActive = isActiveSidebarItem(item, activePath);
const wasActive = usePrevious(isActive);
// active categories are always initialized as expanded
// the default (item.collapsed) is only used for non-active categories
const [collapsed, setCollapsed] = useState(() => {
if (!collapsible) {
return false;
}
return isActive ? false : item.collapsed;
const {
collapsed,
setCollapsed,
getToggleProps,
getCollapsibleProps,
} = useCollapsible({
// 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
useEffect(() => {
const justBecameActive = isActive && !wasActive;
@ -106,19 +104,6 @@ function DocSidebarItemCategory({
}
}, [isActive, wasActive, collapsed]);
const handleItemClick = useCallback(
(e) => {
e.preventDefault();
if (!menuListHeight) {
handleMenuListHeight();
}
setTimeout(() => setCollapsed((state) => !state), 100);
},
[menuListHeight],
);
if (items.length === 0) {
return null;
}
@ -135,22 +120,13 @@ function DocSidebarItemCategory({
'menu__link--active': collapsible && isActive,
[styles.menuLinkText]: !collapsible,
})}
onClick={collapsible ? handleItemClick : undefined}
href={collapsible ? '#' : undefined}
{...getToggleProps()}
{...props}>
{label}
</a>
<ul
className="menu__list"
ref={menuListRef}
style={{
height: menuListHeight,
}}
onTransitionEnd={() => {
if (!collapsed) {
handleMenuListHeight(false);
}
}}>
<ul className="menu__list" {...getCollapsibleProps()}>
<DocSidebarItems
items={items}
tabIndex={collapsed ? '-1' : '0'}

View file

@ -112,13 +112,3 @@
line-height: 0.9;
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;
}

View file

@ -37,6 +37,12 @@ export {useLocationChange} from './utils/useLocationChange';
export {usePrevious} from './utils/usePrevious';
export {useCollapsible} from './utils/useCollapsible';
export type {
UseCollapsibleConfig,
UseCollapsibleReturns,
} from './utils/useCollapsible';
export {
useDocsPreferredVersion,
useDocsPreferredVersionByPluginId,

View 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);
}
},
}),
};
}