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.
This commit is contained in:
Tom Najdek 2024-07-05 12:34:12 +02:00 committed by Dan Stillman
parent d9790b707a
commit 73bc0cbb94
2 changed files with 134 additions and 57 deletions

View file

@ -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 (
<div
key={id}
data-id={id}
className={cx('tab', { selected, dragging: isBeingDragged })}
draggable={true}
onMouseDown={handleTabMouseDown}
onContextMenu={handleContextMenu}
onClick={handleTabClick}
onAuxClick={handleTabClick}
onDragStart={handleDragStart}
onDragEnd={onDragEnd}
tabIndex="-1"
>
{ isItemType
? <CSSItemTypeIcon itemType={icon} className="tab-icon" />
: <CSSIcon name={icon} className="tab-icon" />
}
<div className="tab-name" title={title}>{title}</div>
<div
className="tab-close"
onClick={handleTabClose}
>
<CSSIcon name="x-8" className="icon-16" />
</div>
</div>
);
});
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 (
<div
key={id}
data-id={id}
className={cx('tab', { selected, dragging: dragging && id === dragIDRef.current })}
draggable={true}
onMouseDown={(event) => 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}
<div className="tab-name" title={title}>{title}</div>
<div
className="tab-close"
onClick={(event) => handleTabClose(event, id)}
>
<CSSIcon name="x-8" className="icon-16" />
</div>
</div>
);
}
return (
<div>
<div
@ -301,7 +331,20 @@ const TabBar = forwardRef(function (props, ref) {
<div
className="tabs"
>
{tabs.length ? renderTab(tabs[0], 0) : null}
{tabs.length
? <Tab
{ ...tabs[0] }
key={tabs[0].id}
index={0}
isBeingDragged={ false }
onContextMenu={ handleContextMenu}
onDragEnd={ handleDragEnd }
onDragStart={ handleDragStart}
onTabClick={ handleTabClick}
onTabClose={ handleTabClose}
onTabMouseDown = { handleTabMouseDown }
/>
: null}
</div>
</div>
<div
@ -324,7 +367,18 @@ const TabBar = forwardRef(function (props, ref) {
onScroll={updateScrollArrows}
dir={Zotero.dir}
>
{tabs.map((tab, index) => renderTab(tab, index))}
{tabs.map((tab, index) => <Tab
{...tab}
key={tab.id}
index={index}
isBeingDragged={dragging && dragIDRef.current === tab.id}
onContextMenu={handleContextMenu}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
onTabClick={handleTabClick}
onTabClose={handleTabClose}
onTabMouseDown={handleTabMouseDown}
/>)}
</div>
</div>
<div
@ -346,4 +400,27 @@ const TabBar = forwardRef(function (props, ref) {
TabBar.displayName = 'TabBar';
TabBar.propTypes = {
onTabSelect: PropTypes.func.isRequired,
onTabClose: PropTypes.func.isRequired,
onTabMove: PropTypes.func.isRequired,
refocusReader: PropTypes.func.isRequired,
onContextMenu: PropTypes.func.isRequired,
tabs: PropTypes.arrayOf(
PropTypes.shape({
icon: PropTypes.element.isRequired,
id: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
isBeingDragged: PropTypes.bool.isRequired,
onContextMenu: PropTypes.func.isRequired,
onDragEnd: PropTypes.func.isRequired,
onDragStart: PropTypes.func.isRequired,
onTabClick: PropTypes.func.isRequired,
onTabMouseDown: PropTypes.func.isRequired,
selected: PropTypes.bool.isRequired,
title: PropTypes.string.isRequired
})
).isRequired
};
export default TabBar;

View file

@ -99,18 +99,18 @@ var Zotero_Tabs = new function () {
let index = ZoteroPane.collectionsView?.selection?.focused;
if (typeof index !== 'undefined' && ZoteroPane.collectionsView.getRow(index)) {
let iconName = ZoteroPane.collectionsView.getIconName(index);
icon = <CSSIcon name={iconName} className="tab-icon" />;
icon = { isItemType: false, icon: iconName };
}
}
else if (tab.data?.itemID) {
try {
let item = Zotero.Items.get(tab.data.itemID);
icon = <CSSItemTypeIcon itemType={item.getItemTypeIconName(true)} className="tab-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 = <CSSItemTypeIcon className="tab-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