summaryrefslogtreecommitdiffstats
path: root/devtools/client/webconsole/components/Output/LazyMessageList.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/webconsole/components/Output/LazyMessageList.js')
-rw-r--r--devtools/client/webconsole/components/Output/LazyMessageList.js393
1 files changed, 393 insertions, 0 deletions
diff --git a/devtools/client/webconsole/components/Output/LazyMessageList.js b/devtools/client/webconsole/components/Output/LazyMessageList.js
new file mode 100644
index 0000000000..931b5bb8bd
--- /dev/null
+++ b/devtools/client/webconsole/components/Output/LazyMessageList.js
@@ -0,0 +1,393 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * MIT License
+ *
+ * Copyright (c) 2019 Oleg Grishechkin
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+"use strict";
+
+const {
+ Fragment,
+ Component,
+ createElement,
+ createRef,
+} = require("resource://devtools/client/shared/vendor/react.js");
+
+loader.lazyRequireGetter(
+ this,
+ "PropTypes",
+ "resource://devtools/client/shared/vendor/react-prop-types.js"
+);
+
+// This element is a webconsole optimization for handling large numbers of
+// console messages. The purpose is to only create DOM elements for messages
+// which are actually visible within the scrollport. This code was based on
+// Oleg Grishechkin's react-viewport-list element - however, it has been quite
+// heavily modified, to the point that it is mostly unrecognizable. The most
+// notable behavioral modification is that the list implements the behavior of
+// pinning the scrollport to the bottom of the scroll container.
+class LazyMessageList extends Component {
+ static get propTypes() {
+ return {
+ viewportRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) })
+ .isRequired,
+ items: PropTypes.array.isRequired,
+ itemsToKeepAlive: PropTypes.shape({
+ has: PropTypes.func,
+ keys: PropTypes.func,
+ size: PropTypes.number,
+ }).isRequired,
+ editorMode: PropTypes.bool.isRequired,
+ itemDefaultHeight: PropTypes.number.isRequired,
+ scrollOverdrawCount: PropTypes.number.isRequired,
+ renderItem: PropTypes.func.isRequired,
+ shouldScrollBottom: PropTypes.func.isRequired,
+ cacheGeneration: PropTypes.number.isRequired,
+ serviceContainer: PropTypes.shape({
+ emitForTests: PropTypes.func.isRequired,
+ }),
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.#initialized = false;
+ this.#topBufferRef = createRef();
+ this.#bottomBufferRef = createRef();
+ this.#viewportHeight = window.innerHeight;
+ this.#startIndex = 0;
+ this.#resizeObserver = null;
+ this.#cachedHeights = [];
+
+ this.#scrollHandlerBinding = this.#scrollHandler.bind(this);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillUpdate(nextProps, nextState) {
+ if (nextProps.cacheGeneration !== this.props.cacheGeneration) {
+ this.#cachedHeights = [];
+ this.#startIndex = 0;
+ } else if (
+ (this.props.shouldScrollBottom() &&
+ nextProps.items.length > this.props.items.length) ||
+ this.#startIndex > nextProps.items.length - this.#numItemsToDraw
+ ) {
+ this.#startIndex = Math.max(
+ 0,
+ nextProps.items.length - this.#numItemsToDraw
+ );
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const { viewportRef, serviceContainer } = this.props;
+ if (!viewportRef.current || !this.#topBufferRef.current) {
+ return;
+ }
+
+ if (!this.#initialized) {
+ // We set these up from a one-time call in componentDidUpdate, rather than in
+ // componentDidMount, because we need the parent to be mounted first, to add
+ // listeners to it, and React orders things such that children mount before
+ // parents.
+ this.#addListeners();
+ }
+
+ if (!this.#initialized || prevProps.editorMode !== this.props.editorMode) {
+ this.#resizeObserver.observe(viewportRef.current);
+ }
+
+ this.#initialized = true;
+
+ // Since we updated, we're now going to compute the heights of all visible
+ // elements and store them in a cache. This allows us to get more accurate
+ // buffer regions to make scrolling correct when these elements no longer
+ // exist.
+ let index = this.#startIndex;
+ let element = this.#topBufferRef.current.nextSibling;
+ let elementRect = element?.getBoundingClientRect();
+ while (
+ Element.isInstance(element) &&
+ index < this.#clampedEndIndex &&
+ element !== this.#bottomBufferRef.current
+ ) {
+ const next = element.nextSibling;
+ const nextRect = next.getBoundingClientRect();
+ this.#cachedHeights[index] = nextRect.top - elementRect.top;
+ element = next;
+ elementRect = nextRect;
+ index++;
+ }
+
+ serviceContainer.emitForTests("lazy-message-list-updated-or-noop");
+ }
+
+ componentWillUnmount() {
+ this.#removeListeners();
+ }
+
+ #initialized;
+ #topBufferRef;
+ #bottomBufferRef;
+ #viewportHeight;
+ #startIndex;
+ #resizeObserver;
+ #cachedHeights;
+ #scrollHandlerBinding;
+
+ get #maxIndex() {
+ return this.props.items.length - 1;
+ }
+
+ get #overdrawHeight() {
+ return this.props.scrollOverdrawCount * this.props.itemDefaultHeight;
+ }
+
+ get #numItemsToDraw() {
+ const scrollingWindowCount = Math.ceil(
+ this.#viewportHeight / this.props.itemDefaultHeight
+ );
+ return scrollingWindowCount + 2 * this.props.scrollOverdrawCount;
+ }
+
+ get #unclampedEndIndex() {
+ return this.#startIndex + this.#numItemsToDraw;
+ }
+
+ // Since the "end index" is computed based off a fixed offset from the start
+ // index, it can exceed the length of our items array. This is just a helper
+ // to ensure we don't exceed that.
+ get #clampedEndIndex() {
+ return Math.min(this.#unclampedEndIndex, this.props.items.length);
+ }
+
+ /**
+ * Increases our start index until we've passed enough elements to cover
+ * the difference in px between where we are and where we want to be.
+ *
+ * @param Number startIndex
+ * The current value of our start index.
+ * @param Number deltaPx
+ * The difference in pixels between where we want to be and
+ * where we are.
+ * @return {Number} The new computed start index.
+ */
+ #increaseStartIndex(startIndex, deltaPx) {
+ for (let i = startIndex + 1; i < this.props.items.length; i++) {
+ deltaPx -= this.#cachedHeights[i];
+ startIndex = i;
+
+ if (deltaPx <= 0) {
+ break;
+ }
+ }
+ return startIndex;
+ }
+
+ /**
+ * Decreases our start index until we've passed enough elements to cover
+ * the difference in px between where we are and where we want to be.
+ *
+ * @param Number startIndex
+ * The current value of our start index.
+ * @param Number deltaPx
+ * The difference in pixels between where we want to be and
+ * where we are.
+ * @return {Number} The new computed start index.
+ */
+ #decreaseStartIndex(startIndex, diff) {
+ for (let i = startIndex - 1; i >= 0; i--) {
+ diff -= this.#cachedHeights[i];
+ startIndex = i;
+
+ if (diff <= 0) {
+ break;
+ }
+ }
+ return startIndex;
+ }
+
+ #scrollHandler() {
+ if (!this.props.viewportRef.current || !this.#topBufferRef.current) {
+ return;
+ }
+
+ const scrollportMin =
+ this.props.viewportRef.current.getBoundingClientRect().top -
+ this.#overdrawHeight;
+ const uppermostItemRect =
+ this.#topBufferRef.current.nextSibling.getBoundingClientRect();
+ const uppermostItemMin = uppermostItemRect.top;
+ const uppermostItemMax = uppermostItemRect.bottom;
+
+ let nextStartIndex = this.#startIndex;
+ const downwardPx = scrollportMin - uppermostItemMax;
+ const upwardPx = uppermostItemMin - scrollportMin;
+ if (downwardPx > 0) {
+ nextStartIndex = this.#increaseStartIndex(nextStartIndex, downwardPx);
+ } else if (upwardPx > 0) {
+ nextStartIndex = this.#decreaseStartIndex(nextStartIndex, upwardPx);
+ }
+
+ nextStartIndex = Math.max(
+ 0,
+ Math.min(nextStartIndex, this.props.items.length - this.#numItemsToDraw)
+ );
+
+ if (nextStartIndex !== this.#startIndex) {
+ this.#startIndex = nextStartIndex;
+ this.forceUpdate();
+ } else {
+ const { serviceContainer } = this.props;
+ serviceContainer.emitForTests("lazy-message-list-updated-or-noop");
+ }
+ }
+
+ #addListeners() {
+ const { viewportRef } = this.props;
+ viewportRef.current.addEventListener("scroll", this.#scrollHandlerBinding);
+ this.#resizeObserver = new ResizeObserver(entries => {
+ this.#viewportHeight =
+ viewportRef.current.parentNode.parentNode.clientHeight;
+ this.forceUpdate();
+ });
+ }
+
+ #removeListeners() {
+ const { viewportRef } = this.props;
+ this.#resizeObserver?.disconnect();
+ viewportRef.current?.removeEventListener(
+ "scroll",
+ this.#scrollHandlerBinding
+ );
+ }
+
+ get bottomBuffer() {
+ return this.#bottomBufferRef.current;
+ }
+
+ isItemNearBottom(index) {
+ return index >= this.props.items.length - this.#numItemsToDraw;
+ }
+
+ render() {
+ const { items, itemDefaultHeight, renderItem, itemsToKeepAlive } =
+ this.props;
+ if (!items.length) {
+ return createElement(Fragment, {
+ key: "LazyMessageList",
+ });
+ }
+
+ // Resize our cached heights to fit if necessary.
+ const countUncached = items.length - this.#cachedHeights.length;
+ if (countUncached > 0) {
+ // It would be lovely if javascript allowed us to resize an array in one
+ // go. I think this is the closest we can get to that. This in theory
+ // allows us to realloc, and doesn't require copying the whole original
+ // array like concat does.
+ this.#cachedHeights.push(...Array(countUncached).fill(itemDefaultHeight));
+ }
+
+ let topBufferHeight = 0;
+ let bottomBufferHeight = 0;
+ // We can't compute the bottom buffer height until the end, so we just
+ // store the index of where it needs to go.
+ let bottomBufferIndex = 0;
+ let currentChild = 0;
+ const startIndex = this.#startIndex;
+ const endIndex = this.#clampedEndIndex;
+ // We preallocate this array to avoid allocations in the loop. The minimum,
+ // and typical length for it is the size of the body plus 2 for the top and
+ // bottom buffers. It can be bigger due to itemsToKeepAlive, but we can't just
+ // add the size, since itemsToKeepAlive could in theory hold items which are
+ // not even in the list.
+ const children = new Array(endIndex - startIndex + 2);
+ const pushChild = c => {
+ if (currentChild >= children.length) {
+ children.push(c);
+ } else {
+ children[currentChild] = c;
+ }
+ return currentChild++;
+ };
+ for (let i = 0; i < items.length; i++) {
+ const itemId = items[i];
+ if (i < startIndex) {
+ if (i == 0 || itemsToKeepAlive.has(itemId)) {
+ // If this is our first item, and we wouldn't otherwise be rendering
+ // it, we want to ensure that it's at the beginning of our children
+ // array to ensure keyboard navigation functions properly.
+ pushChild(renderItem(itemId, i));
+ } else {
+ topBufferHeight += this.#cachedHeights[i];
+ }
+ } else if (i < endIndex) {
+ if (i == startIndex) {
+ pushChild(
+ createElement("div", {
+ key: "LazyMessageListTop",
+ className: "lazy-message-list-top",
+ ref: this.#topBufferRef,
+ style: { height: topBufferHeight },
+ })
+ );
+ }
+ pushChild(renderItem(itemId, i));
+ if (i == endIndex - 1) {
+ // We're just reserving the bottom buffer's spot in the children
+ // array here. We will create the actual element and assign it at
+ // this index after the loop.
+ bottomBufferIndex = pushChild(null);
+ }
+ } else if (i == items.length - 1 || itemsToKeepAlive.has(itemId)) {
+ // Similarly to the logic for our first item, we also want to ensure
+ // that our last item is always rendered as the last item in our
+ // children array.
+ pushChild(renderItem(itemId, i));
+ } else {
+ bottomBufferHeight += this.#cachedHeights[i];
+ }
+ }
+
+ children[bottomBufferIndex] = createElement("div", {
+ key: "LazyMessageListBottom",
+ className: "lazy-message-list-bottom",
+ ref: this.#bottomBufferRef,
+ style: { height: bottomBufferHeight },
+ });
+
+ return createElement(
+ Fragment,
+ {
+ key: "LazyMessageList",
+ },
+ children
+ );
+ }
+}
+
+module.exports = LazyMessageList;