From 237d1a31f5e0f39043c1154717726f05c6bbbd44 Mon Sep 17 00:00:00 2001 From: Alexey Pyltsyn Date: Wed, 7 Jul 2021 19:50:13 +0300 Subject: [PATCH] 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 --- .../docusaurus-theme-classic/package.json | 2 +- .../src/theme/DocSidebar/index.tsx | 62 ++----- .../src/theme/DocSidebar/styles.module.css | 10 - packages/docusaurus-theme-common/src/index.ts | 6 + .../src/utils/useCollapsible.ts | 175 ++++++++++++++++++ 5 files changed, 201 insertions(+), 54 deletions(-) create mode 100644 packages/docusaurus-theme-common/src/utils/useCollapsible.ts diff --git a/packages/docusaurus-theme-classic/package.json b/packages/docusaurus-theme-classic/package.json index 9705250c7a..99bcd14d3a 100644 --- a/packages/docusaurus-theme-classic/package.json +++ b/packages/docusaurus-theme-classic/package.json @@ -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}\"", diff --git a/packages/docusaurus-theme-classic/src/theme/DocSidebar/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocSidebar/index.tsx index 79c82ada49..ae96ae86b5 100644 --- a/packages/docusaurus-theme-classic/src/theme/DocSidebar/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/DocSidebar/index.tsx @@ -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(null); - const [menuListHeight, setMenuListHeight] = useState( - 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} -
    { - if (!collapsed) { - handleMenuListHeight(false); - } - }}> + +
      boolean); + animation?: CollapsibleAnimationConfig; +}; + +export type UseCollapsibleReturns = { + collapsed: boolean; + setCollapsed: Dispatch>; + toggleCollapsed: () => void; + + getToggleProps(): { + onClick?: () => void; + }; + + getCollapsibleProps(): { + ref: RefObject; // 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; + 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(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); + } + }, + }), + }; +}