From 253c5c78663314d6cb2c1d6d03a0139eddddaff9 Mon Sep 17 00:00:00 2001 From: Arnost Pleskot Date: Wed, 31 May 2023 15:37:13 +0200 Subject: [PATCH] perf: memoize rendering of library (#6622) Co-authored-by: dwelle --- src/components/LibraryMenu.tsx | 129 ++++++++++----- src/components/LibraryMenuItems.scss | 6 + src/components/LibraryMenuItems.tsx | 229 ++++++++++++++++---------- src/components/LibraryMenuSection.tsx | 145 ++++++---------- src/components/LibraryUnit.scss | 2 +- src/components/LibraryUnit.tsx | 171 +++++++++---------- src/hooks/useLibraryItemSvg.ts | 1 + 7 files changed, 377 insertions(+), 306 deletions(-) diff --git a/src/components/LibraryMenu.tsx b/src/components/LibraryMenu.tsx index a49f3197..f5f386d4 100644 --- a/src/components/LibraryMenu.tsx +++ b/src/components/LibraryMenu.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React, { useState, useCallback, useMemo, useRef } from "react"; import Library, { distributeLibraryItemsOnSquareGrid, libraryItemsAtom, @@ -27,6 +27,8 @@ import { useUIAppState } from "../context/ui-appState"; import "./LibraryMenu.scss"; import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; +import { isShallowEqual } from "../utils"; +import { NonDeletedExcalidrawElement } from "../element/types"; export const isLibraryMenuOpenAtom = atom(false); @@ -42,7 +44,9 @@ export const LibraryMenuContent = ({ libraryReturnUrl, library, id, - appState, + theme, + selectedItems, + onSelectItems, }: { pendingElements: LibraryItem["elements"]; onInsertLibraryItems: (libraryItems: LibraryItems) => void; @@ -51,33 +55,47 @@ export const LibraryMenuContent = ({ libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; library: Library; id: string; - appState: UIAppState; + theme: UIAppState["theme"]; + selectedItems: LibraryItem["id"][]; + onSelectItems: (id: LibraryItem["id"][]) => void; }) => { const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); - const addToLibrary = useCallback( - async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => { - trackEvent("element", "addToLibrary", "ui"); - if (elements.some((element) => element.type === "image")) { - return setAppState({ - errorMessage: "Support for adding images to the library coming soon!", + const _onAddToLibrary = useCallback( + (elements: LibraryItem["elements"]) => { + const addToLibrary = async ( + processedElements: LibraryItem["elements"], + libraryItems: LibraryItems, + ) => { + trackEvent("element", "addToLibrary", "ui"); + if (processedElements.some((element) => element.type === "image")) { + return setAppState({ + errorMessage: + "Support for adding images to the library coming soon!", + }); + } + const nextItems: LibraryItems = [ + { + status: "unpublished", + elements: processedElements, + id: randomId(), + created: Date.now(), + }, + ...libraryItems, + ]; + onAddToLibrary(); + library.setLibrary(nextItems).catch(() => { + setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); }); - } - const nextItems: LibraryItems = [ - { - status: "unpublished", - elements, - id: randomId(), - created: Date.now(), - }, - ...libraryItems, - ]; - onAddToLibrary(); - library.setLibrary(nextItems).catch(() => { - setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); - }); + }; + addToLibrary(elements, libraryItemsData.libraryItems); }, - [onAddToLibrary, library, setAppState], + [onAddToLibrary, library, setAppState, libraryItemsData.libraryItems], + ); + + const libraryItems = useMemo( + () => libraryItemsData.libraryItems, + [libraryItemsData], ); if ( @@ -103,15 +121,15 @@ export const LibraryMenuContent = ({ - addToLibrary(elements, libraryItemsData.libraryItems) - } + libraryItems={libraryItems} + onAddToLibrary={_onAddToLibrary} onInsertLibraryItems={onInsertLibraryItems} pendingElements={pendingElements} id={id} libraryReturnUrl={libraryReturnUrl} - theme={appState.theme} + theme={theme} + onSelectItems={onSelectItems} + selectedItems={selectedItems} /> {showBtn && ( )} ); }; +const usePendingElementsMemo = ( + appState: UIAppState, + elements: readonly NonDeletedExcalidrawElement[], +) => { + const create = () => getSelectedElements(elements, appState, true); + const val = useRef(create()); + const prevAppState = useRef(appState); + const prevElements = useRef(elements); + + if ( + !isShallowEqual( + appState.selectedElementIds, + prevAppState.current.selectedElementIds, + ) || + !isShallowEqual(elements, prevElements.current) + ) { + val.current = create(); + prevAppState.current = appState; + prevElements.current = elements; + } + return val.current; +}; + /** * This component is meant to be rendered inside inside our * or host apps Sidebar components. @@ -136,9 +177,19 @@ export const LibraryMenu = () => { const appState = useUIAppState(); const setAppState = useExcalidrawSetAppState(); const elements = useExcalidrawElements(); + const [selectedItems, setSelectedItems] = useState([]); + const memoizedLibrary = useMemo(() => library, [library]); + // BUG: pendingElements are still causing some unnecessary rerenders because clicking into canvas returns some ids even when no element is selected. + const pendingElements = usePendingElementsMemo(appState, elements); - const onAddToLibrary = useCallback(() => { - // deselect canvas elements + const onInsertLibraryItems = useCallback( + (libraryItems: LibraryItems) => { + onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); + }, + [onInsertElements], + ); + + const deselectItems = useCallback(() => { setAppState({ selectedElementIds: {}, selectedGroupIds: {}, @@ -147,16 +198,16 @@ export const LibraryMenu = () => { return ( { - onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); - }} - onAddToLibrary={onAddToLibrary} + pendingElements={pendingElements} + onInsertLibraryItems={onInsertLibraryItems} + onAddToLibrary={deselectItems} setAppState={setAppState} libraryReturnUrl={appProps.libraryReturnUrl} - library={library} + library={memoizedLibrary} id={id} - appState={appState} + theme={appState.theme} + selectedItems={selectedItems} + onSelectItems={setSelectedItems} /> ); }; diff --git a/src/components/LibraryMenuItems.scss b/src/components/LibraryMenuItems.scss index 1a3fa971..8ac09aab 100644 --- a/src/components/LibraryMenuItems.scss +++ b/src/components/LibraryMenuItems.scss @@ -73,6 +73,12 @@ } } + &__grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-gap: 1rem; + } + .separator { width: 100%; display: flex; diff --git a/src/components/LibraryMenuItems.tsx b/src/components/LibraryMenuItems.tsx index 8dd6b30f..ff88e537 100644 --- a/src/components/LibraryMenuItems.tsx +++ b/src/components/LibraryMenuItems.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { serializeLibraryAsJSON } from "../data/json"; import { t } from "../i18n"; import { @@ -14,12 +20,22 @@ import Spinner from "./Spinner"; import { duplicateElements } from "../element/newElement"; import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent"; -import LibraryMenuSection from "./LibraryMenuSection"; +import { + LibraryMenuSection, + LibraryMenuSectionGrid, +} from "./LibraryMenuSection"; import { useScrollPosition } from "../hooks/useScrollPosition"; import { useLibraryCache } from "../hooks/useLibraryItemSvg"; import "./LibraryMenuItems.scss"; +// using an odd number of items per batch so the rendering creates an irregular +// pattern which looks more organic +const ITEMS_RENDERED_PER_BATCH = 17; +// when render outputs cached we can render many more items per batch to +// speed it up +const CACHED_ITEMS_RENDERED_PER_BATCH = 64; + export default function LibraryMenuItems({ isLoading, libraryItems, @@ -29,6 +45,8 @@ export default function LibraryMenuItems({ theme, id, libraryReturnUrl, + onSelectItems, + selectedItems, }: { isLoading: boolean; libraryItems: LibraryItems; @@ -38,8 +56,9 @@ export default function LibraryMenuItems({ libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; theme: UIAppState["theme"]; id: string; + selectedItems: LibraryItem["id"][]; + onSelectItems: (id: LibraryItem["id"][]) => void; }) { - const [selectedItems, setSelectedItems] = useState([]); const libraryContainerRef = useRef(null); const scrollPosition = useScrollPosition(libraryContainerRef); @@ -49,13 +68,16 @@ export default function LibraryMenuItems({ libraryContainerRef.current?.scrollTo(0, scrollPosition); } }, []); // eslint-disable-line react-hooks/exhaustive-deps - const { svgCache } = useLibraryCache(); - const unpublishedItems = libraryItems.filter( - (item) => item.status !== "published", + const { svgCache } = useLibraryCache(); + const unpublishedItems = useMemo( + () => libraryItems.filter((item) => item.status !== "published"), + [libraryItems], ); - const publishedItems = libraryItems.filter( - (item) => item.status === "published", + + const publishedItems = useMemo( + () => libraryItems.filter((item) => item.status === "published"), + [libraryItems], ); const showBtn = !libraryItems.length && !pendingElements.length; @@ -69,50 +91,56 @@ export default function LibraryMenuItems({ LibraryItem["id"] | null >(null); - const onItemSelectToggle = ( - id: LibraryItem["id"], - event: React.MouseEvent, - ) => { - const shouldSelect = !selectedItems.includes(id); + const onItemSelectToggle = useCallback( + (id: LibraryItem["id"], event: React.MouseEvent) => { + const shouldSelect = !selectedItems.includes(id); - const orderedItems = [...unpublishedItems, ...publishedItems]; + const orderedItems = [...unpublishedItems, ...publishedItems]; - if (shouldSelect) { - if (event.shiftKey && lastSelectedItem) { - const rangeStart = orderedItems.findIndex( - (item) => item.id === lastSelectedItem, - ); - const rangeEnd = orderedItems.findIndex((item) => item.id === id); + if (shouldSelect) { + if (event.shiftKey && lastSelectedItem) { + const rangeStart = orderedItems.findIndex( + (item) => item.id === lastSelectedItem, + ); + const rangeEnd = orderedItems.findIndex((item) => item.id === id); - if (rangeStart === -1 || rangeEnd === -1) { - setSelectedItems([...selectedItems, id]); - return; + if (rangeStart === -1 || rangeEnd === -1) { + onSelectItems([...selectedItems, id]); + return; + } + + const selectedItemsMap = arrayToMap(selectedItems); + const nextSelectedIds = orderedItems.reduce( + (acc: LibraryItem["id"][], item, idx) => { + if ( + (idx >= rangeStart && idx <= rangeEnd) || + selectedItemsMap.has(item.id) + ) { + acc.push(item.id); + } + return acc; + }, + [], + ); + + onSelectItems(nextSelectedIds); + } else { + onSelectItems([...selectedItems, id]); } - - const selectedItemsMap = arrayToMap(selectedItems); - const nextSelectedIds = orderedItems.reduce( - (acc: LibraryItem["id"][], item, idx) => { - if ( - (idx >= rangeStart && idx <= rangeEnd) || - selectedItemsMap.has(item.id) - ) { - acc.push(item.id); - } - return acc; - }, - [], - ); - - setSelectedItems(nextSelectedIds); + setLastSelectedItem(id); } else { - setSelectedItems([...selectedItems, id]); + setLastSelectedItem(null); + onSelectItems(selectedItems.filter((_id) => _id !== id)); } - setLastSelectedItem(id); - } else { - setLastSelectedItem(null); - setSelectedItems(selectedItems.filter((_id) => _id !== id)); - } - }; + }, + [ + lastSelectedItem, + onSelectItems, + publishedItems, + selectedItems, + unpublishedItems, + ], + ); const getInsertedElements = useCallback( (id: string) => { @@ -136,37 +164,45 @@ export default function LibraryMenuItems({ [libraryItems, selectedItems], ); - const onItemDrag = (id: LibraryItem["id"], event: React.DragEvent) => { - event.dataTransfer.setData( - MIME_TYPES.excalidrawlib, - serializeLibraryAsJSON(getInsertedElements(id)), - ); - }; + const onItemDrag = useCallback( + (id: LibraryItem["id"], event: React.DragEvent) => { + event.dataTransfer.setData( + MIME_TYPES.excalidrawlib, + serializeLibraryAsJSON(getInsertedElements(id)), + ); + }, + [getInsertedElements], + ); - const isItemSelected = (id: LibraryItem["id"] | null) => { - if (!id) { - return false; - } + const isItemSelected = useCallback( + (id: LibraryItem["id"] | null) => { + if (!id) { + return false; + } - return selectedItems.includes(id); - }; + return selectedItems.includes(id); + }, + [selectedItems], + ); + + const onAddToLibraryClick = useCallback(() => { + onAddToLibrary(pendingElements); + }, [pendingElements, onAddToLibrary]); const onItemClick = useCallback( (id: LibraryItem["id"] | null) => { - if (!id) { - onAddToLibrary(pendingElements); - } else { + if (id) { onInsertLibraryItems(getInsertedElements(id)); } }, - [ - getInsertedElements, - onAddToLibrary, - onInsertLibraryItems, - pendingElements, - ], + [getInsertedElements, onInsertLibraryItems], ); + const itemsRenderedPerBatch = + svgCache.size >= libraryItems.length + ? CACHED_ITEMS_RENDERED_PER_BATCH + : ITEMS_RENDERED_PER_BATCH; + return (
)} @@ -225,20 +261,28 @@ export default function LibraryMenuItems({
) : ( - + + {pendingElements.length > 0 && ( + + )} + + )} @@ -251,14 +295,17 @@ export default function LibraryMenuItems({ )} {publishedItems.length > 0 ? ( - + + + ) : unpublishedItems.length > 0 ? (
)} diff --git a/src/components/LibraryMenuSection.tsx b/src/components/LibraryMenuSection.tsx index 496f045b..0e10470f 100644 --- a/src/components/LibraryMenuSection.tsx +++ b/src/components/LibraryMenuSection.tsx @@ -1,16 +1,10 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { LibraryUnit } from "./LibraryUnit"; +import React, { memo, ReactNode, useEffect, useState } from "react"; +import { EmptyLibraryUnit, LibraryUnit } from "./LibraryUnit"; import { LibraryItem } from "../types"; -import Stack from "./Stack"; -import clsx from "clsx"; import { ExcalidrawElement, NonDeleted } from "../element/types"; import { SvgCache } from "../hooks/useLibraryItemSvg"; import { useTransition } from "../hooks/useTransition"; -const ITEMS_PER_ROW = 4; -const ROWS_RENDERED_PER_BATCH = 6; -const CACHED_ROWS_RENDERED_PER_BATCH = 16; - type LibraryOrPendingItem = ( | LibraryItem | /* pending library item */ { @@ -26,91 +20,58 @@ interface Props { onItemDrag: (id: LibraryItem["id"], event: React.DragEvent) => void; isItemSelected: (id: LibraryItem["id"] | null) => boolean; svgCache: SvgCache; + itemsRenderedPerBatch: number; } -function LibraryRow({ - items, - onItemSelectToggle, - onItemDrag, - isItemSelected, - onClick, - svgCache, -}: Props) { - return ( - - {items.map((item) => ( - - - - ))} - - ); -} +export const LibraryMenuSectionGrid = ({ + children, +}: { + children: ReactNode; +}) => { + return
{children}
; +}; -const EmptyLibraryRow = () => ( - - {Array.from({ length: ITEMS_PER_ROW }).map((_, index) => ( - -
- - ))} - +export const LibraryMenuSection = memo( + ({ + items, + onItemSelectToggle, + onItemDrag, + isItemSelected, + onClick, + svgCache, + itemsRenderedPerBatch, + }: Props) => { + const [, startTransition] = useTransition(); + const [index, setIndex] = useState(0); + + useEffect(() => { + if (index < items.length) { + startTransition(() => { + setIndex(index + itemsRenderedPerBatch); + }); + } + }, [index, items.length, startTransition, itemsRenderedPerBatch]); + + return ( + <> + {items.map((item, i) => { + return i < index ? ( + + ) : ( + + ); + })} + + ); + }, ); - -function LibraryMenuSection({ - items, - onItemSelectToggle, - onItemDrag, - isItemSelected, - onClick, - svgCache, -}: Props) { - const rows = Math.ceil(items.length / ITEMS_PER_ROW); - const [, startTransition] = useTransition(); - const [index, setIndex] = useState(0); - - const rowsRenderedPerBatch = useMemo(() => { - return svgCache.size === 0 - ? ROWS_RENDERED_PER_BATCH - : CACHED_ROWS_RENDERED_PER_BATCH; - }, [svgCache]); - - useEffect(() => { - if (index < rows) { - startTransition(() => { - setIndex(index + rowsRenderedPerBatch); - }); - } - }, [index, rows, startTransition, rowsRenderedPerBatch]); - - return ( - <> - {Array.from({ length: rows }).map((_, i) => - i < index ? ( - - ) : ( - - ), - )} - - ); -} - -export default LibraryMenuSection; diff --git a/src/components/LibraryUnit.scss b/src/components/LibraryUnit.scss index 1bfc1e6d..a5f26bff 100644 --- a/src/components/LibraryUnit.scss +++ b/src/components/LibraryUnit.scss @@ -30,7 +30,7 @@ var(--color-gray-10) ); background-size: 200% 200%; - animation: library-unit__skeleton-opacity-animation 0.3s linear; + animation: library-unit__skeleton-opacity-animation 0.2s linear; } } diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index f256b49f..ce5d4ebb 100644 --- a/src/components/LibraryUnit.tsx +++ b/src/components/LibraryUnit.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { useEffect, useRef, useState } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { useDevice } from "../components/App"; import { LibraryItem } from "../types"; import "./LibraryUnit.scss"; @@ -7,96 +7,101 @@ import { CheckboxItem } from "./CheckboxItem"; import { PlusIcon } from "./icons"; import { SvgCache, useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; -export const LibraryUnit = ({ - id, - elements, - isPending, - onClick, - selected, - onToggle, - onDrag, - svgCache, -}: { - id: LibraryItem["id"] | /** for pending item */ null; - elements?: LibraryItem["elements"]; - isPending?: boolean; - onClick: (id: LibraryItem["id"] | null) => void; - selected: boolean; - onToggle: (id: string, event: React.MouseEvent) => void; - onDrag: (id: string, event: React.DragEvent) => void; - svgCache: SvgCache; -}) => { - const ref = useRef(null); - const svg = useLibraryItemSvg(id, elements, svgCache); +export const LibraryUnit = memo( + ({ + id, + elements, + isPending, + onClick, + selected, + onToggle, + onDrag, + svgCache, + }: { + id: LibraryItem["id"] | /** for pending item */ null; + elements?: LibraryItem["elements"]; + isPending?: boolean; + onClick: (id: LibraryItem["id"] | null) => void; + selected: boolean; + onToggle: (id: string, event: React.MouseEvent) => void; + onDrag: (id: string, event: React.DragEvent) => void; + svgCache: SvgCache; + }) => { + const ref = useRef(null); + const svg = useLibraryItemSvg(id, elements, svgCache); - useEffect(() => { - const node = ref.current; + useEffect(() => { + const node = ref.current; - if (!node) { - return; - } + if (!node) { + return; + } - if (svg) { - svg.querySelector(".style-fonts")?.remove(); - node.innerHTML = svg.outerHTML; - } + if (svg) { + node.innerHTML = svg.outerHTML; + } - return () => { - node.innerHTML = ""; - }; - }, [elements, svg]); + return () => { + node.innerHTML = ""; + }; + }, [svg]); - const [isHovered, setIsHovered] = useState(false); - const isMobile = useDevice().isMobile; - const adder = isPending && ( -
{PlusIcon}
- ); + const [isHovered, setIsHovered] = useState(false); + const isMobile = useDevice().isMobile; + const adder = isPending && ( +
{PlusIcon}
+ ); - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > + return (
{ - if (id && event.shiftKey) { - onToggle(id, event); - } else { - onClick(id); + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
{ + if (id && event.shiftKey) { + onToggle(id, event); + } else { + onClick(id); + } } - } - : undefined - } - onDragStart={(event) => { - if (!id) { - event.preventDefault(); - return; + : undefined } - setIsHovered(false); - onDrag(id, event); - }} - /> - {adder} - {id && elements && (isHovered || isMobile || selected) && ( - onToggle(id, event)} - className="library-unit__checkbox" + onDragStart={(event) => { + if (!id) { + event.preventDefault(); + return; + } + setIsHovered(false); + onDrag(id, event); + }} /> - )} -
- ); -}; + {adder} + {id && elements && (isHovered || isMobile || selected) && ( + onToggle(id, event)} + className="library-unit__checkbox" + /> + )} +
+ ); + }, +); + +export const EmptyLibraryUnit = () => ( +
+); diff --git a/src/hooks/useLibraryItemSvg.ts b/src/hooks/useLibraryItemSvg.ts index ba980219..1c27f0ce 100644 --- a/src/hooks/useLibraryItemSvg.ts +++ b/src/hooks/useLibraryItemSvg.ts @@ -39,6 +39,7 @@ export const useLibraryItemSvg = ( // When there is no svg in cache export it and save to cache (async () => { const exportedSvg = await exportLibraryItemToSvg(elements); + exportedSvg.querySelector(".style-fonts")?.remove(); if (exportedSvg) { svgCache.set(id, exportedSvg);