about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorNolan Lawson <nolan@nolanlawson.com>2017-05-29 09:17:51 -0700
committerEugen Rochko <eugen@zeonfederated.com>2017-05-29 18:17:51 +0200
commit34a93ccf571426ffaac68c573f231bb679c0ff3b (patch)
treed4141134e914772377e969f21978f630f52b0eb4 /app
parent922fb74197ef65e6f6ebfba627f80eb483486c1c (diff)
Add IntersectionObserverWrapper to cut down on re-renders (#3406)
Diffstat (limited to 'app')
-rw-r--r--app/javascript/mastodon/components/status.js66
-rw-r--r--app/javascript/mastodon/components/status_list.js65
-rw-r--r--app/javascript/mastodon/features/ui/util/intersection_observer_wrapper.js48
3 files changed, 100 insertions, 79 deletions
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index a9f9c1b6b..d35642ede 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -32,16 +32,16 @@ class Status extends ImmutablePureComponent {
     onOpenMedia: PropTypes.func,
     onOpenVideo: PropTypes.func,
     onBlock: PropTypes.func,
-    onRef: PropTypes.func,
-    isIntersecting: PropTypes.bool,
     me: PropTypes.number,
     boostModal: PropTypes.bool,
     autoPlayGif: PropTypes.bool,
     muted: PropTypes.bool,
+    intersectionObserverWrapper: PropTypes.object,
   };
 
   state = {
-    isHidden: false,
+    isIntersecting: true, // assume intersecting until told otherwise
+    isHidden: false, // set to true in requestIdleCallback to trigger un-render
   }
 
   // Avoid checking props that are functions (and whose equality will always
@@ -59,12 +59,12 @@ class Status extends ImmutablePureComponent {
   updateOnStates = []
 
   shouldComponentUpdate (nextProps, nextState) {
-    if (nextProps.isIntersecting === false && nextState.isHidden) {
+    if (!nextState.isIntersecting && nextState.isHidden) {
       // It's only if we're not intersecting (i.e. offscreen) and isHidden is true
       // that either "isIntersecting" or "isHidden" matter, and then they're
       // the only things that matter.
-      return this.props.isIntersecting !== false || !this.state.isHidden;
-    } else if (nextProps.isIntersecting !== false && this.props.isIntersecting === false) {
+      return this.state.isIntersecting || !this.state.isHidden;
+    } else if (nextState.isIntersecting && !this.state.isIntersecting) {
       // If we're going from a non-intersecting state to an intersecting state,
       // (i.e. offscreen to onscreen), then we definitely need to re-render
       return true;
@@ -73,21 +73,47 @@ class Status extends ImmutablePureComponent {
     return super.shouldComponentUpdate(nextProps, nextState);
   }
 
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.isIntersecting === false && this.props.isIntersecting !== false) {
-      requestIdleCallback(() => this.setState({ isHidden: true }));
-    } else {
-      this.setState({ isHidden: !nextProps.isIntersecting });
+  componentDidMount () {
+    if (!this.props.intersectionObserverWrapper) {
+      // TODO: enable IntersectionObserver optimization for notification statuses.
+      // These are managed in notifications/index.js rather than status_list.js
+      return;
     }
+    this.props.intersectionObserverWrapper.observe(
+      this.props.id,
+      this.node,
+      this.handleIntersection
+    );
   }
 
-  handleRef = (node) => {
-    if (this.props.onRef) {
-      this.props.onRef(node);
-
-      if (node && node.children.length !== 0) {
-        this.height = node.clientHeight;
+  handleIntersection = (entry) => {
+    // Edge 15 doesn't support isIntersecting, but we can infer it from intersectionRatio
+    // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12156111/
+    const isIntersecting = entry.intersectionRatio > 0;
+    this.setState((prevState) => {
+      if (prevState.isIntersecting && !isIntersecting) {
+        requestIdleCallback(this.hideIfNotIntersecting);
       }
+      return {
+        isIntersecting: isIntersecting,
+        isHidden: false,
+      };
+    });
+  }
+
+  hideIfNotIntersecting = () => {
+    // When the browser gets a chance, test if we're still not intersecting,
+    // and if so, set our isHidden to true to trigger an unrender. The point of
+    // this is to save DOM nodes and avoid using up too much memory.
+    // See: https://github.com/tootsuite/mastodon/issues/2900
+    this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
+  }
+
+
+  handleRef = (node) => {
+    this.node = node;
+    if (node && node.children.length !== 0) {
+      this.height = node.clientHeight;
     }
   }
 
@@ -107,14 +133,14 @@ class Status extends ImmutablePureComponent {
   render () {
     let media = null;
     let statusAvatar;
-    const { status, account, isIntersecting, onRef, ...other } = this.props;
-    const { isHidden } = this.state;
+    const { status, account, ...other } = this.props;
+    const { isIntersecting, isHidden } = this.state;
 
     if (status === null) {
       return null;
     }
 
-    if (isIntersecting === false && isHidden) {
+    if (!isIntersecting && isHidden) {
       return (
         <div ref={this.handleRef} data-id={status.get('id')} style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}>
           {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 39f663dfb..9ee3af4d1 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
 import StatusContainer from '../containers/status_container';
 import LoadMore from './load_more';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
 
 class StatusList extends ImmutablePureComponent {
 
@@ -26,12 +27,7 @@ class StatusList extends ImmutablePureComponent {
     trackScroll: true,
   };
 
-  state = {
-    isIntersecting: {},
-    intersectionCount: 0,
-  }
-
-  statusRefQueue = []
+  intersectionObserverWrapper = new IntersectionObserverWrapper();
 
   handleScroll = (e) => {
     const { scrollTop, scrollHeight, clientHeight } = e.target;
@@ -64,53 +60,14 @@ class StatusList extends ImmutablePureComponent {
   }
 
   attachIntersectionObserver () {
-    const onIntersection = (entries) => {
-      this.setState(state => {
-
-        entries.forEach(entry => {
-          const statusId = entry.target.getAttribute('data-id');
-
-          // Edge 15 doesn't support isIntersecting, but we can infer it from intersectionRatio
-          // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12156111/
-          state.isIntersecting[statusId] = entry.intersectionRatio > 0;
-        });
-
-        // isIntersecting is a map of DOM data-id's to booleans (true for
-        // intersecting, false for non-intersecting).
-        //
-        // We always want to return true in shouldComponentUpdate() if
-        // this object changes, because onIntersection() is only called if
-        // something has changed.
-        //
-        // Now, we *could* use an immutable map or some other structure to
-        // diff the full map, but that would be pointless because the browser
-        // has already informed us that something has changed. So we can just
-        // use a regular object, which will be diffed by ImmutablePureComponent
-        // based on reference equality (i.e. it's always "unchanged") and
-        // then we just increment intersectionCount to force a change.
-
-        return {
-          isIntersecting: state.isIntersecting,
-          intersectionCount: state.intersectionCount + 1,
-        };
-      });
-    };
-
-    const options = {
+    this.intersectionObserverWrapper.connect({
       root: this.node,
       rootMargin: '300% 0px',
-    };
-
-    this.intersectionObserver = new IntersectionObserver(onIntersection, options);
-
-    if (this.statusRefQueue.length) {
-      this.statusRefQueue.forEach(node => this.intersectionObserver.observe(node));
-      this.statusRefQueue = [];
-    }
+    });
   }
 
   detachIntersectionObserver () {
-    this.intersectionObserver.disconnect();
+    this.intersectionObserverWrapper.disconnect();
   }
 
   attachScrollListener () {
@@ -125,15 +82,6 @@ class StatusList extends ImmutablePureComponent {
     this.node = c;
   }
 
-  handleStatusRef = (node) => {
-    if (node && this.intersectionObserver) {
-      const statusId = node.getAttribute('data-id');
-      this.intersectionObserver.observe(node);
-    } else {
-      this.statusRefQueue.push(node);
-    }
-  }
-
   handleLoadMore = (e) => {
     e.preventDefault();
     this.props.onScrollToBottom();
@@ -141,7 +89,6 @@ class StatusList extends ImmutablePureComponent {
 
   render () {
     const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
-    const { isIntersecting } = this.state;
 
     let loadMore       = null;
     let scrollableArea = null;
@@ -164,7 +111,7 @@ class StatusList extends ImmutablePureComponent {
             {prepend}
 
             {statusIds.map((statusId) => {
-              return <StatusContainer key={statusId} id={statusId} isIntersecting={isIntersecting[statusId]} onRef={this.handleStatusRef} />;
+              return <StatusContainer key={statusId} id={statusId} intersectionObserverWrapper={this.intersectionObserverWrapper} />;
             })}
 
             {loadMore}
diff --git a/app/javascript/mastodon/features/ui/util/intersection_observer_wrapper.js b/app/javascript/mastodon/features/ui/util/intersection_observer_wrapper.js
new file mode 100644
index 000000000..0e959f9ae
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/util/intersection_observer_wrapper.js
@@ -0,0 +1,48 @@
+// Wrapper for IntersectionObserver in order to make working with it
+// a bit easier. We also follow this performance advice:
+// "If you need to observe multiple elements, it is both possible and
+// advised to observe multiple elements using the same IntersectionObserver
+// instance by calling observe() multiple times."
+// https://developers.google.com/web/updates/2016/04/intersectionobserver
+
+class IntersectionObserverWrapper {
+
+  callbacks = {};
+  observerBacklog = [];
+  observer = null;
+
+  connect (options) {
+    const onIntersection = (entries) => {
+      entries.forEach(entry => {
+        const id = entry.target.getAttribute('data-id');
+        if (this.callbacks[id]) {
+          this.callbacks[id](entry);
+        }
+      });
+    };
+
+    this.observer = new IntersectionObserver(onIntersection, options);
+    this.observerBacklog.forEach(([ id, node, callback ]) => {
+      this.observe(id, node, callback);
+    });
+    this.observerBacklog = null;
+  }
+
+  observe (id, node, callback) {
+    if (!this.observer) {
+      this.observerBacklog.push([ id, node, callback ]);
+    } else {
+      this.callbacks[id] = callback;
+      this.observer.observe(node);
+    }
+  }
+
+  disconnect () {
+    if (this.observer) {
+      this.observer.disconnect();
+    }
+  }
+
+}
+
+export default IntersectionObserverWrapper;