diff --git a/src/components/virtualList/variable.js b/src/components/virtualList/variable.js index e2cf193c6d..cb46f26b53 100644 --- a/src/components/virtualList/variable.js +++ b/src/components/virtualList/variable.js @@ -34,13 +34,16 @@ export default class VariableVirtualList { this.offsets = [0]; this.offsetsDirty = false; this.renderedRange = { start: 0, end: 0 }; + this.renderedItemCount = 0; this.frame = null; this.stickToBottom = true; this.lastScrollTop = this.container.scrollTop; this.lastScrollTime = this.now(); + this.scrollDirection = 0; this.touching = false; this.releaseTimer = null; this.destroyed = false; + this.observedItems = new Set(); this.topSpacer = document.createElement("div"); this.topSpacer.className = "variable-virtual-spacer"; @@ -59,7 +62,11 @@ export default class VariableVirtualList { this.stickToBottom = this.isNearBottom(); const now = this.now(); const elapsed = Math.max(8, now - this.lastScrollTime); - const distance = Math.abs(this.container.scrollTop - this.lastScrollTop); + const delta = this.container.scrollTop - this.lastScrollTop; + const direction = Math.sign(delta) || this.scrollDirection; + const directionChanged = direction !== this.scrollDirection; + this.scrollDirection = direction; + const distance = Math.abs(delta); const projectedDistance = distance * (32 / elapsed); this.dynamicOverscan = Math.min( this.maxOverscan, @@ -69,13 +76,17 @@ export default class VariableVirtualList { this.lastScrollTime = now; // Scroll rendering is intentionally synchronous. Android WebView can move // the compositor several rows before the next animation frame. - this.render(); + // Refill immediately on reversal, before relying on the smaller rear buffer. + this.render(!directionChanged); this.scheduleOverscanRelease(); }; this.onTouchStart = () => { this.touching = true; this.clearOverscanRelease(); this.dynamicOverscan = this.activeOverscan; + this.scrollDirection = 0; + this.lastScrollTop = this.container.scrollTop; + this.lastScrollTime = this.now(); // Pre-paint the fling guard before compositor scrolling begins. this.render(); }; @@ -99,6 +110,7 @@ export default class VariableVirtualList { ? new ResizeObserver((entries) => this.onResize(entries)) : null; this.resizeObserver?.observe(this.container); + if (this.footer) this.resizeObserver?.observe(this.footer); } now() { @@ -115,6 +127,7 @@ export default class VariableVirtualList { this.releaseTimer = null; if (this.destroyed || this.touching) return; this.dynamicOverscan = this.overscan; + this.scrollDirection = 0; this.render(); }, 500, @@ -144,7 +157,11 @@ export default class VariableVirtualList { }; this.items.push(item); this.itemByElement.set(element, item); - this.offsetsDirty = true; + // Existing offsets remain valid on append. Height changes still take the + // rebuild path, including any messages appended before that rebuild. + if (!this.offsetsDirty) { + this.offsets.push(this.offsets[this.offsets.length - 1] + item.height); + } if (wasNearBottom || this.items.length === 1) this.stickToBottom = true; this.scheduleRender(); } @@ -155,12 +172,16 @@ export default class VariableVirtualList { this.offsets = [0]; this.offsetsDirty = false; this.renderedRange = { start: 0, end: 0 }; + this.renderedItemCount = 0; this.stickToBottom = true; this.dynamicOverscan = this.overscan; + this.scrollDirection = 0; this.topSpacer.style.height = "0px"; this.bottomSpacer.style.height = "0px"; this.itemContainer.replaceChildren(); this.container.scrollTop = 0; + this.lastScrollTop = 0; + this.lastScrollTime = this.now(); this.footerHeight = this.getFooterHeight(); this.observeResizeTargets(); } @@ -216,17 +237,56 @@ export default class VariableVirtualList { observeResizeTargets() { if (!this.resizeObserver) return; - this.resizeObserver.disconnect(); - this.resizeObserver.observe(this.container); - for (const element of this.itemContainer.children) { - this.resizeObserver.observe(element); + const mountedItems = new Set(this.itemContainer.children); + for (const element of this.observedItems) { + if (!mountedItems.has(element)) this.resizeObserver.unobserve(element); } - if (this.footer) this.resizeObserver.observe(this.footer); + for (const element of mountedItems) { + if (!this.observedItems.has(element)) + this.resizeObserver.observe(element); + } + this.observedItems = mountedItems; + } + + getOverscan() { + const ahead = this.dynamicOverscan; + // Keep a rear guard for abrupt reversals, while spending most of the + // active buffer on the direction the reader is moving toward. + const behind = Math.max(this.overscan, ahead / 4); + return { + before: this.scrollDirection > 0 ? behind : ahead, + after: this.scrollDirection < 0 ? behind : ahead, + }; } - render() { + render(scrollOnly = false) { if (this.destroyed) return; + const overscan = this.getOverscan(); + // Keep the mounted window stable while there is still a safety buffer on + // both sides. Touch start and scheduled data/measurement updates always + // render, so this shortcut cannot hide appended or resized content. + if ( + scrollOnly && + !this.offsetsDirty && + !this.stickToBottom && + this.renderedItemCount === this.items.length + ) { + const { start, end } = this.renderedRange; + const scrollTop = this.container.scrollTop; + if ( + end > start && + this.container.clientHeight === this.viewportHeight && + (start === 0 || + scrollTop >= this.offsets[start] + overscan.before / 2) && + (end === this.items.length || + scrollTop + this.viewportHeight <= + this.offsets[end] - overscan.after / 2) + ) { + return; + } + } this.rebuildOffsets(); + this.renderedItemCount = this.items.length; if (!this.items.length) { this.footerHeight = this.getFooterHeight(); this.itemContainer.replaceChildren(); @@ -248,13 +308,11 @@ export default class VariableVirtualList { ? Math.max(0, totalHeight + this.footerHeight - viewportHeight) : this.container.scrollTop; const start = this.findIndexAt( - Math.max(0, targetScrollTop - this.dynamicOverscan), + Math.max(0, targetScrollTop - overscan.before), ); const end = Math.min( this.items.length, - this.findIndexAt( - targetScrollTop + viewportHeight + this.dynamicOverscan, - ) + 1, + this.findIndexAt(targetScrollTop + viewportHeight + overscan.after) + 1, ); this.updateMountedRange(start, end); @@ -399,6 +457,7 @@ export default class VariableVirtualList { this.frame = null; this.clearOverscanRelease(); this.resizeObserver?.disconnect(); + this.observedItems.clear(); this.container.removeEventListener("scroll", this.onScroll); this.container.removeEventListener("touchstart", this.onTouchStart); this.container.removeEventListener("touchend", this.onTouchEnd); diff --git a/tests/unit/variableVirtualList.test.js b/tests/unit/variableVirtualList.test.js index ed1e0c70d7..ff2a873103 100644 --- a/tests/unit/variableVirtualList.test.js +++ b/tests/unit/variableVirtualList.test.js @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import VariableVirtualList from "components/virtualList/variable"; function createList() { @@ -14,6 +14,176 @@ function createList() { } describe("VariableVirtualList", () => { + it.each([ + 0, 360, + ])("covers jumps, reversals, and list edges with %i pixel overscan", (overscan) => { + const container = document.createElement("div"); + Object.defineProperties(container, { + clientHeight: { value: 200 }, + scrollHeight: { value: 52000 }, + }); + document.body.append(container); + const list = new VariableVirtualList(container, { overscan }); + for (let i = 0; i < 1000; i++) list.append(document.createElement("div")); + for (const offset of [0, 10, 10000, 12000, 11999, 500, 51800, 40000, 0]) { + container.scrollTop = offset; + list.onScroll(); + expect(list.offsets[list.renderedRange.start]).toBeLessThanOrEqual( + container.scrollTop, + ); + expect(list.offsets[list.renderedRange.end]).toBeGreaterThanOrEqual( + container.scrollTop + 200, + ); + } + list.clear(); + expect(list.scrollDirection).toBe(0); + list.destroy(); + container.remove(); + }); + + it("extends clean offsets without reading previous message heights", () => { + const { container, list } = createList(); + for (let i = 0; i < 100; i++) list.append(document.createElement("div")); + const offsets = list.offsets; + const heightRead = vi.fn(() => 52); + for (const item of list.items) + Object.defineProperty(item, "height", { get: heightRead }); + for (let i = 0; i < 100; i++) list.append(document.createElement("div")); + list.rebuildOffsets(); + expect(heightRead).not.toHaveBeenCalled(); + expect(list.offsets).toBe(offsets); + expect(list.offsets).toHaveLength(201); + expect(list.offsets[200]).toBe(10400); + list.destroy(); + container.remove(); + }); + + it("reconciles appended offsets after height changes and after clearing", () => { + const { container, list } = createList(); + list.append(document.createElement("div")); + list.append(document.createElement("div")); + list.updateHeight(list.items[0], 100); + list.append(document.createElement("div")); + list.append(document.createElement("div")); + list.rebuildOffsets(); + expect(list.offsets).toEqual([0, 100, 152, 204, 256]); + list.append(document.createElement("div")); + expect(list.offsets).toEqual([0, 100, 152, 204, 256, 308]); + list.clear(); + list.append(document.createElement("div")); + expect(list.offsets).toEqual([0, 52]); + list.destroy(); + container.remove(); + }); + + it("moves overscan ahead of scrolling and refills immediately on reversal", () => { + const { container, list } = createList(); + Object.defineProperty(container, "scrollHeight", { value: 52000 }); + for (let i = 0; i < 1000; i++) list.append(document.createElement("div")); + list.stickToBottom = false; + container.scrollTop = 20000; + list.onTouchStart(); + const symmetricCount = list.mountedCount; + vi.spyOn(list, "now").mockReturnValue(list.lastScrollTime + 32); + const margins = () => ({ + before: container.scrollTop - list.offsets[list.renderedRange.start], + after: + list.offsets[list.renderedRange.end] - + container.scrollTop - + container.clientHeight, + }); + container.scrollTop += 10; + list.onScroll(); + expect(margins().after).toBeGreaterThan(margins().before * 2); + expect(list.mountedCount).toBeLessThan(symmetricCount); + const mounted = vi.spyOn(list, "updateMountedRange"); + container.scrollTop -= 10; + list.onScroll(); + expect(mounted).toHaveBeenCalledOnce(); + expect(margins().before).toBeGreaterThan(margins().after * 2); + expect(margins().after).toBeGreaterThanOrEqual(list.overscan); + list.onTouchStart(); + expect(Math.abs(margins().before - margins().after)).toBeLessThan(52); + list.destroy(); + container.remove(); + }); + + it("retains resize observations for overlapping rows and releases removed rows", () => { + const { container, list } = createList(); + const observe = vi.spyOn(list.resizeObserver, "observe"); + const unobserve = vi.spyOn(list.resizeObserver, "unobserve"); + const disconnect = vi.spyOn(list.resizeObserver, "disconnect"); + for (let index = 0; index < 200; index++) { + list.append(document.createElement("div")); + } + list.stickToBottom = false; + container.scrollTop = 1000; + list.render(); + const previous = new Set(list.itemContainer.children); + observe.mockClear(); + + list.render(); + expect(observe).not.toHaveBeenCalled(); + expect(unobserve).not.toHaveBeenCalled(); + expect(disconnect).not.toHaveBeenCalled(); + + container.scrollTop += 104; + list.render(); + const current = new Set(list.itemContainer.children); + expect(observe.mock.calls.map(([element]) => element)).toEqual( + [...current].filter((element) => !previous.has(element)), + ); + expect(unobserve.mock.calls.map(([element]) => element)).toEqual( + [...previous].filter((element) => !current.has(element)), + ); + unobserve.mockClear(); + list.clear(); + expect(unobserve).toHaveBeenCalledTimes(current.size); + list.destroy(); + container.remove(); + }); + + it("leaves buffered rows untouched until the viewport approaches their edge", () => { + const { container, list } = createList(); + Object.defineProperty(container, "scrollHeight", { value: 52000 }); + for (let index = 0; index < 1000; index++) { + list.append(document.createElement("div")); + } + list.stickToBottom = false; + container.scrollTop = 10000; + list.onScroll(); + list.onTouchStart(); + container.scrollTop += 20; + list.onScroll(); + const mounted = vi.spyOn(list, "updateMountedRange"); + const initialRange = { ...list.renderedRange }; + container.scrollTop += 20; + list.onScroll(); + expect(mounted).not.toHaveBeenCalled(); + expect(list.renderedRange).toEqual(initialRange); + + container.scrollTop += 2000; + list.onScroll(); + expect(mounted).toHaveBeenCalledOnce(); + expect(list.offsets[list.renderedRange.start]).toBeLessThan( + container.scrollTop, + ); + expect(list.offsets[list.renderedRange.end]).toBeGreaterThan( + container.scrollTop + container.clientHeight, + ); + + mounted.mockClear(); + list.updateHeight(list.items[list.renderedRange.start], 120); + list.onScroll(); + expect(mounted).toHaveBeenCalledOnce(); + mounted.mockClear(); + list.append(document.createElement("div")); + list.onScroll(); + expect(mounted).toHaveBeenCalledOnce(); + list.destroy(); + container.remove(); + }); + it("retains all items while mounting only the viewport window", () => { const { container, list } = createList(); const elements = Array.from({ length: 1000 }, (_, index) => {