1/** 2 * @file BatchScroller.js 3 * @description Batched infinite scroll for the ticket/add-on card grids 4 * (dashboard/events/event/tickets/ticket/index.php). Rendering every ticket/add-on eagerly 5 * used to instantiate thousands of web components at once and reliably crashed the page on 6 * mobile (out-of-memory). This keeps the UX as continuous infinite scroll while guaranteeing 7 * only a bounded number of <application-card-layout> instances ever exist in the DOM at once. 8 * 9 * Cards are tracked in fixed-size batches (not individually) -- a handful of tracked 10 * boundaries instead of hundreds of individually-observed cards. At most 2 batches are ever 11 * mounted; every other batch is represented by a single placeholder <div> spanning the full 12 * grid width (grid-column:1/-1, so this holds regardless of column count -- single-column 13 * add-on grid or multi-column auto-fill ticket grid), sized to that batch's actual 14 * last-measured height (not estimated), so scroll position and page height stay stable. A 15 * single shared IntersectionObserver (rootMargin 800px, double the 400px already used by 16 * ApplicationCardLayout's cover-image observer, since building a batch is costlier than 17 * fetching one image) watches both the trailing "load more" sentinel and every currently- 18 * unmounted batch's placeholder; mounting a batch always re-enforces the 2-batch cap, 19 * demoting whichever mounted batch is now furthest away. 20 * 21 * pause()/resume() force-mount (then re-window) every batch, used around a drag-reorder 22 * session (see the card-drag-start/card-drag-end events dispatched by DraggableCardList.js) 23 * so the drag's sibling-position math always sees the complete, real list. resume() rebuilds 24 * batches from the DOM's current order rather than the pre-pause grouping, since the drag may 25 * have moved cards across old batch boundaries. 26 * 27 * Loaded as a plain classic script (includeJS), not an ES module -- its consumer, 28 * tickets-ticket-2018-06-27.js, is itself a classic script and can't use `import`. Depends on 29 * buildApplicationCard() from CardBuilder.js (must be included first). 30 */ 31 32class BatchScroller { 33 /** 34 * @param {Object} opts 35 * @param {HTMLElement} opts.containerEl - the <card-list> (manage mode) or plain 36 * <div class="not-card-list"> (view-only mode) holding the cards. 37 * @param {'ticket'|'addon'} opts.cardType 38 * @param {boolean} opts.canManage 39 * @param {number} opts.batchSize 40 * @param {Object[]} opts.data - ordered array of card data (ticketCardData/addonCardData, 41 * already filtered to whatever subset should currently be shown). 42 * @param {(el: HTMLElement) => void} [opts.onCardBuilt] - called once per newly-built card 43 * element, before it's inserted -- used by the page script to apply current 44 * selected/other transient state before the card is visible. 45 */ 46 constructor(opts) {
47 this.containerEl = opts.containerEl; 48 this.cardType = opts.cardType; 49 this.canManage = opts.canManage; 50 this.batchSize = opts.batchSize; 51 this.onCardBuilt = opts.onCardBuilt || function () {}; 52 this.cardListEl = this.containerEl.tagName === 'CARD-LIST' ? this.containerEl : null; 53 54 this.paused = false; 55 this.mountedIndices = new Set(); 56 this._observer = null; 57 58 this._buildSentinel(); 59 this.setData(opts.data, { adoptExisting: true }); 60 61 if (this.cardListEl) { 62 this.cardListEl.addEventListener('card-drag-start', () => this.pause()); 63 this.cardListEl.addEventListener('card-drag-end', () => this.resume()); 64 } 65 } 66 67 /** 68 * Replaces the working dataset (e.g. after a filter/sort change) and restarts batching 69 * from the top. Pass { adoptExisting: true } only on construction, when batch 0's cards 70 * may already be present in the DOM from server-side rendering. 71 */ 72 setData(data, { adoptExisting = false } = {}) { 73 this._teardownBatches(); 74 this.data = data; 75 this.batches = []; 76 for (let i = 0; i < data.length; i += this.batchSize) { 77 this.batches.push({ 78 index: this.batches.length, 79 items: data.slice(i, i + this.batchSize), 80 mounted: false, 81 elements: [], 82 placeholderEl: null, 83 }); 84 } 85 this.mountedIndices = new Set(); 86 87 if (adoptExisting) { 88 const existing = Array.from( 89 this.containerEl.querySelectorAll('application-card-layout[slot="application-card"]') 90 ); 91 if (existing.length > 0 && this.batches.length > 0) { 92 this.batches[0].mounted = true; 93 this.batches[0].elements = existing; 94 this.mountedIndices.add(0); 95 } 96 } else { 97 // Filter/sort changed: nothing from the previous dataset is trustworthy to keep 98 // mounted (ids/order may differ), so start clean and scroll back to the top. 99 this.containerEl.querySelectorAll('application-card-layout[slot="application-card"]').forEach((el) => el.remove()); 100 this.containerEl.querySelectorAll('.batch-scroller-placeholder').forEach((el) => el.remove()); 101 if (this.batches.length > 0) this._mount(0); 102 } 103 104 this._repositionSentinel(); 105 this._observeSentinel(); 106 } 107 108 /** Total number of cards currently represented (mounted or not). */ 109 get length() { 110 return this.data.length; 111 } 112 113 /** 114 * Force-mounts every batch (e.g. for a drag-reorder session that needs the complete, 115 * real sibling list) and suspends the 2-batch cap until resume(). 116 */ 117 pause() { 118 if (this.paused) return; 119 this.paused = true; 120 this.batches.forEach((batch) => this._mount(batch.index, { skipEnforce: true })); 121 } 122 123 /** Re-enables the 2-batch cap and prunes back down to it around the last-focused batch. */ 124 resume() { 125 if (!this.paused) return; 126 this.paused = false; 127 128 // Every card is currently a real, mounted element (pause() mounted them all). 129 // Whatever ran while paused (drag, revert) may have moved cards across what used to 130 // be batch boundaries, so rebuild batches from their actual current DOM order instead 131 // of trusting the pre-pause grouping. 132 const orderedEls = [...this.containerEl.querySelectorAll('application-card-layout[slot="application-card"]')]; 133 const byId = new Map(this.data.map((item) => [String(item.id), item])); 134 this.data = orderedEls.map((el) => byId.get(el.getAttribute('application-id'))).filter(Boolean); 135 136 this.batches = []; 137 for (let i = 0; i < orderedEls.length; i += this.batchSize) { 138 this.batches.push({ 139 index: this.batches.length, 140 items: this.data.slice(i, i + this.batchSize), 141 mounted: true, 142 elements: orderedEls.slice(i, i + this.batchSize), 143 placeholderEl: null, 144 }); 145 } 146 this.mountedIndices = new Set(this.batches.map((b) => b.index)); 147 148 this._enforceWindow(0); 149 // A full reorder re-appends every card, which leaves the sentinel behind wherever it 150 // happened to sit -- it must stay the last child, both as the "load more" trigger and 151 // as the insertion anchor for future batch mounts. 152 this._repositionSentinel(); 153 } 154 155 _buildSentinel() { 156 this.sentinel = document.createElement('div'); 157 this.sentinel.className = 'batch-scroller-sentinel'; 158 this.sentinel.setAttribute('slot', 'application-card'); 159 this.sentinel.style.cssText = 'grid-column:1/-1;height:1px;pointer-events:none;'; 160 } 161 162 _repositionSentinel() { 163 this.containerEl.appendChild(this.sentinel); 164 } 165 166 _getObserver() { 167 if (!this._observer) { 168 this._observer = new IntersectionObserver((entries) => {
169 entries.forEach((entry) => { 170 if (!entry.isIntersecting) return; 171 if (entry.target === this.sentinel) { 172 this._loadNext(); 173 } else { 174 const idx = Number(entry.target.dataset.batchIndex); 175 this._observer.unobserve(entry.target); 176 this._mount(idx); 177 } 178 }); 179 }, { rootMargin: '800px 0px' }); 180 } 181 return this._observer; 182 } 183 184 _observeSentinel() { 185 this._getObserver().observe(this.sentinel); 186 } 187 188 // Advances from the highest batch ever reached, not "first unmounted by array order" -- 189 // a demoted earlier batch is unmounted too, and picking that one up here would re-insert 190 // it at its old spot near the top instead of loading new content at the bottom. 191 _loadNext() { 192 const highestMounted = this.mountedIndices.size ? Math.max(...this.mountedIndices) : -1; 193 const next = this.batches[highestMounted + 1]; 194 if (next && !next.mounted) this._mount(next.index); 195 } 196 197 _mount(index, { skipEnforce = false } = {}) { 198 const batch = this.batches[index]; 199 if (!batch) return; 200 201 if (!batch.mounted) { 202 const observer = this._getObserver(); 203 if (batch.placeholderEl) observer.unobserve(batch.placeholderEl); 204 205 const cards = batch.items.map((data) => buildApplicationCard(data, this.cardType, this.canManage)); 206 207 const anchor = batch.placeholderEl || this.sentinel; 208 cards.forEach((card) => this.containerEl.insertBefore(card, anchor)); 209 if (batch.placeholderEl) { 210 batch.placeholderEl.remove(); 211 batch.placeholderEl = null; 212 } 213 214 // Only safe to touch component state (e.g. onCardBuilt setting `.selected`) after 215 // insertion, since connectedCallback (which builds the internal shadow-DOM nodes 216 // those setters reach into) runs synchronously on insert. 217 cards.forEach((card) => this.onCardBuilt(card)); 218 219 batch.elements = cards; 220 batch.mounted = true; 221 222 // customElements.whenDefined guards against a real race: if the page reloads 223 // already scrolled down, the IntersectionObserver's mandatory initial notification 224 // can fire before <card-list> has upgraded to DraggableCardList, so 225 // registerApplicationCards wouldn't exist on it yet -- same pattern 226 // DraggableCardList.js itself uses for application-card-layout. 227 if (this.cardListEl) { 228 customElements.whenDefined('card-list').then(() => this.cardListEl.registerApplicationCards(cards)); 229 } 230 } 231 232 this.mountedIndices.add(index); 233 if (!skipEnforce && !this.paused) this._enforceWindow(index); 234 } 235 236 // Keeps only the 2 mounted batches closest to focusIndex (including focusIndex itself), 237 // demoting the rest -- this is what actually caps DOM size regardless of total card count. 238 _enforceWindow(focusIndex) { 239 const mounted = [...this.mountedIndices].sort( 240 (a, b) => Math.abs(a - focusIndex) - Math.abs(b - focusIndex) 241 ); 242 const keep = new Set(mounted.slice(0, 2)); 243 mounted.forEach((idx) => { 244 if (!keep.has(idx)) this._demote(idx); 245 }); 246 this.mountedIndices = keep; 247 } 248 249 _demote(index) { 250 const batch = this.batches[index]; 251 if (!batch || !batch.mounted || batch.elements.length === 0) return; 252 253 const first = batch.elements[0]; 254 const last = batch.elements[batch.elements.length - 1]; 255 const top = first.getBoundingClientRect().top; 256 const bottom = last.getBoundingClientRect().bottom; 257 const height = Math.max(1, Math.round(bottom - top)); 258 259 const placeholder = document.createElement('div'); 260 placeholder.className = 'batch-scroller-placeholder'; 261 placeholder.dataset.batchIndex = String(index); 262 placeholder.setAttribute('slot', 'application-card'); 263 placeholder.style.cssText = `grid-column:1/-1;height:${height}px;`; 264 265 this.containerEl.insertBefore(placeholder, first);
266 batch.elements.forEach((el) => el.remove()); 267 268 batch.elements = []; 269 batch.mounted = false; 270 batch.placeholderEl = placeholder; 271 272 this._getObserver().observe(placeholder); 273 } 274 275 _teardownBatches() { 276 if (!this.batches) return; 277 if (this._observer) { 278 this._observer.disconnect(); 279 this._observer = null; 280 } 281 this.containerEl.querySelectorAll('.batch-scroller-placeholder').forEach((el) => el.remove()); 282 } 283}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.