@humanspeak/svelte-virtual-list 0.2.6-beta.6 → 0.2.6-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/SvelteVirtualList.svelte +461 -130
- package/dist/SvelteVirtualList.svelte.d.ts +2 -2
- package/dist/reactive-height-manager/INTEGRATION_EXAMPLE.md +136 -0
- package/dist/reactive-height-manager/README.md +324 -0
- package/dist/reactive-height-manager/ReactiveHeightManager.svelte.d.ts +116 -0
- package/dist/reactive-height-manager/ReactiveHeightManager.svelte.js +200 -0
- package/dist/reactive-height-manager/benchmark.d.ts +5 -0
- package/dist/reactive-height-manager/benchmark.js +25 -0
- package/dist/reactive-height-manager/index.d.ts +50 -0
- package/dist/reactive-height-manager/index.js +55 -0
- package/dist/reactive-height-manager/types.d.ts +41 -0
- package/dist/reactive-height-manager/types.js +1 -0
- package/dist/utils/heightCalculation.d.ts +8 -1
- package/dist/utils/heightCalculation.js +5 -4
- package/dist/utils/heightChangeDetection.d.ts +12 -0
- package/dist/utils/heightChangeDetection.js +20 -0
- package/dist/utils/resizeObserver.d.ts +0 -33
- package/dist/utils/resizeObserver.js +0 -57
- package/dist/utils/scrollCalculation.d.ts +2 -2
- package/dist/utils/scrollCalculation.js +34 -21
- package/dist/utils/throttle.d.ts +95 -0
- package/dist/utils/throttle.js +155 -0
- package/dist/utils/virtualList.d.ts +11 -14
- package/dist/utils/virtualList.js +100 -53
- package/dist/utils/virtualListDebug.d.ts +1 -1
- package/dist/utils/virtualListDebug.js +1 -2
- package/package.json +21 -20
|
@@ -161,6 +161,7 @@
|
|
|
161
161
|
} from './types.js'
|
|
162
162
|
import { calculateAverageHeightDebounced } from './utils/heightCalculation.js'
|
|
163
163
|
import { createRafScheduler } from './utils/raf.js'
|
|
164
|
+
import { isSignificantHeightChange } from './utils/heightChangeDetection.js'
|
|
164
165
|
import {
|
|
165
166
|
calculateScrollPosition,
|
|
166
167
|
calculateTransformY,
|
|
@@ -169,12 +170,16 @@
|
|
|
169
170
|
} from './utils/virtualList.js'
|
|
170
171
|
import { createDebugInfo, shouldShowDebugInfo } from './utils/virtualListDebug.js'
|
|
171
172
|
import { calculateScrollTarget } from './utils/scrollCalculation.js'
|
|
172
|
-
|
|
173
|
+
import { createAdvancedThrottledCallback } from './utils/throttle.js'
|
|
174
|
+
import { ReactiveHeightManager } from './reactive-height-manager/index.js'
|
|
173
175
|
import { BROWSER } from 'esm-env'
|
|
174
|
-
import { onMount, tick } from 'svelte'
|
|
176
|
+
import { onMount, tick, untrack } from 'svelte'
|
|
175
177
|
|
|
176
178
|
const rafSchedule = createRafScheduler()
|
|
177
|
-
|
|
179
|
+
// Package-specific debug flag - safe for library distribution
|
|
180
|
+
// Enable with: NODE_ENV=development SVELTE_VIRTUAL_LIST_DEBUG=true
|
|
181
|
+
const INTERNAL_DEBUG =
|
|
182
|
+
import.meta.env.DEV && import.meta.env.VITE_SVELTE_VIRTUAL_LIST_DEBUG === 'true'
|
|
178
183
|
/**
|
|
179
184
|
* Core configuration props with default values
|
|
180
185
|
* @type {SvelteVirtualListProps}
|
|
@@ -215,6 +220,7 @@
|
|
|
215
220
|
let isCalculatingHeight = $state(false) // Prevents concurrent height calculations
|
|
216
221
|
let isScrolling = $state(false) // Tracks active scrolling state
|
|
217
222
|
let lastMeasuredIndex = $state(-1) // Index of last measured item
|
|
223
|
+
let lastScrollTopSnapshot = $state(0) // Previous scroll position snapshot
|
|
218
224
|
|
|
219
225
|
/**
|
|
220
226
|
* Timers and Observers
|
|
@@ -228,15 +234,182 @@
|
|
|
228
234
|
*/
|
|
229
235
|
let heightCache = $state<Record<number, number>>({}) // Cache of measured item heights
|
|
230
236
|
let dirtyItems = $state(new Set<number>()) // Set of item indices that need height recalculation
|
|
237
|
+
let dirtyItemsCount = $state(0) // Reactive count of dirty items
|
|
231
238
|
|
|
232
239
|
let prevVisibleRange = $state<SvelteVirtualListPreviousVisibleRange | null>(null)
|
|
233
240
|
let prevHeight = $state<number>(0)
|
|
234
241
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Reactive Height Manager - O(1) height calculation system
|
|
244
|
+
* Replaces O(n) totalHeight loop with incremental updates
|
|
245
|
+
*/
|
|
246
|
+
let heightManager = new ReactiveHeightManager({
|
|
247
|
+
itemLength: items.length,
|
|
248
|
+
itemHeight: defaultEstimatedItemHeight
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// Dynamic update coordination to avoid UA scroll anchoring interference
|
|
252
|
+
let dynamicUpdateInProgress = $state(false)
|
|
253
|
+
let suppressBottomAnchoringUntilMs = $state(0)
|
|
254
|
+
function beginDynamicUpdate(): void {
|
|
255
|
+
dynamicUpdateInProgress = true
|
|
256
|
+
if (viewportElement) {
|
|
257
|
+
viewportElement.style.setProperty('overflow-anchor', 'none')
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function endDynamicUpdate(): void {
|
|
261
|
+
dynamicUpdateInProgress = false
|
|
262
|
+
if (viewportElement) {
|
|
263
|
+
viewportElement.style.setProperty('overflow-anchor', 'auto')
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Handles scroll position corrections when item heights change, ensuring proper positioning
|
|
269
|
+
* relative to the user's scroll context. This function calculates the cumulative impact of
|
|
270
|
+
* height changes above the current viewport and adjusts the scroll position accordingly.
|
|
271
|
+
*
|
|
272
|
+
* The correction logic considers:
|
|
273
|
+
* - Height changes occurring above the visible area (which would shift content)
|
|
274
|
+
* - The current scroll position and visible range
|
|
275
|
+
* - Whether height changes warrant a scroll adjustment
|
|
276
|
+
*
|
|
277
|
+
* This prevents jarring jumps when items resize, maintaining the user's visual context
|
|
278
|
+
* and where they are positioned relative to the current scroll position.
|
|
279
|
+
*/
|
|
280
|
+
const handleHeightChangesScrollCorrection = (
|
|
281
|
+
heightChanges: Array<{ index: number; oldHeight: number; newHeight: number; delta: number }>
|
|
282
|
+
) => {
|
|
283
|
+
if (!viewportElement || !initialized || userHasScrolledAway) {
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* CRITICAL: BottomToTop Mode Height Change Fix
|
|
289
|
+
* ============================================
|
|
290
|
+
*
|
|
291
|
+
* Problem: In bottomToTop mode, when items change height while user is at bottom,
|
|
292
|
+
* the list would jump to middle positions (e.g. items 1032-1096) instead of
|
|
293
|
+
* staying anchored at bottom showing Item 0.
|
|
294
|
+
*
|
|
295
|
+
* Root Cause: Height calculations using simple averages (items.length * calculatedItemHeight)
|
|
296
|
+
* were drastically skewed by single item changes. Example:
|
|
297
|
+
* - 1 item changes from 20px to 100px (+80px actual change)
|
|
298
|
+
* - Average jumps from 20px to 22.35px (+2.35px per item)
|
|
299
|
+
* - Across 10,000 items: 2.35px × 10,000 = 23,500px total height error!
|
|
300
|
+
* - This caused massive scroll position overshoots and incorrect positioning
|
|
301
|
+
*
|
|
302
|
+
* Solution: Two-step native scrollIntoView approach
|
|
303
|
+
* 1. Fixed skewed height calculations using actual heightCache measurements (see totalHeight)
|
|
304
|
+
* 2. When wasAtBottomBeforeHeightChange=true (captured before any height processing):
|
|
305
|
+
* a) First scroll to approximate bottom position to render Item 0 in virtual viewport
|
|
306
|
+
* b) Use native scrollIntoView() with block:'end' for precise bottom alignment
|
|
307
|
+
*
|
|
308
|
+
* Why This Works:
|
|
309
|
+
* - Uses browser's native scroll logic instead of error-prone manual calculations
|
|
310
|
+
* - Two-step ensures Item 0 exists in DOM before attempting to scroll to it
|
|
311
|
+
* - Native scrollIntoView handles all edge cases (subpixel precision, browser differences)
|
|
312
|
+
* - Eliminates complex math that was accumulating rounding errors
|
|
313
|
+
* - Smooth behavior provides better UX than instant jumps
|
|
314
|
+
*
|
|
315
|
+
* Dependencies:
|
|
316
|
+
* - wasAtBottomBeforeHeightChange: Set to true when first item marked dirty, prevents cascading corrections
|
|
317
|
+
* - totalHeight(): Uses actual heightCache measurements instead of skewed averages
|
|
318
|
+
* - Aggressive scroll correction: Blocked when wasAtBottomBeforeHeightChange=true
|
|
319
|
+
*
|
|
320
|
+
* ⚠️ DO NOT MODIFY WITHOUT EXTENSIVE TESTING ⚠️
|
|
321
|
+
* This fix resolves a complex interaction between:
|
|
322
|
+
* - Virtual list rendering (only ~20 items visible, rest virtualized)
|
|
323
|
+
* - Height change calculations (prone to average skewing with large datasets)
|
|
324
|
+
* - Multiple scroll correction mechanisms (specific vs aggressive)
|
|
325
|
+
* - Bottom anchor positioning in reversed list mode (bottomToTop)
|
|
326
|
+
*
|
|
327
|
+
* Test coverage: tests/bottomToTop/firstItemHeightChange.spec.ts (45 comprehensive tests)
|
|
328
|
+
* Related fixes: See aggressive scroll correction logic ~line 410 with !wasAtBottomBeforeHeightChange
|
|
329
|
+
*/
|
|
330
|
+
if (
|
|
331
|
+
mode === 'bottomToTop' &&
|
|
332
|
+
wasAtBottomBeforeHeightChange &&
|
|
333
|
+
!programmaticScrollInProgress &&
|
|
334
|
+
performance.now() >= suppressBottomAnchoringUntilMs &&
|
|
335
|
+
!dynamicUpdateInProgress
|
|
336
|
+
) {
|
|
337
|
+
// Step 1: Scroll to approximate position to ensure Item 0 gets rendered in virtual viewport
|
|
338
|
+
const approximateScrollTop = Math.max(0, totalHeight() - height)
|
|
339
|
+
viewportElement.scrollTop = approximateScrollTop
|
|
340
|
+
scrollTop = approximateScrollTop
|
|
341
|
+
|
|
342
|
+
// Step 2: Use native scrollIntoView for precise bottom-edge positioning after DOM updates
|
|
343
|
+
tick().then(() => {
|
|
344
|
+
const item0Element = viewportElement.querySelector('[data-original-index="0"]')
|
|
345
|
+
if (item0Element) {
|
|
346
|
+
// Native browser API handles all positioning edge cases perfectly
|
|
347
|
+
item0Element.scrollIntoView({
|
|
348
|
+
block: 'end', // Align Item 0 to bottom edge of viewport
|
|
349
|
+
behavior: 'smooth', // Smooth animation for better UX
|
|
350
|
+
inline: 'nearest' // Minimal horizontal adjustment
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
// Sync our internal scroll state with actual DOM position
|
|
354
|
+
scrollTop = viewportElement.scrollTop
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
return // Skip remaining scroll correction logic - we've handled bottomToTop case
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const currentScrollTop = viewportElement.scrollTop
|
|
362
|
+
const maxScrollTop = Math.max(0, totalHeight() - height)
|
|
363
|
+
|
|
364
|
+
// Calculate total height change impact above current visible area
|
|
365
|
+
let heightChangeAboveViewport = 0
|
|
366
|
+
const currentVisibleRange = visibleItems()
|
|
367
|
+
|
|
368
|
+
for (const change of heightChanges) {
|
|
369
|
+
// Only consider items that are above the current visible range
|
|
370
|
+
if (change.index < currentVisibleRange.start) {
|
|
371
|
+
heightChangeAboveViewport += change.delta
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// If there are height changes above the viewport, adjust scroll to maintain position
|
|
376
|
+
if (Math.abs(heightChangeAboveViewport) > 1) {
|
|
377
|
+
const newScrollTop = Math.min(
|
|
378
|
+
maxScrollTop,
|
|
379
|
+
Math.max(0, currentScrollTop + heightChangeAboveViewport)
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
viewportElement.scrollTop = newScrollTop
|
|
383
|
+
scrollTop = newScrollTop
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Height update function - removed throttling to fix race condition on initial load
|
|
388
|
+
// Create throttled height update function with trailing execution to ensure measurement always happens
|
|
389
|
+
const triggerHeightUpdate = createAdvancedThrottledCallback(
|
|
390
|
+
() => {
|
|
391
|
+
if (BROWSER && dirtyItemsCount > 0) {
|
|
392
|
+
// Capture bottom state before any height processing to prevent cascading corrections
|
|
393
|
+
wasAtBottomBeforeHeightChange = atBottom
|
|
394
|
+
beginDynamicUpdate()
|
|
395
|
+
updateHeight()
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
16,
|
|
399
|
+
{
|
|
400
|
+
leading: true, // Execute immediately for responsiveness
|
|
401
|
+
trailing: true // CRUCIAL: Execute the last call after delay to ensure measurement always happens
|
|
239
402
|
}
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
// Trigger height calculation when dirty items are added
|
|
406
|
+
$effect(() => {
|
|
407
|
+
triggerHeightUpdate()
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
// Keep height manager synchronized with items length
|
|
411
|
+
$effect(() => {
|
|
412
|
+
heightManager.updateItemLength(items.length)
|
|
240
413
|
})
|
|
241
414
|
|
|
242
415
|
const updateHeight = () => {
|
|
@@ -247,44 +420,93 @@
|
|
|
247
420
|
itemElements,
|
|
248
421
|
heightCache,
|
|
249
422
|
lastMeasuredIndex,
|
|
250
|
-
|
|
423
|
+
heightManager.averageHeight,
|
|
251
424
|
(result) => {
|
|
252
|
-
|
|
425
|
+
// Critical updates that must trigger reactive effects immediately
|
|
426
|
+
heightManager.itemHeight = result.newHeight
|
|
253
427
|
lastMeasuredIndex = result.newLastMeasuredIndex
|
|
254
428
|
heightCache = result.updatedHeightCache
|
|
255
429
|
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
430
|
+
// Handle height changes for scroll correction (needs updated heightCache)
|
|
431
|
+
if (result.heightChanges.length > 0 && mode === 'bottomToTop') {
|
|
432
|
+
handleHeightChangesScrollCorrection(result.heightChanges)
|
|
433
|
+
}
|
|
259
434
|
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
435
|
+
// Non-critical updates wrapped in untrack to prevent reactive cascades
|
|
436
|
+
untrack(() => {
|
|
437
|
+
// Process height changes with ReactiveHeightManager (O(dirty) instead of O(n)!)
|
|
438
|
+
if (result.heightChanges.length > 0) {
|
|
439
|
+
heightManager.processDirtyHeights(result.heightChanges)
|
|
440
|
+
}
|
|
264
441
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
442
|
+
// Clear processed dirty items (all dirty items were processed)
|
|
443
|
+
dirtyItems.clear()
|
|
444
|
+
dirtyItemsCount = 0
|
|
445
|
+
|
|
446
|
+
// Reset bottom state flag
|
|
447
|
+
wasAtBottomBeforeHeightChange = false
|
|
448
|
+
})
|
|
449
|
+
endDynamicUpdate()
|
|
271
450
|
},
|
|
272
451
|
100, // debounceTime
|
|
273
452
|
dirtyItems, // Pass dirty items for processing
|
|
274
|
-
|
|
275
|
-
|
|
453
|
+
0, // Don't pass ReactiveHeightManager state - let each system manage its own totals
|
|
454
|
+
0, // Don't pass ReactiveHeightManager state - let each system manage its own totals
|
|
455
|
+
mode // Pass mode for correct element indexing
|
|
276
456
|
)
|
|
277
457
|
}
|
|
278
458
|
|
|
279
459
|
// Add new effect to handle height changes
|
|
280
460
|
// Track if user has scrolled away from bottom to prevent snap-back
|
|
281
461
|
let userHasScrolledAway = $state(false)
|
|
462
|
+
let programmaticScrollInProgress = $state(false) // Prevent bottom-anchoring during programmatic scrolls
|
|
282
463
|
let lastCalculatedHeight = $state(0)
|
|
464
|
+
let lastItemsLength = $state(0)
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* CRITICAL: O(1) Reactive Total Height Calculation
|
|
468
|
+
* ===============================================
|
|
469
|
+
*
|
|
470
|
+
* Uses ReactiveHeightManager for O(1) height calculations instead of O(n) loops.
|
|
471
|
+
* This fixes the root cause of massive scroll jumps in bottomToTop mode.
|
|
472
|
+
*
|
|
473
|
+
* Problem with Previous O(n) Approach:
|
|
474
|
+
* - Looped through ALL items on every reactive update
|
|
475
|
+
* - Used simple: items.length * calculatedItemHeight
|
|
476
|
+
* - When 1 item changes from 20px to 100px in 10,000 items:
|
|
477
|
+
* - calculatedItemHeight jumps from 20 to 22.35 (+2.35px)
|
|
478
|
+
* - Total height jumps from 200,000px to 223,500px (+23,500px!)
|
|
479
|
+
* - This 23,500px error caused massive scroll position overshoots
|
|
480
|
+
*
|
|
481
|
+
* Solution with ReactiveHeightManager:
|
|
482
|
+
* - O(1) reactive calculations using incremental updates
|
|
483
|
+
* - Uses actual measured heights from heightCache where available
|
|
484
|
+
* - Only estimates heights for items that haven't been measured yet
|
|
485
|
+
* - Processes only dirty/changed heights instead of all items
|
|
486
|
+
*
|
|
487
|
+
* Example with O(1) Approach:
|
|
488
|
+
* - 20 items measured: 19 × 20px + 1 × 100px = 460px measured
|
|
489
|
+
* - 9,980 unmeasured: 9,980 × 23px (avg of measured) = 229,540px estimated
|
|
490
|
+
* - Total: 460px + 229,540px = 230,000px (only +30,000px vs +23,500px error)
|
|
491
|
+
* - Much smaller error that doesn't cause massive scroll jumps
|
|
492
|
+
* - Updates incrementally using processDirtyHeights() instead of recalculating all
|
|
493
|
+
*
|
|
494
|
+
* This getter is reactive and updates whenever heightManager's internal state changes.
|
|
495
|
+
* Used by: atBottom calculation, scroll corrections, maxScrollTop calculations
|
|
496
|
+
*/
|
|
497
|
+
let totalHeight = $derived(() => heightManager.totalHeight)
|
|
498
|
+
|
|
499
|
+
let atTop = $derived(scrollTop <= 1)
|
|
500
|
+
let atBottom = $derived(scrollTop >= totalHeight() - height - 1)
|
|
501
|
+
let wasAtBottomBeforeHeightChange = false
|
|
502
|
+
let lastVisibleRange: SvelteVirtualListPreviousVisibleRange | null = null
|
|
503
|
+
|
|
504
|
+
$inspect('scrollState: atTop', atTop)
|
|
505
|
+
$inspect('scrollState: atBottom', atBottom)
|
|
283
506
|
|
|
284
507
|
$effect(() => {
|
|
285
508
|
if (BROWSER && initialized && mode === 'bottomToTop' && viewportElement) {
|
|
286
|
-
const
|
|
287
|
-
const targetScrollTop = Math.max(0, totalHeight - height)
|
|
509
|
+
const targetScrollTop = Math.max(0, totalHeight() - height)
|
|
288
510
|
const currentScrollTop = viewportElement.scrollTop
|
|
289
511
|
const scrollDifference = Math.abs(currentScrollTop - targetScrollTop)
|
|
290
512
|
|
|
@@ -292,25 +514,26 @@
|
|
|
292
514
|
// 1. Item height changed significantly (not just user scrolling)
|
|
293
515
|
// 2. User hasn't intentionally scrolled away from bottom
|
|
294
516
|
// 3. We're significantly off target
|
|
517
|
+
// 4. We're not at the bottom (where height changes should be handled more carefully)
|
|
295
518
|
const heightChanged = Math.abs(calculatedItemHeight - lastCalculatedHeight) > 1
|
|
519
|
+
const maxScrollTop = Math.max(0, totalHeight() - height)
|
|
520
|
+
|
|
521
|
+
// In bottomToTop mode, we're "at bottom" when scroll is at max position
|
|
522
|
+
const isAtBottom = Math.abs(currentScrollTop - maxScrollTop) < calculatedItemHeight
|
|
296
523
|
const shouldCorrect =
|
|
297
|
-
heightChanged &&
|
|
524
|
+
heightChanged &&
|
|
525
|
+
!userHasScrolledAway &&
|
|
526
|
+
!isAtBottom && // Don't apply aggressive correction when at bottom
|
|
527
|
+
!programmaticScrollInProgress && // Don't interfere with programmatic scrolls
|
|
528
|
+
performance.now() >= suppressBottomAnchoringUntilMs &&
|
|
529
|
+
!dynamicUpdateInProgress &&
|
|
530
|
+
scrollDifference > calculatedItemHeight * 3
|
|
298
531
|
|
|
299
532
|
if (shouldCorrect) {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
'to',
|
|
305
|
-
targetScrollTop,
|
|
306
|
-
'diff:',
|
|
307
|
-
scrollDifference,
|
|
308
|
-
'heightChanged:',
|
|
309
|
-
heightChanged
|
|
310
|
-
)
|
|
311
|
-
}
|
|
312
|
-
viewportElement.scrollTop = targetScrollTop
|
|
313
|
-
scrollTop = targetScrollTop
|
|
533
|
+
// Round to avoid subpixel positioning issues in bottomToTop mode
|
|
534
|
+
const roundedTargetScrollTop = Math.round(targetScrollTop)
|
|
535
|
+
viewportElement.scrollTop = roundedTargetScrollTop
|
|
536
|
+
scrollTop = roundedTargetScrollTop
|
|
314
537
|
}
|
|
315
538
|
|
|
316
539
|
// Track if user has scrolled significantly away from bottom
|
|
@@ -322,6 +545,56 @@
|
|
|
322
545
|
}
|
|
323
546
|
})
|
|
324
547
|
|
|
548
|
+
// Handle items being added/removed in bottomToTop mode
|
|
549
|
+
$effect(() => {
|
|
550
|
+
// Only track items.length to prevent re-runs on other reactive changes
|
|
551
|
+
const currentItemsLength = items.length
|
|
552
|
+
|
|
553
|
+
if (
|
|
554
|
+
BROWSER &&
|
|
555
|
+
initialized &&
|
|
556
|
+
mode === 'bottomToTop' &&
|
|
557
|
+
viewportElement &&
|
|
558
|
+
lastItemsLength > 0
|
|
559
|
+
) {
|
|
560
|
+
const itemsAdded = currentItemsLength - lastItemsLength
|
|
561
|
+
|
|
562
|
+
if (itemsAdded !== 0) {
|
|
563
|
+
// Capture all reactive values immediately to prevent re-triggering
|
|
564
|
+
const currentScrollTop = viewportElement.scrollTop
|
|
565
|
+
const currentCalculatedItemHeight = calculatedItemHeight
|
|
566
|
+
const currentHeight = height
|
|
567
|
+
const currentTotalHeight = totalHeight()
|
|
568
|
+
const maxScrollTop = Math.max(0, currentTotalHeight - currentHeight)
|
|
569
|
+
|
|
570
|
+
// Check if user was at/near the bottom before items were added
|
|
571
|
+
const wasNearBottom =
|
|
572
|
+
Math.abs(
|
|
573
|
+
currentScrollTop -
|
|
574
|
+
Math.max(
|
|
575
|
+
0,
|
|
576
|
+
lastItemsLength * currentCalculatedItemHeight - currentHeight
|
|
577
|
+
)
|
|
578
|
+
) <
|
|
579
|
+
currentCalculatedItemHeight * 2
|
|
580
|
+
|
|
581
|
+
if (wasNearBottom || currentScrollTop === 0) {
|
|
582
|
+
// User was at bottom, keep them at bottom after new items are added
|
|
583
|
+
beginDynamicUpdate()
|
|
584
|
+
const newScrollTop = maxScrollTop
|
|
585
|
+
viewportElement.scrollTop = newScrollTop
|
|
586
|
+
scrollTop = newScrollTop
|
|
587
|
+
|
|
588
|
+
// Reset the "scrolled away" flag since we're actively managing position
|
|
589
|
+
userHasScrolledAway = false
|
|
590
|
+
endDynamicUpdate()
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
lastItemsLength = currentItemsLength
|
|
596
|
+
})
|
|
597
|
+
|
|
325
598
|
// Update container height when element is mounted
|
|
326
599
|
$effect(() => {
|
|
327
600
|
if (BROWSER && containerElement) {
|
|
@@ -339,8 +612,7 @@
|
|
|
339
612
|
items.length &&
|
|
340
613
|
!initialized
|
|
341
614
|
) {
|
|
342
|
-
const
|
|
343
|
-
const targetScrollTop = Math.max(0, totalHeight - height)
|
|
615
|
+
const targetScrollTop = Math.max(0, totalHeight() - height)
|
|
344
616
|
|
|
345
617
|
// Add delay to ensure layout is complete
|
|
346
618
|
tick().then(() => {
|
|
@@ -362,23 +634,6 @@
|
|
|
362
634
|
}
|
|
363
635
|
})
|
|
364
636
|
|
|
365
|
-
/**
|
|
366
|
-
* Calculate precise item height based on actual measurements when available
|
|
367
|
-
*/
|
|
368
|
-
// Running totals for efficient precise height calculation
|
|
369
|
-
let totalMeasuredHeight = $state(0)
|
|
370
|
-
let measuredCount = $state(0)
|
|
371
|
-
const preciseItemHeight = $derived(() => {
|
|
372
|
-
if (measuredCount > 100) {
|
|
373
|
-
const avgHeight = totalMeasuredHeight / measuredCount
|
|
374
|
-
// Only use if the difference is significant (more than 0.5px)
|
|
375
|
-
if (Math.abs(avgHeight - calculatedItemHeight) > 0.5) {
|
|
376
|
-
return avgHeight
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
return calculatedItemHeight
|
|
380
|
-
})
|
|
381
|
-
|
|
382
637
|
/**
|
|
383
638
|
* Calculates the range of items that should be rendered based on current scroll position.
|
|
384
639
|
*
|
|
@@ -406,32 +661,39 @@
|
|
|
406
661
|
// This prevents showing wrong items when scrollTop starts at 0
|
|
407
662
|
if (mode === 'bottomToTop' && !initialized && scrollTop === 0 && viewportHeight > 0) {
|
|
408
663
|
// Calculate what the correct scroll position should be
|
|
409
|
-
const
|
|
410
|
-
const targetScrollTop = Math.max(0, totalHeight - viewportHeight)
|
|
664
|
+
const targetScrollTop = Math.max(0, totalHeight() - viewportHeight)
|
|
411
665
|
|
|
412
666
|
// Use the target scroll position for visible range calculation
|
|
413
|
-
|
|
667
|
+
lastVisibleRange = calculateVisibleRange(
|
|
414
668
|
targetScrollTop,
|
|
415
669
|
viewportHeight,
|
|
416
|
-
|
|
670
|
+
heightManager.averageHeight,
|
|
417
671
|
items.length,
|
|
418
672
|
bufferSize,
|
|
419
|
-
mode
|
|
673
|
+
mode,
|
|
674
|
+
atBottom,
|
|
675
|
+
wasAtBottomBeforeHeightChange,
|
|
676
|
+
lastVisibleRange,
|
|
677
|
+
totalHeight()
|
|
420
678
|
)
|
|
421
679
|
|
|
422
|
-
return
|
|
680
|
+
return lastVisibleRange
|
|
423
681
|
}
|
|
424
682
|
|
|
425
|
-
|
|
683
|
+
lastVisibleRange = calculateVisibleRange(
|
|
426
684
|
scrollTop,
|
|
427
685
|
viewportHeight,
|
|
428
|
-
|
|
686
|
+
heightManager.averageHeight,
|
|
429
687
|
items.length,
|
|
430
688
|
bufferSize,
|
|
431
|
-
mode
|
|
689
|
+
mode,
|
|
690
|
+
atBottom,
|
|
691
|
+
wasAtBottomBeforeHeightChange,
|
|
692
|
+
lastVisibleRange,
|
|
693
|
+
totalHeight()
|
|
432
694
|
)
|
|
433
695
|
|
|
434
|
-
return
|
|
696
|
+
return lastVisibleRange
|
|
435
697
|
})
|
|
436
698
|
|
|
437
699
|
/**
|
|
@@ -461,7 +723,16 @@
|
|
|
461
723
|
if (!isScrolling) {
|
|
462
724
|
isScrolling = true
|
|
463
725
|
rafSchedule(() => {
|
|
464
|
-
|
|
726
|
+
const current = viewportElement.scrollTop
|
|
727
|
+
if (mode === 'bottomToTop') {
|
|
728
|
+
const delta = lastScrollTopSnapshot - current
|
|
729
|
+
if (delta > 0.5) {
|
|
730
|
+
suppressBottomAnchoringUntilMs = performance.now() + 300
|
|
731
|
+
userHasScrolledAway = true
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
lastScrollTopSnapshot = current
|
|
735
|
+
scrollTop = current
|
|
465
736
|
isScrolling = false
|
|
466
737
|
})
|
|
467
738
|
}
|
|
@@ -544,37 +815,45 @@
|
|
|
544
815
|
if (BROWSER) {
|
|
545
816
|
// Watch for individual item size changes
|
|
546
817
|
itemResizeObserver = new ResizeObserver((entries) => {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
818
|
+
tick().then(() => {
|
|
819
|
+
let shouldRecalculate = false
|
|
820
|
+
const visibleRange = visibleItems() // Cache once to avoid reactive loops
|
|
821
|
+
|
|
822
|
+
for (const entry of entries) {
|
|
823
|
+
const element = entry.target as HTMLElement
|
|
824
|
+
const elementIndex = itemElements.indexOf(element)
|
|
825
|
+
const actualIndex = parseInt(element.dataset.originalIndex || '-1', 10)
|
|
826
|
+
|
|
827
|
+
if (elementIndex !== -1) {
|
|
828
|
+
if (actualIndex >= 0) {
|
|
829
|
+
const currentHeight = element.getBoundingClientRect().height
|
|
830
|
+
const isSignificant = isSignificantHeightChange(
|
|
831
|
+
actualIndex,
|
|
832
|
+
currentHeight,
|
|
833
|
+
heightCache
|
|
834
|
+
)
|
|
559
835
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
836
|
+
// Only mark as dirty if height change is significant
|
|
837
|
+
if (isSignificant) {
|
|
838
|
+
// Capture bottom state when FIRST item gets marked dirty
|
|
839
|
+
if (dirtyItemsCount === 0) {
|
|
840
|
+
wasAtBottomBeforeHeightChange = atBottom
|
|
841
|
+
}
|
|
563
842
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
843
|
+
dirtyItems.add(actualIndex)
|
|
844
|
+
dirtyItemsCount = dirtyItems.size
|
|
845
|
+
shouldRecalculate = true
|
|
846
|
+
}
|
|
847
|
+
}
|
|
568
848
|
}
|
|
569
849
|
}
|
|
570
|
-
}
|
|
571
850
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
}
|
|
577
|
-
}
|
|
851
|
+
if (shouldRecalculate) {
|
|
852
|
+
rafSchedule(() => {
|
|
853
|
+
updateHeight()
|
|
854
|
+
})
|
|
855
|
+
}
|
|
856
|
+
})
|
|
578
857
|
})
|
|
579
858
|
}
|
|
580
859
|
|
|
@@ -681,10 +960,10 @@
|
|
|
681
960
|
* {/snippet}
|
|
682
961
|
* </SvelteVirtualList>
|
|
683
962
|
*
|
|
684
|
-
* @returns {void}
|
|
963
|
+
* @returns {Promise<void>} Promise that resolves when scrolling is complete
|
|
685
964
|
* @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
|
|
686
965
|
*/
|
|
687
|
-
export const scroll = (options: SvelteVirtualListScrollOptions): void => {
|
|
966
|
+
export const scroll = async (options: SvelteVirtualListScrollOptions): Promise<void> => {
|
|
688
967
|
const { index, smoothScroll, shouldThrowOnBounds, align } = {
|
|
689
968
|
...DEFAULT_SCROLL_OPTIONS,
|
|
690
969
|
...options
|
|
@@ -716,10 +995,10 @@
|
|
|
716
995
|
// Use extracted scroll calculation utility
|
|
717
996
|
const scrollTarget = calculateScrollTarget({
|
|
718
997
|
mode,
|
|
719
|
-
align,
|
|
998
|
+
align: align || 'auto',
|
|
720
999
|
targetIndex,
|
|
721
1000
|
itemsLength: items.length,
|
|
722
|
-
calculatedItemHeight,
|
|
1001
|
+
calculatedItemHeight: heightManager.averageHeight, // Use dynamic average from ReactiveHeightManager
|
|
723
1002
|
height,
|
|
724
1003
|
scrollTop,
|
|
725
1004
|
firstVisibleIndex,
|
|
@@ -732,10 +1011,52 @@
|
|
|
732
1011
|
return
|
|
733
1012
|
}
|
|
734
1013
|
|
|
1014
|
+
// Prevent bottom-anchoring logic from interfering with programmatic scroll
|
|
1015
|
+
programmaticScrollInProgress = true
|
|
1016
|
+
|
|
1017
|
+
// CROSS-BROWSER COMPATIBILITY FIX:
|
|
1018
|
+
// All major browsers (Chrome, Firefox, Safari) have inconsistent behavior with scrollTo()
|
|
1019
|
+
// in bottomToTop mode when using smooth scrolling. Using scrollIntoView() on the highest
|
|
1020
|
+
// visible element provides consistent cross-browser smooth scrolling behavior.
|
|
1021
|
+
// This approach works universally and maintains the user's expected smooth scroll experience.
|
|
1022
|
+
if (mode === 'bottomToTop' && smoothScroll) {
|
|
1023
|
+
// Find the element with the highest original-index in the current viewport
|
|
1024
|
+
const visibleElements = viewportElement.querySelectorAll('[data-original-index]')
|
|
1025
|
+
let maxIndex = -1
|
|
1026
|
+
let maxElement: HTMLElement | null = null
|
|
1027
|
+
for (const el of visibleElements) {
|
|
1028
|
+
const index = parseInt(el.getAttribute('data-original-index') || '-1')
|
|
1029
|
+
if (index > maxIndex) {
|
|
1030
|
+
maxIndex = index
|
|
1031
|
+
maxElement = el as HTMLElement
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
maxElement?.scrollIntoView({
|
|
1036
|
+
behavior: 'smooth'
|
|
1037
|
+
})
|
|
1038
|
+
await tick()
|
|
1039
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
1040
|
+
await tick()
|
|
1041
|
+
}
|
|
1042
|
+
|
|
735
1043
|
viewportElement.scrollTo({
|
|
736
1044
|
top: scrollTarget,
|
|
737
1045
|
behavior: smoothScroll ? 'smooth' : 'auto'
|
|
738
1046
|
})
|
|
1047
|
+
|
|
1048
|
+
// Update scrollTop state in next frame to avoid synchronous re-renders
|
|
1049
|
+
requestAnimationFrame(() => {
|
|
1050
|
+
scrollTop = scrollTarget
|
|
1051
|
+
})
|
|
1052
|
+
|
|
1053
|
+
// Clear the flag after scroll completes
|
|
1054
|
+
setTimeout(
|
|
1055
|
+
() => {
|
|
1056
|
+
programmaticScrollInProgress = false
|
|
1057
|
+
},
|
|
1058
|
+
smoothScroll ? 500 : 100
|
|
1059
|
+
)
|
|
739
1060
|
}
|
|
740
1061
|
|
|
741
1062
|
/**
|
|
@@ -748,25 +1069,12 @@
|
|
|
748
1069
|
function autoObserveItemResize(element: HTMLElement) {
|
|
749
1070
|
if (itemResizeObserver) {
|
|
750
1071
|
itemResizeObserver.observe(element)
|
|
751
|
-
if (INTERNAL_DEBUG) {
|
|
752
|
-
console.log(
|
|
753
|
-
'Started observing element:',
|
|
754
|
-
element,
|
|
755
|
-
'Current height:',
|
|
756
|
-
element.getBoundingClientRect().height
|
|
757
|
-
)
|
|
758
|
-
}
|
|
759
|
-
} else if (INTERNAL_DEBUG) {
|
|
760
|
-
console.log('itemResizeObserver not available for element:', element)
|
|
761
1072
|
}
|
|
762
1073
|
|
|
763
1074
|
return {
|
|
764
1075
|
destroy() {
|
|
765
1076
|
if (itemResizeObserver) {
|
|
766
1077
|
itemResizeObserver.unobserve(element)
|
|
767
|
-
if (INTERNAL_DEBUG) {
|
|
768
|
-
console.log('Stopped observing element:', element)
|
|
769
|
-
}
|
|
770
1078
|
}
|
|
771
1079
|
}
|
|
772
1080
|
}
|
|
@@ -800,9 +1108,8 @@
|
|
|
800
1108
|
{...testId ? { 'data-testid': `${testId}-content` } : {}}
|
|
801
1109
|
class={contentClass ?? 'virtual-list-content'}
|
|
802
1110
|
style:height="{(() => {
|
|
803
|
-
// Use
|
|
804
|
-
|
|
805
|
-
return Math.max(height, totalActualHeight)
|
|
1111
|
+
// Use ReactiveHeightManager's accurate total height for better cross-browser compatibility
|
|
1112
|
+
return Math.max(height, totalHeight())
|
|
806
1113
|
})()}px"
|
|
807
1114
|
>
|
|
808
1115
|
<!-- Items container is translated to show correct items -->
|
|
@@ -810,25 +1117,46 @@
|
|
|
810
1117
|
id="virtual-list-items"
|
|
811
1118
|
{...testId ? { 'data-testid': `${testId}-items` } : {}}
|
|
812
1119
|
class={itemsClass ?? 'virtual-list-items'}
|
|
1120
|
+
style:visibility={height === 0 && mode === 'bottomToTop' ? 'hidden' : 'visible'}
|
|
813
1121
|
style:transform="translateY({(() => {
|
|
1122
|
+
const viewportHeight = height || 0
|
|
1123
|
+
const visibleRange = visibleItems()
|
|
1124
|
+
|
|
1125
|
+
// For bottomToTop mode with few items, provide reasonable initial positioning
|
|
1126
|
+
// even when height is not yet measured to prevent flash
|
|
1127
|
+
let effectiveHeight = viewportHeight
|
|
1128
|
+
if (mode === 'bottomToTop' && viewportHeight === 0 && containerElement) {
|
|
1129
|
+
// Measure height synchronously if available
|
|
1130
|
+
effectiveHeight = containerElement.getBoundingClientRect().height || 400
|
|
1131
|
+
} else if (mode === 'bottomToTop' && viewportHeight === 0) {
|
|
1132
|
+
// Fallback to reasonable default height estimate for initial positioning
|
|
1133
|
+
effectiveHeight = 400
|
|
1134
|
+
}
|
|
1135
|
+
|
|
814
1136
|
const transform = calculateTransformY(
|
|
815
1137
|
mode,
|
|
816
1138
|
items.length,
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1139
|
+
visibleRange.end,
|
|
1140
|
+
visibleRange.start,
|
|
1141
|
+
heightManager.averageHeight,
|
|
1142
|
+
effectiveHeight,
|
|
1143
|
+
totalHeight() // Pass ReactiveHeightManager's accurate total height
|
|
820
1144
|
)
|
|
821
1145
|
|
|
822
1146
|
return transform
|
|
823
1147
|
})()}px)"
|
|
824
1148
|
>
|
|
825
1149
|
{#each (() => {
|
|
1150
|
+
const visibleRange = visibleItems()
|
|
826
1151
|
const slice = mode === 'bottomToTop' ? items
|
|
827
|
-
.slice(
|
|
828
|
-
.reverse() : items.slice(
|
|
1152
|
+
.slice(visibleRange.start, visibleRange.end)
|
|
1153
|
+
.reverse() : items.slice(visibleRange.start, visibleRange.end)
|
|
1154
|
+
|
|
1155
|
+
// Map each item with its original index for proper DOM element tracking
|
|
1156
|
+
const itemsWithOriginalIndex = slice.map( (item, sliceIndex) => ({ item, originalIndex: mode === 'bottomToTop' ? visibleRange.end - 1 - sliceIndex : visibleRange.start + sliceIndex, sliceIndex }) )
|
|
829
1157
|
|
|
830
|
-
return
|
|
831
|
-
})() as
|
|
1158
|
+
return itemsWithOriginalIndex
|
|
1159
|
+
})() as currentItemWithIndex, i (currentItemWithIndex.originalIndex)}
|
|
832
1160
|
<!-- Only debug when visible range or average height changes -->
|
|
833
1161
|
{#if debug && i === 0 && shouldShowDebugInfo(prevVisibleRange, visibleItems(), prevHeight, calculatedItemHeight)}
|
|
834
1162
|
{@const debugInfo = createDebugInfo(
|
|
@@ -837,19 +1165,22 @@
|
|
|
837
1165
|
Object.keys(heightCache).length,
|
|
838
1166
|
calculatedItemHeight,
|
|
839
1167
|
scrollTop,
|
|
840
|
-
height || 0
|
|
1168
|
+
height || 0,
|
|
1169
|
+
totalHeight()
|
|
841
1170
|
)}
|
|
842
1171
|
{debugFunction
|
|
843
1172
|
? debugFunction(debugInfo)
|
|
844
1173
|
: console.info('Virtual List Debug:', debugInfo)}
|
|
845
1174
|
{/if}
|
|
846
1175
|
<!-- Render each visible item -->
|
|
847
|
-
<div
|
|
1176
|
+
<div
|
|
1177
|
+
bind:this={itemElements[currentItemWithIndex.sliceIndex]}
|
|
1178
|
+
use:autoObserveItemResize
|
|
1179
|
+
data-original-index={currentItemWithIndex.originalIndex}
|
|
1180
|
+
>
|
|
848
1181
|
{@render renderItem(
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
? items.length - (visibleItems().start + i) - 1
|
|
852
|
-
: visibleItems().start + i
|
|
1182
|
+
currentItemWithIndex.item,
|
|
1183
|
+
currentItemWithIndex.originalIndex
|
|
853
1184
|
)}
|
|
854
1185
|
</div>
|
|
855
1186
|
{/each}
|