From 73bc0cbb94d3087ad9cdce2d74ad2b13e2a922be Mon Sep 17 00:00:00 2001 From: Tom Najdek Date: Fri, 5 Jul 2024 12:34:12 +0200 Subject: [PATCH] Optimization: Reduce number of renders in tab bar By extracting Tab into a separate, memoized component, caching handlers and tweaking how icons and other props are passed, we're able to only re-render tabs that actually changed, rather than re-rendering the entire tab bar all the time. This should be especially noticeable when dragging tabs around but will reduce CPU cycles used in general. --- chrome/content/zotero/components/tabBar.jsx | 183 ++++++++++++++------ chrome/content/zotero/tabs.js | 8 +- 2 files changed, 134 insertions(+), 57 deletions(-) diff --git a/chrome/content/zotero/components/tabBar.jsx b/chrome/content/zotero/components/tabBar.jsx index 69a23eb97a..9767fad760 100644 --- a/chrome/content/zotero/components/tabBar.jsx +++ b/chrome/content/zotero/components/tabBar.jsx @@ -25,12 +25,69 @@ 'use strict'; -import React, { forwardRef, useState, useRef, useImperativeHandle, useEffect, useLayoutEffect } from 'react'; +import React, { forwardRef, useState, useRef, useImperativeHandle, useEffect, useLayoutEffect, memo, useCallback } from 'react'; +import PropTypes from 'prop-types'; import cx from 'classnames'; -const { CSSIcon } = require('./icons'); +const { CSSIcon, CSSItemTypeIcon } = require('./icons'); const SCROLL_ARROW_SCROLL_BY = 222; +const Tab = memo((props) => { + const { icon, id, index, isBeingDragged, isItemType, onContextMenu, onDragEnd, onDragStart, onTabClick, onTabClose, onTabMouseDown, selected, title } = props; + + const handleTabMouseDown = useCallback(event => onTabMouseDown(event, id), [onTabMouseDown, id]); + const handleContextMenu = useCallback(event => onContextMenu(event, id), [onContextMenu, id]); + const handleTabClick = useCallback(event => onTabClick(event, id), [onTabClick, id]); + const handleDragStart = useCallback(event => onDragStart(event, id, index), [onDragStart, id, index]); + const handleTabClose = useCallback(event => onTabClose(event, id), [onTabClose, id]); + + return ( +
+ { isItemType + ? + : + } +
{title}
+
+ +
+
+ ); +}); + +Tab.displayName = 'Tab'; +Tab.propTypes = { + icon: PropTypes.string, + id: PropTypes.string.isRequired, + index: PropTypes.number.isRequired, + isBeingDragged: PropTypes.bool.isRequired, + isItemType: PropTypes.bool, + onContextMenu: PropTypes.func.isRequired, + onDragEnd: PropTypes.func.isRequired, + onDragStart: PropTypes.func.isRequired, + onTabClick: PropTypes.func.isRequired, + onTabClose: PropTypes.func.isRequired, + onTabMouseDown: PropTypes.func.isRequired, + selected: PropTypes.bool.isRequired, + title: PropTypes.string.isRequired +}; + + const TabBar = forwardRef(function (props, ref) { const [tabs, setTabs] = useState([]); const [dragging, setDragging] = useState(false); @@ -109,7 +166,7 @@ const TabBar = forwardRef(function (props, ref) { } } - function handleTabMouseDown(event, id) { + const handleTabMouseDown = useCallback((event, id) => { // Don't select tab if it'll be closed with middle button click on mouse up // or on right-click if ([1, 2].includes(event.button)) { @@ -121,23 +178,23 @@ const TabBar = forwardRef(function (props, ref) { } props.onTabSelect(id); event.stopPropagation(); - } + }, [props.onTabSelect]); - function handleContextMenu(event, id) { + const handleContextMenu = useCallback((event, id) => { let { screenX, screenY } = event; // Popup gets immediately closed without this setTimeout(() => { props.onContextMenu(screenX, screenY, id); }); - } + }, [props.onContextMenu]); - function handleTabClick(event, id) { + const handleTabClick = useCallback((event, id) => { if (event.button === 1) { props.onTabClose(id); } - } + }, [props.onTabClose]); - function handleDragStart(event, id, index) { + const handleDragStart = useCallback((event, id, index) => { // Library tab is not draggable if (index === 0) { event.preventDefault(); @@ -157,14 +214,14 @@ const TabBar = forwardRef(function (props, ref) { setDragging(true); // Store the current tab id dragIDRef.current = id; - } + }, []); - function handleDragEnd() { + const handleDragEnd = useCallback(() => { setDragging(false); props.refocusReader(); - } + }, [props.refocusReader]); - function handleTabBarDragOver(event) { + const handleTabBarDragOver = useCallback((event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; // Throttle @@ -220,15 +277,15 @@ const TabBar = forwardRef(function (props, ref) { props.onTabMove(dragIDRef.current, index); } mouseMoveWaitUntil.current = Date.now() + 20; - } + }, [props.onTabMove]); - function handleTabClose(event, id) { + const handleTabClose = useCallback((event, id) => { props.onTabClose(id); event.stopPropagation(); - } + }, [props.onTabClose]); - function handleWheel(event) { + const handleWheel = useCallback((event) => { // Normalize wheel speed let x = event.deltaX || event.deltaY; if (x && event.deltaMode) { @@ -242,54 +299,27 @@ const TabBar = forwardRef(function (props, ref) { window.requestAnimationFrame(() => { tabsRef.current.scrollLeft += x; }); - } + }, []); - function handleClickScrollStart() { + const handleClickScrollStart = useCallback(() => { tabsRef.current.scrollTo({ left: tabsRef.current.scrollLeft - (SCROLL_ARROW_SCROLL_BY * (Zotero.rtl ? -1 : 1)), behavior: 'smooth' }); - } + }, []); - function handleClickScrollEnd() { + const handleClickScrollEnd = useCallback(() => { tabsRef.current.scrollTo({ left: tabsRef.current.scrollLeft + (SCROLL_ARROW_SCROLL_BY * (Zotero.rtl ? -1 : 1)), behavior: 'smooth' }); - } + }, []); // Prevent maximizing/minimizing window - function handleScrollArrowDoubleClick(event) { + const handleScrollArrowDoubleClick = useCallback((event) => { event.preventDefault(); - } + }, []); - function renderTab({ id, title, selected, icon }, index) { - return ( -
handleTabMouseDown(event, id)} - onContextMenu={(event) => handleContextMenu(event, id)} - onClick={(event) => handleTabClick(event, id)} - onAuxClick={(event) => handleTabClick(event, id)} - onDragStart={(event) => handleDragStart(event, id, index)} - onDragEnd={handleDragEnd} - tabIndex="-1" - > - {icon} -
{title}
-
handleTabClose(event, id)} - > - -
-
- ); - } - return (
- {tabs.length ? renderTab(tabs[0], 0) : null} + {tabs.length + ? + : null}
- {tabs.map((tab, index) => renderTab(tab, index))} + {tabs.map((tab, index) => )}
; + icon = { isItemType: false, icon: iconName }; } } else if (tab.data?.itemID) { try { let item = Zotero.Items.get(tab.data.itemID); - icon = ; + icon = { isItemType: true, icon: item.getItemTypeIconName(true) }; } catch (e) { // item might not yet be loaded, we will get the right icon on the next update // but until then use a default placeholder - icon = ; + icon = { isItemType: true, icon: null }; } } @@ -119,7 +119,7 @@ var Zotero_Tabs = new function () { type: tab.type, title: tab.title, selected: tab.id == this._selectedID, - icon, + ...icon, }; })); // Disable File > Close menuitem if multiple tabs are open