From 7da093bd752d62783b0ba869146c4196d475c5fb Mon Sep 17 00:00:00 2001 From: John Furrow Date: Sun, 10 Jul 2016 01:04:44 -0700 Subject: [PATCH 1/4] Improve scrolling performance in torrent list --- client/sass/components/_progress-bar.scss | 13 +- client/sass/components/_torrents.scss | 4 +- .../scripts/components/General/ProgressBar.js | 9 +- .../scripts/components/TorrentList/Torrent.js | 78 ++++++++-- .../scripts/components/TorrentList/index.js | 141 ++++++++++-------- 5 files changed, 162 insertions(+), 83 deletions(-) diff --git a/client/sass/components/_progress-bar.scss b/client/sass/components/_progress-bar.scss index 277f93b4..611154c6 100644 --- a/client/sass/components/_progress-bar.scss +++ b/client/sass/components/_progress-bar.scss @@ -22,6 +22,7 @@ $progress-bar--fill--selected: #fff; $progress-bar--fill--stopped: #e3e5e5; .progress-bar { + align-items: center; display: flex; transition: opacity 0.25s; width: 100%; @@ -66,15 +67,13 @@ $progress-bar--fill--stopped: #e3e5e5; } &__fill { - align-items: center; background: $progress-bar--fill; - bottom: 0; + display: block; height: $progress-bar--height; - left: 0; - position: absolute; top: 50%; - transform: translateY(-50%); + transform-origin: 0 50%; transition: background 0.25s, width 0.25s; + width: 100%; z-index: 1; .is-seeding & { @@ -101,6 +100,7 @@ $progress-bar--fill--stopped: #e3e5e5; &__wrapper { flex: 1 1 auto; position: relative; + height: 3px; &:after { background: $progress-bar--fill; @@ -110,8 +110,7 @@ $progress-bar--fill--stopped: #e3e5e5; opacity: 0.5; position: absolute; z-index: 0; - top: 50%; - transform: translateY(-50%); + top: 1px; transition: background 0.25s, opacity 0.25s; width: 100%; diff --git a/client/sass/components/_torrents.scss b/client/sass/components/_torrents.scss index e0f27166..37e2fc33 100644 --- a/client/sass/components/_torrents.scss +++ b/client/sass/components/_torrents.scss @@ -197,8 +197,8 @@ $more-info--border: $textbox-repeater--button--border; &__details { align-items: center; display: flex; - flex: 1; - flex-flow: row wrap; + flex: 1 1 auto; + flex-flow: row; list-style: none; &--primary, diff --git a/client/scripts/components/General/ProgressBar.js b/client/scripts/components/General/ProgressBar.js index 422392fa..a7f93dbc 100644 --- a/client/scripts/components/General/ProgressBar.js +++ b/client/scripts/components/General/ProgressBar.js @@ -2,14 +2,19 @@ import React from 'react'; export default class ProgressBar extends React.Component { render() { + let style = {}; + + if (this.props.percent !== 100) { + style = {transform: `scaleX(${this.props.percent / 100})`}; + } + return (
{this.props.icon}
-
+
); diff --git a/client/scripts/components/TorrentList/Torrent.js b/client/scripts/components/TorrentList/Torrent.js index b3e21bd1..316f9f1d 100644 --- a/client/scripts/components/TorrentList/Torrent.js +++ b/client/scripts/components/TorrentList/Torrent.js @@ -16,11 +16,37 @@ import {torrentStatusIcons} from '../../util/torrentStatusIcons'; import {torrentStatusClasses} from '../../util/torrentStatusClasses'; import UploadThickIcon from '../Icons/UploadThickIcon'; +const ICONS = { + clock: , + disk: , + downloadThick: , + information: , + peers: , + ratio: , + seeds: , + uploadThick: +}; + const METHODS_TO_BIND = [ 'handleClick', 'handleRightClick' ]; +const TORRENT_PRIMITIVES_TO_OBSERVE = [ + 'bytesDone', + 'downloadRate', + 'status', + 'tags', + 'totalPeers', + 'totalSeeds', + 'uploadRate' +]; + +const TORRENT_ARRAYS_TO_OBSERVE = [ + 'status', + 'tags' +]; + export default class Torrent extends React.Component { constructor() { super(); @@ -30,6 +56,33 @@ export default class Torrent extends React.Component { }); } + shouldComponentUpdate(nextProps) { + if (nextProps.selected !== this.props.selected) { + return true; + } + + let nextTorrent = nextProps.torrent; + let {torrent} = this.props; + + let shouldUpdate = TORRENT_ARRAYS_TO_OBSERVE.some((key) => { + let nextArr = nextTorrent[key]; + let currentArr = this.props.torrent[key]; + + return nextArr.length !== currentArr.length || + nextArr.some((nextValue, index) => { + return nextValue !== currentArr[index]; + }); + }); + + if (!shouldUpdate) { + return TORRENT_PRIMITIVES_TO_OBSERVE.some((key) => { + return nextTorrent[key] !== torrent[key]; + }); + } + + return shouldUpdate; + } + getTags(tags) { return tags.map((tag, index) => { return ( @@ -65,7 +118,6 @@ export default class Torrent extends React.Component { let uploadTotal = format.data(torrent.uploadTotal); let torrentClasses = torrentStatusClasses(torrent, this.props.selected ? 'is-selected' : null, 'torrent'); - let torrentStatusIcon = torrentStatusIcons(torrent.status); let isActive = downloadRate.value > 0 || uploadRate.value > 0; let isDownloading = downloadRate.value > 0; @@ -73,13 +125,13 @@ export default class Torrent extends React.Component { let secondaryDetails = [
  • - + {ICONS.downloadThick} {downloadRate.value} {downloadRate.unit}
  • ,
  • - + {ICONS.uploadThick} {uploadRate.value} {uploadRate.unit}
  • @@ -89,7 +141,7 @@ export default class Torrent extends React.Component { secondaryDetails.unshift(
  • - + {ICONS.clock} {eta}
  • ); @@ -107,7 +159,7 @@ export default class Torrent extends React.Component {
    • - + {ICONS.downloadThick} {torrent.percentComplete} %  —  @@ -115,29 +167,29 @@ export default class Torrent extends React.Component { {completed.unit}
    • - + {ICONS.uploadThick} {uploadTotal.value} {uploadTotal.unit}
    • - + {ICONS.ratio} {ratio}
    • - + {ICONS.disk} {totalSize.value} {totalSize.unit}
    • - + {ICONS.calendar} {addedString}
    • - + {ICONS.peers} {torrent.connectedPeers} of {torrent.totalPeers}
    • - + {ICONS.seeds} {torrent.connectedSeeds} of {torrent.totalSeeds}
    @@ -145,11 +197,11 @@ export default class Torrent extends React.Component { {this.getTags(torrent.tags)}
    - + ); diff --git a/client/scripts/components/TorrentList/index.js b/client/scripts/components/TorrentList/index.js index 8b9d5d0b..b89d1ee5 100644 --- a/client/scripts/components/TorrentList/index.js +++ b/client/scripts/components/TorrentList/index.js @@ -32,13 +32,18 @@ const METHODS_TO_BIND = [ 'onTorrentFilterChange', 'onTorrentSelectionChange', 'setScrollPosition', - 'setViewportHeight' + 'setViewportHeight', + 'rerender' ]; +let cachedTorrents = {}; + class TorrentListContainer extends React.Component { constructor() { super(); + this.lastScrollPosition = 0; + this.rerenderTimeout = null; this.state = { emptyTorrentList: false, handleTorrentPriorityChange: null, @@ -47,7 +52,7 @@ class TorrentListContainer extends React.Component { minTorrentIndex: 0, scrollPosition: 0, torrentCount: 0, - torrentHeight: 72, + torrentHeight: 71, torrents: [], torrentRequestError: false, torrentRequestSuccess: false, @@ -58,15 +63,11 @@ class TorrentListContainer extends React.Component { this[method] = this[method].bind(this); }); - this.setScrollPosition = _.throttle(this.setScrollPosition, 100, { - leading: true, + this.setScrollPosition = _.throttle(this.setScrollPosition, 300, { trailing: true }); - this.handleWindowResize = _.throttle(this.setViewportHeight, 350, { - leading: true, - trailing: true - }); + this.handleWindowResize = _.debounce(this.setViewportHeight, 250); } componentDidMount() { @@ -316,7 +317,7 @@ class TorrentListContainer extends React.Component { // Calculate the number of items that should be rendered based on the height // of the viewport. We offset this to render a few more outide of the // container's dimensions, which looks nicer when the user scrolls. - let offset = 10; + let offset = this.rerenderTimeout == null ? 20 : 0; // The number of elements in view is the height of the viewport divided // by the height of the elements. @@ -340,7 +341,11 @@ class TorrentListContainer extends React.Component { } setScrollPosition(scrollValues) { - this.setState({scrollPosition: scrollValues.scrollTop}); + global.requestAnimationFrame(() => { + let {scrollTop} = scrollValues; + this.setState({scrollPosition: scrollTop}); + this.lastScrollPosition = scrollTop; + }); } setViewportHeight() { @@ -351,70 +356,88 @@ class TorrentListContainer extends React.Component { } } + rerender() { + global.requestAnimationFrame(() => { + this.rerenderTimeout = null; + this.forceUpdate(); + }); + } + render() { let content = null; if (this.state.emptyTorrentList || this.state.torrents.length === 0) { content = this.getEmptyTorrentListNotification(); } else if (this.state.torrentRequestSuccess) { - let contextMenu = null; - let selectedTorrents = TorrentStore.getSelectedTorrents(); - let torrents = this.state.torrents; - let viewportLimits = this.getViewportLimits(); + let scrollDelta = this.state.scrollPosition - this.lastScrollPosition; - let listPadding = this.getListPadding( - viewportLimits.minTorrentIndex, - viewportLimits.maxTorrentIndex, - torrents.length - ); + if (this.lastContent != null + && (scrollDelta > 600 || scrollDelta < -600)) { + content = this.lastContent; - let maxTorrentIndex = viewportLimits.maxTorrentIndex; - let minTorrentIndex = viewportLimits.minTorrentIndex; + global.requestAnimationFrame(() => { + if (this.rerenderTimeout != null) { + global.clearTimeout(this.rerenderTimeout); + } + this.rerenderTimeout = global.setTimeout(this.rerender, 500); + }); + } else { + let contextMenu = null; + let selectedTorrents = TorrentStore.getSelectedTorrents(); + let torrents = this.state.torrents; + let viewportLimits = this.getViewportLimits(); - if (minTorrentIndex < 0) { - minTorrentIndex = 0; - } - - if (this.state.contextMenu != null) { - contextMenu = ( - + let listPadding = this.getListPadding( + viewportLimits.minTorrentIndex, + viewportLimits.maxTorrentIndex, + torrents.length ); - } - let visibleTorrents = torrents.slice(minTorrentIndex, maxTorrentIndex); + let maxTorrentIndex = viewportLimits.maxTorrentIndex; + let minTorrentIndex = viewportLimits.minTorrentIndex; - let torrentList = visibleTorrents.map((torrent, index) => { - let isSelected = false; - let hash = torrent.hash; - - if (selectedTorrents.indexOf(hash) > -1) { - isSelected = true; + if (minTorrentIndex < 0) { + minTorrentIndex = 0; } - return ( - - ); - }); + if (this.state.contextMenu != null) { + contextMenu = ( + + ); + } - content = ( -
      - - {contextMenu} - -
    • - {torrentList} -
    • -
    - ); + let visibleTorrents = torrents.slice(minTorrentIndex, maxTorrentIndex); + + let torrentList = visibleTorrents.map((torrent, index) => { + let {hash} = torrent; + + return ( + -1} + handleClick={this.handleTorrentClick} + handleRightClick={this.handleRightClick} + handleDetailsClick={this.handleDetailsClick} /> + ); + }); + + content = ( +
      + + {contextMenu} + +
    • + {torrentList} +
    • +
    + ); + + this.lastContent = content; + } } else { content = this.getLoadingIndicator(); } From ab026108153a7745256be17b475fb13eaaefe54e Mon Sep 17 00:00:00 2001 From: John Furrow Date: Tue, 12 Jul 2016 21:36:41 -0700 Subject: [PATCH 2/4] Simple progress bar caching --- .../scripts/components/General/ProgressBar.js | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/client/scripts/components/General/ProgressBar.js b/client/scripts/components/General/ProgressBar.js index a7f93dbc..ceedf7b7 100644 --- a/client/scripts/components/General/ProgressBar.js +++ b/client/scripts/components/General/ProgressBar.js @@ -1,11 +1,26 @@ import React from 'react'; +let cachedProgressBars = {}; + export default class ProgressBar extends React.Component { render() { - let style = {}; + let {percent} = this.props; + let progressBar; - if (this.props.percent !== 100) { - style = {transform: `scaleX(${this.props.percent / 100})`}; + if (cachedProgressBars[percent] != null) { + progressBar = cachedProgressBars[percent]; + } else { + let style = {}; + + if (percent !== 100) { + style = {transform: `scaleX(${percent / 100})`}; + } + + progressBar = ( +
    +
    +
    + ); } return ( @@ -13,9 +28,7 @@ export default class ProgressBar extends React.Component {
    {this.props.icon}
    -
    -
    -
    + {progressBar}
    ); } From 167b309e1a8697a9dc60e81e5e010c1ccc81f013 Mon Sep 17 00:00:00 2001 From: John Furrow Date: Tue, 12 Jul 2016 21:37:15 -0700 Subject: [PATCH 3/4] Add events for custom scrollbars --- .../components/General/CustomScrollbars.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/client/scripts/components/General/CustomScrollbars.js b/client/scripts/components/General/CustomScrollbars.js index 7acb4faa..7d56dccc 100644 --- a/client/scripts/components/General/CustomScrollbars.js +++ b/client/scripts/components/General/CustomScrollbars.js @@ -2,18 +2,30 @@ import classnames from 'classnames'; import React from 'react'; import {Scrollbars} from 'react-custom-scrollbars'; +const METHODS_TO_BIND = ['getHorizontalThumb', 'getVerticalThumb']; + export default class CustomScrollbar extends React.Component { + constructor() { + super(); + + METHODS_TO_BIND.forEach((method) => { + this[method] = this[method].bind(this); + }); + } + getHorizontalThumb(props) { return (
    + className="scrollbars__thumb scrollbars__thumb--horizontal" + onMouseUp={this.props.onThumbMouseUp} /> ); } getVerticalThumb(props) { return (
    + className="scrollbars__thumb scrollbars__thumb--vertical" + onMouseUp={this.props.onThumbMouseUp} /> ); } @@ -39,6 +51,8 @@ export default class CustomScrollbar extends React.Component { renderThumbHorizontal={this.getHorizontalThumb} renderThumbVertical={this.getVerticalThumb} onScroll={this.props.nativeScrollHandler} + onScrollStart={this.props.onScrollStart} + onScrollStop={this.props.onScrollStop} onScrollFrame={this.props.scrollHandler}> {this.props.children} From bc5c9c81790bca09f5ab544209aa89428ae3beec Mon Sep 17 00:00:00 2001 From: John Furrow Date: Tue, 12 Jul 2016 21:39:24 -0700 Subject: [PATCH 4/4] Scrolling performance improvements --- .../scripts/components/TorrentList/index.js | 101 ++++++++++-------- 1 file changed, 55 insertions(+), 46 deletions(-) diff --git a/client/scripts/components/TorrentList/index.js b/client/scripts/components/TorrentList/index.js index b89d1ee5..4a35948d 100644 --- a/client/scripts/components/TorrentList/index.js +++ b/client/scripts/components/TorrentList/index.js @@ -25,25 +25,26 @@ const METHODS_TO_BIND = [ 'handleContextMenuItemClick', 'handleDetailsClick', 'handleRightClick', + 'handleScrollStop', 'handleTorrentClick', 'onContextMenuChange', 'onReceiveTorrentsError', 'onReceiveTorrentsSuccess', 'onTorrentFilterChange', 'onTorrentSelectionChange', + 'postponeRerender', 'setScrollPosition', - 'setViewportHeight', - 'rerender' + 'setViewportHeight' ]; -let cachedTorrents = {}; +let cachedTorrentList = null; class TorrentListContainer extends React.Component { constructor() { super(); this.lastScrollPosition = 0; - this.rerenderTimeout = null; + this.postponedRerender = false; this.state = { emptyTorrentList: false, handleTorrentPriorityChange: null, @@ -63,11 +64,11 @@ class TorrentListContainer extends React.Component { this[method] = this[method].bind(this); }); - this.setScrollPosition = _.throttle(this.setScrollPosition, 300, { + this.handleWindowResize = _.debounce(this.setViewportHeight, 250); + this.postponeRerender = _.debounce(this.postponeRerender, 500); + this.setScrollPosition = _.throttle(this.setScrollPosition, 250, { trailing: true }); - - this.handleWindowResize = _.debounce(this.setViewportHeight, 250); } componentDidMount() { @@ -317,7 +318,12 @@ class TorrentListContainer extends React.Component { // Calculate the number of items that should be rendered based on the height // of the viewport. We offset this to render a few more outide of the // container's dimensions, which looks nicer when the user scrolls. - let offset = this.rerenderTimeout == null ? 20 : 0; + let offset = 0; + + if (this.postponedRerender === false + && !this.refs.torrentList.refs.scrollbar.dragging) { + offset = 20; + } // The number of elements in view is the height of the viewport divided // by the height of the elements. @@ -340,6 +346,19 @@ class TorrentListContainer extends React.Component { TorrentFilterStore.clearAllFilters(); } + handleScrollStop() { + // Force update as soon as scrolling stops. + this.postponedRerender = false; + this.forceUpdate(); + } + + postponeRerender() { + global.requestAnimationFrame(() => { + this.postponedRerender = false; + this.forceUpdate(); + }); + } + setScrollPosition(scrollValues) { global.requestAnimationFrame(() => { let {scrollTop} = scrollValues; @@ -356,45 +375,33 @@ class TorrentListContainer extends React.Component { } } - rerender() { - global.requestAnimationFrame(() => { - this.rerenderTimeout = null; - this.forceUpdate(); - }); - } - render() { let content = null; if (this.state.emptyTorrentList || this.state.torrents.length === 0) { content = this.getEmptyTorrentListNotification(); } else if (this.state.torrentRequestSuccess) { - let scrollDelta = this.state.scrollPosition - this.lastScrollPosition; + let scrollDelta = Math.abs(this.state.scrollPosition - + this.lastScrollPosition); - if (this.lastContent != null - && (scrollDelta > 600 || scrollDelta < -600)) { - content = this.lastContent; + // If the torrent list is cached and the user is scrolling a large amount, + // or the user is dragging the scroll handle, then we postpone the list's + // rerender for better FPS. + if ((cachedTorrentList != null && scrollDelta > 1000) + || this.refs.torrentList.refs.scrollbar.dragging === true) { + this.postponedRerender = true; + content = cachedTorrentList; global.requestAnimationFrame(() => { - if (this.rerenderTimeout != null) { - global.clearTimeout(this.rerenderTimeout); - } - this.rerenderTimeout = global.setTimeout(this.rerender, 500); + this.postponeRerender(); }); } else { let contextMenu = null; let selectedTorrents = TorrentStore.getSelectedTorrents(); - let torrents = this.state.torrents; - let viewportLimits = this.getViewportLimits(); - - let listPadding = this.getListPadding( - viewportLimits.minTorrentIndex, - viewportLimits.maxTorrentIndex, - torrents.length - ); - - let maxTorrentIndex = viewportLimits.maxTorrentIndex; - let minTorrentIndex = viewportLimits.minTorrentIndex; + let {minTorrentIndex, maxTorrentIndex} = this.getViewportLimits(); + let {torrents} = this.state; + let listPadding = this.getListPadding(minTorrentIndex, maxTorrentIndex, + torrents.length); if (minTorrentIndex < 0) { minTorrentIndex = 0; @@ -407,18 +414,19 @@ class TorrentListContainer extends React.Component { ); } - let visibleTorrents = torrents.slice(minTorrentIndex, maxTorrentIndex); + let torrentList = torrents.slice(minTorrentIndex, maxTorrentIndex).map( + (torrent, index) => { + let {hash} = torrent; - let torrentList = visibleTorrents.map((torrent, index) => { - let {hash} = torrent; - - return ( - -1} - handleClick={this.handleTorrentClick} - handleRightClick={this.handleRightClick} - handleDetailsClick={this.handleDetailsClick} /> - ); - }); + return ( + + ); + } + ); content = (
      @@ -436,7 +444,7 @@ class TorrentListContainer extends React.Component {
    ); - this.lastContent = content; + cachedTorrentList = content; } } else { content = this.getLoadingIndicator(); @@ -445,6 +453,7 @@ class TorrentListContainer extends React.Component { return (
    {content}