diff options
Diffstat (limited to 'devtools/client/webconsole/components/Output/LazyMessageList.js')
-rw-r--r-- | devtools/client/webconsole/components/Output/LazyMessageList.js | 393 |
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; |