LazyMessageList.js (13547B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 * 5 * This file incorporates work covered by the following copyright and 6 * permission notice: 7 * 8 * MIT License 9 * 10 * Copyright (c) 2019 Oleg Grishechkin 11 * 12 * Permission is hereby granted, free of charge, to any person obtaining a copy 13 * of this software and associated documentation files (the "Software"), to deal 14 * in the Software without restriction, including without limitation the rights 15 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 * copies of the Software, and to permit persons to whom the Software is 17 * furnished to do so, subject to the following conditions: 18 * 19 * The above copyright notice and this permission notice shall be included in all 20 * copies or substantial portions of the Software. 21 * 22 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 28 * SOFTWARE. 29 */ 30 "use strict"; 31 32 const { 33 Fragment, 34 Component, 35 createElement, 36 createRef, 37 } = require("resource://devtools/client/shared/vendor/react.mjs"); 38 39 loader.lazyRequireGetter( 40 this, 41 "PropTypes", 42 "resource://devtools/client/shared/vendor/react-prop-types.js" 43 ); 44 45 // This element is a webconsole optimization for handling large numbers of 46 // console messages. The purpose is to only create DOM elements for messages 47 // which are actually visible within the scrollport. This code was based on 48 // Oleg Grishechkin's react-viewport-list element - however, it has been quite 49 // heavily modified, to the point that it is mostly unrecognizable. The most 50 // notable behavioral modification is that the list implements the behavior of 51 // pinning the scrollport to the bottom of the scroll container. 52 class LazyMessageList extends Component { 53 static get propTypes() { 54 return { 55 viewportRef: PropTypes.shape({ 56 // Note that we can't use Element here because, the Element global is 57 // exposed from base-loader and is not the same as window.Element. 58 // Also PropTypes.instanceOf relies solely on `instanceof` and not on 59 // isInstance, so we really need to use the actual constructor. 60 current: PropTypes.instanceOf(window.Element), 61 }).isRequired, 62 items: PropTypes.array.isRequired, 63 itemsToKeepAlive: PropTypes.shape({ 64 has: PropTypes.func, 65 keys: PropTypes.func, 66 size: PropTypes.number, 67 }).isRequired, 68 editorMode: PropTypes.bool.isRequired, 69 itemDefaultHeight: PropTypes.number.isRequired, 70 scrollOverdrawCount: PropTypes.number.isRequired, 71 renderItem: PropTypes.func.isRequired, 72 shouldScrollBottom: PropTypes.func.isRequired, 73 cacheGeneration: PropTypes.number.isRequired, 74 serviceContainer: PropTypes.shape({ 75 emitForTests: PropTypes.func.isRequired, 76 }), 77 }; 78 } 79 80 constructor(props) { 81 super(props); 82 this.#initialized = false; 83 this.#topBufferRef = createRef(); 84 this.#bottomBufferRef = createRef(); 85 this.#viewportHeight = window.innerHeight; 86 this.#startIndex = 0; 87 this.#resizeObserver = null; 88 this.#cachedHeights = []; 89 90 this.#scrollHandlerBinding = this.#scrollHandler.bind(this); 91 } 92 93 // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 94 UNSAFE_componentWillUpdate(nextProps) { 95 if (nextProps.cacheGeneration !== this.props.cacheGeneration) { 96 this.#cachedHeights = []; 97 this.#startIndex = 0; 98 } else if ( 99 (this.props.shouldScrollBottom() && 100 nextProps.items.length > this.props.items.length) || 101 this.#startIndex > nextProps.items.length - this.#numItemsToDraw 102 ) { 103 this.#startIndex = Math.max( 104 0, 105 nextProps.items.length - this.#numItemsToDraw 106 ); 107 } 108 } 109 110 componentDidUpdate(prevProps) { 111 const { viewportRef, serviceContainer } = this.props; 112 if (!viewportRef.current || !this.#topBufferRef.current) { 113 return; 114 } 115 116 if (!this.#initialized) { 117 // We set these up from a one-time call in componentDidUpdate, rather than in 118 // componentDidMount, because we need the parent to be mounted first, to add 119 // listeners to it, and React orders things such that children mount before 120 // parents. 121 this.#addListeners(); 122 } 123 124 if (!this.#initialized || prevProps.editorMode !== this.props.editorMode) { 125 this.#resizeObserver.observe(viewportRef.current); 126 } 127 128 this.#initialized = true; 129 130 // Since we updated, we're now going to compute the heights of all visible 131 // elements and store them in a cache. This allows us to get more accurate 132 // buffer regions to make scrolling correct when these elements no longer 133 // exist. 134 let index = this.#startIndex; 135 let element = this.#topBufferRef.current.nextSibling; 136 let elementRect = element?.getBoundingClientRect(); 137 while ( 138 Element.isInstance(element) && 139 index < this.#clampedEndIndex && 140 element !== this.#bottomBufferRef.current 141 ) { 142 const next = element.nextSibling; 143 const nextRect = next.getBoundingClientRect(); 144 this.#cachedHeights[index] = nextRect.top - elementRect.top; 145 element = next; 146 elementRect = nextRect; 147 index++; 148 } 149 150 serviceContainer.emitForTests("lazy-message-list-updated-or-noop"); 151 } 152 153 componentWillUnmount() { 154 this.#removeListeners(); 155 } 156 157 #initialized; 158 #topBufferRef; 159 #bottomBufferRef; 160 #viewportHeight; 161 #startIndex; 162 #resizeObserver; 163 #cachedHeights; 164 #scrollHandlerBinding; 165 166 get #overdrawHeight() { 167 return this.props.scrollOverdrawCount * this.props.itemDefaultHeight; 168 } 169 170 get #numItemsToDraw() { 171 const scrollingWindowCount = Math.ceil( 172 this.#viewportHeight / this.props.itemDefaultHeight 173 ); 174 return scrollingWindowCount + 2 * this.props.scrollOverdrawCount; 175 } 176 177 get #unclampedEndIndex() { 178 return this.#startIndex + this.#numItemsToDraw; 179 } 180 181 // Since the "end index" is computed based off a fixed offset from the start 182 // index, it can exceed the length of our items array. This is just a helper 183 // to ensure we don't exceed that. 184 get #clampedEndIndex() { 185 return Math.min(this.#unclampedEndIndex, this.props.items.length); 186 } 187 188 /** 189 * Increases our start index until we've passed enough elements to cover 190 * the difference in px between where we are and where we want to be. 191 * 192 * @param Number startIndex 193 * The current value of our start index. 194 * @param Number deltaPx 195 * The difference in pixels between where we want to be and 196 * where we are. 197 * @return {number} The new computed start index. 198 */ 199 #increaseStartIndex(startIndex, deltaPx) { 200 for (let i = startIndex + 1; i < this.props.items.length; i++) { 201 deltaPx -= this.#cachedHeights[i]; 202 startIndex = i; 203 204 if (deltaPx <= 0) { 205 break; 206 } 207 } 208 return startIndex; 209 } 210 211 /** 212 * Decreases our start index until we've passed enough elements to cover 213 * the difference in px between where we are and where we want to be. 214 * 215 * @param Number startIndex 216 * The current value of our start index. 217 * @param Number deltaPx 218 * The difference in pixels between where we want to be and 219 * where we are. 220 * @return {number} The new computed start index. 221 */ 222 #decreaseStartIndex(startIndex, diff) { 223 for (let i = startIndex - 1; i >= 0; i--) { 224 diff -= this.#cachedHeights[i]; 225 startIndex = i; 226 227 if (diff <= 0) { 228 break; 229 } 230 } 231 return startIndex; 232 } 233 234 #scrollHandler() { 235 if (!this.props.viewportRef.current || !this.#topBufferRef.current) { 236 return; 237 } 238 239 const scrollportMin = 240 this.props.viewportRef.current.getBoundingClientRect().top - 241 this.#overdrawHeight; 242 const uppermostItemRect = 243 this.#topBufferRef.current.nextSibling.getBoundingClientRect(); 244 const uppermostItemMin = uppermostItemRect.top; 245 const uppermostItemMax = uppermostItemRect.bottom; 246 247 let nextStartIndex = this.#startIndex; 248 const downwardPx = scrollportMin - uppermostItemMax; 249 const upwardPx = uppermostItemMin - scrollportMin; 250 if (downwardPx > 0) { 251 nextStartIndex = this.#increaseStartIndex(nextStartIndex, downwardPx); 252 } else if (upwardPx > 0) { 253 nextStartIndex = this.#decreaseStartIndex(nextStartIndex, upwardPx); 254 } 255 256 nextStartIndex = Math.max( 257 0, 258 Math.min(nextStartIndex, this.props.items.length - this.#numItemsToDraw) 259 ); 260 261 if (nextStartIndex !== this.#startIndex) { 262 this.#startIndex = nextStartIndex; 263 this.forceUpdate(); 264 } else { 265 const { serviceContainer } = this.props; 266 serviceContainer.emitForTests("lazy-message-list-updated-or-noop"); 267 } 268 } 269 270 #addListeners() { 271 const { viewportRef } = this.props; 272 viewportRef.current.addEventListener("scroll", this.#scrollHandlerBinding); 273 this.#resizeObserver = new ResizeObserver(() => { 274 this.#viewportHeight = 275 viewportRef.current.parentNode.parentNode.clientHeight; 276 this.forceUpdate(); 277 }); 278 } 279 280 #removeListeners() { 281 const { viewportRef } = this.props; 282 this.#resizeObserver?.disconnect(); 283 viewportRef.current?.removeEventListener( 284 "scroll", 285 this.#scrollHandlerBinding 286 ); 287 } 288 289 get bottomBuffer() { 290 return this.#bottomBufferRef.current; 291 } 292 293 isItemNearBottom(index) { 294 return index >= this.props.items.length - this.#numItemsToDraw; 295 } 296 297 render() { 298 const { items, itemDefaultHeight, renderItem, itemsToKeepAlive } = 299 this.props; 300 if (!items.length) { 301 return createElement(Fragment, { 302 key: "LazyMessageList", 303 }); 304 } 305 306 // Resize our cached heights to fit if necessary. 307 const countUncached = items.length - this.#cachedHeights.length; 308 if (countUncached > 0) { 309 // It would be lovely if javascript allowed us to resize an array in one 310 // go. I think this is the closest we can get to that. This in theory 311 // allows us to realloc, and doesn't require copying the whole original 312 // array like concat does. 313 this.#cachedHeights.push(...Array(countUncached).fill(itemDefaultHeight)); 314 } 315 316 let topBufferHeight = 0; 317 let bottomBufferHeight = 0; 318 // We can't compute the bottom buffer height until the end, so we just 319 // store the index of where it needs to go. 320 let bottomBufferIndex = 0; 321 let currentChild = 0; 322 const startIndex = this.#startIndex; 323 const endIndex = this.#clampedEndIndex; 324 // We preallocate this array to avoid allocations in the loop. The minimum, 325 // and typical length for it is the size of the body plus 2 for the top and 326 // bottom buffers. It can be bigger due to itemsToKeepAlive, but we can't just 327 // add the size, since itemsToKeepAlive could in theory hold items which are 328 // not even in the list. 329 const children = new Array(endIndex - startIndex + 2); 330 const pushChild = c => { 331 if (currentChild >= children.length) { 332 children.push(c); 333 } else { 334 children[currentChild] = c; 335 } 336 return currentChild++; 337 }; 338 for (let i = 0; i < items.length; i++) { 339 const itemId = items[i]; 340 if (i < startIndex) { 341 if (i == 0 || itemsToKeepAlive.has(itemId)) { 342 // If this is our first item, and we wouldn't otherwise be rendering 343 // it, we want to ensure that it's at the beginning of our children 344 // array to ensure keyboard navigation functions properly. 345 pushChild(renderItem(itemId, i)); 346 } else { 347 topBufferHeight += this.#cachedHeights[i]; 348 } 349 } else if (i < endIndex) { 350 if (i == startIndex) { 351 pushChild( 352 createElement("div", { 353 key: "LazyMessageListTop", 354 className: "lazy-message-list-top", 355 ref: this.#topBufferRef, 356 style: { height: topBufferHeight }, 357 }) 358 ); 359 } 360 pushChild(renderItem(itemId, i)); 361 if (i == endIndex - 1) { 362 // We're just reserving the bottom buffer's spot in the children 363 // array here. We will create the actual element and assign it at 364 // this index after the loop. 365 bottomBufferIndex = pushChild(null); 366 } 367 } else if (i == items.length - 1 || itemsToKeepAlive.has(itemId)) { 368 // Similarly to the logic for our first item, we also want to ensure 369 // that our last item is always rendered as the last item in our 370 // children array. 371 pushChild(renderItem(itemId, i)); 372 } else { 373 bottomBufferHeight += this.#cachedHeights[i]; 374 } 375 } 376 377 children[bottomBufferIndex] = createElement("div", { 378 key: "LazyMessageListBottom", 379 className: "lazy-message-list-bottom", 380 ref: this.#bottomBufferRef, 381 style: { height: bottomBufferHeight }, 382 }); 383 384 return createElement( 385 Fragment, 386 { 387 key: "LazyMessageList", 388 }, 389 children 390 ); 391 } 392 } 393 394 module.exports = LazyMessageList;