@humanspeak/svelte-virtual-list 0.4.6 → 0.5.1
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/README.md +16 -63
- package/dist/SvelteVirtualList.svelte +26 -746
- package/dist/SvelteVirtualList.svelte.d.ts +9 -58
- package/dist/index.d.ts +2 -2
- package/dist/reactive-list-manager/INTEGRATION_EXAMPLE.md +0 -5
- package/dist/types.d.ts +0 -11
- package/dist/utils/heightCalculation.d.ts +1 -2
- package/dist/utils/heightCalculation.js +2 -2
- package/dist/utils/scrollCalculation.d.ts +3 -9
- package/dist/utils/scrollCalculation.js +13 -72
- package/dist/utils/types.d.ts +0 -3
- package/dist/utils/virtualList.d.ts +12 -18
- package/dist/utils/virtualList.js +60 -128
- package/package.json +29 -29
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
@component SvelteVirtualList
|
|
3
3
|
|
|
4
4
|
A high-performance, memory-efficient virtualized list component for Svelte 5.
|
|
5
|
-
Renders only visible items plus a buffer, supporting dynamic item heights
|
|
6
|
-
|
|
5
|
+
Renders only visible items plus a buffer, supporting dynamic item heights
|
|
6
|
+
and programmatic control.
|
|
7
7
|
|
|
8
8
|
=============================
|
|
9
9
|
== Key Features ==
|
|
10
10
|
=============================
|
|
11
11
|
- Dynamic item height support (no fixed height required)
|
|
12
|
-
- Top-to-bottom and bottom-to-top (chat-style) scrolling
|
|
13
12
|
- Programmatic scrolling with flexible alignment (top, bottom, auto)
|
|
14
13
|
- Smooth scrolling and buffer size configuration
|
|
15
14
|
- SSR compatible and hydration-friendly
|
|
@@ -25,7 +24,6 @@
|
|
|
25
24
|
```svelte
|
|
26
25
|
<SvelteVirtualList
|
|
27
26
|
items={data}
|
|
28
|
-
mode="bottomToTop"
|
|
29
27
|
bind:this={listRef}
|
|
30
28
|
>
|
|
31
29
|
{#snippet renderItem(item)}
|
|
@@ -43,7 +41,6 @@
|
|
|
43
41
|
- Handles resize events and dynamic content changes
|
|
44
42
|
- Optimized for very large lists through virtualization
|
|
45
43
|
- Modular architecture with extracted utility functions
|
|
46
|
-
- Bi-directional support: mode="topToBottom" or "bottomToTop"
|
|
47
44
|
- Designed for extensibility and easy debugging
|
|
48
45
|
|
|
49
46
|
=============================
|
|
@@ -76,49 +73,44 @@
|
|
|
76
73
|
* - Implemented debounced measurements
|
|
77
74
|
* - Created height averaging mechanism for performance
|
|
78
75
|
*
|
|
79
|
-
* 3.
|
|
80
|
-
* - Added bottomToTop mode
|
|
81
|
-
* - Solved complex initialization issues with flexbox
|
|
82
|
-
* - Implemented careful scroll position management
|
|
83
|
-
*
|
|
84
|
-
* 4. Performance Optimizations ✓
|
|
76
|
+
* 3. Performance Optimizations ✓
|
|
85
77
|
* - Added element recycling through keyed each blocks
|
|
86
78
|
* - Implemented RAF for smooth animations
|
|
87
79
|
* - Optimized DOM updates with transform translations
|
|
88
80
|
*
|
|
89
|
-
*
|
|
81
|
+
* 4. Stability Improvements ✓
|
|
90
82
|
* - Added ResizeObserver for responsive updates
|
|
91
83
|
* - Implemented proper cleanup on component destruction
|
|
92
84
|
* - Added debug mode for development assistance
|
|
93
85
|
*
|
|
94
|
-
*
|
|
86
|
+
* 5. Large Dataset Optimizations ✓
|
|
95
87
|
* - Implemented chunked processing for 10k+ items
|
|
96
88
|
* - Added progressive initialization system
|
|
97
89
|
* - Deferred height calculations for better initial load
|
|
98
90
|
* - Optimized memory usage for large lists
|
|
99
91
|
* - Added progress tracking for initialization
|
|
100
92
|
*
|
|
101
|
-
*
|
|
93
|
+
* 6. Size Management Improvements ✓
|
|
102
94
|
* - Implemented height caching system for measured items
|
|
103
95
|
* - Added smart height estimation for unmeasured items
|
|
104
96
|
* - Optimized resize handling with debouncing
|
|
105
97
|
* - Added height recalculation on content changes
|
|
106
98
|
* - Implemented progressive height adjustments
|
|
107
99
|
*
|
|
108
|
-
*
|
|
100
|
+
* 7. Code Quality & Maintainability ✓
|
|
109
101
|
* - Extracted debug utilities for better testing
|
|
110
102
|
* - Improved type safety throughout
|
|
111
103
|
* - Added comprehensive documentation
|
|
112
104
|
* - Optimized debug output to reduce noise
|
|
113
105
|
*
|
|
114
|
-
*
|
|
106
|
+
* 8. Architecture Refactoring ✓
|
|
115
107
|
* - Extracted scroll calculation logic to scrollCalculation.ts utility
|
|
116
108
|
* - Extracted ResizeObserver utilities to resizeObserver.ts
|
|
117
109
|
* - Added comprehensive test coverage for extracted utilities
|
|
118
110
|
* - Improved separation of concerns and maintainability
|
|
119
111
|
* - Simplified initialization (removed unnecessary chunked processing)
|
|
120
112
|
*
|
|
121
|
-
*
|
|
113
|
+
* 9. Future Improvements (Planned)
|
|
122
114
|
* - Add horizontal scrolling support
|
|
123
115
|
* - Implement variable-sized item caching
|
|
124
116
|
* - Add keyboard navigation support
|
|
@@ -126,7 +118,6 @@
|
|
|
126
118
|
* - Add accessibility enhancements
|
|
127
119
|
*
|
|
128
120
|
* Technical Challenges Solved:
|
|
129
|
-
* - Bottom-to-top scrolling in flexbox layouts
|
|
130
121
|
* - Dynamic height calculations without layout thrashing
|
|
131
122
|
* - Smooth scrolling on various devices
|
|
132
123
|
* - Memory management for large lists
|
|
@@ -166,8 +157,7 @@
|
|
|
166
157
|
calculateTransformY,
|
|
167
158
|
calculateVisibleRange,
|
|
168
159
|
clampValue,
|
|
169
|
-
updateHeightAndScroll as utilsUpdateHeightAndScroll
|
|
170
|
-
getScrollOffsetForIndex
|
|
160
|
+
updateHeightAndScroll as utilsUpdateHeightAndScroll
|
|
171
161
|
} from './utils/virtualList.js'
|
|
172
162
|
import { createDebugInfo, shouldShowDebugInfo } from './utils/virtualListDebug.js'
|
|
173
163
|
import { calculateScrollTarget } from './utils/scrollCalculation.js'
|
|
@@ -178,11 +168,7 @@
|
|
|
178
168
|
|
|
179
169
|
const rafSchedule = createRafScheduler()
|
|
180
170
|
// Timing constants
|
|
181
|
-
const GLOBAL_CORRECTION_COOLDOWN_MS = 16
|
|
182
|
-
const SCROLL_IDLE_DELAY_MS = 250
|
|
183
|
-
const SUPPRESSION_WINDOW_MS = 450
|
|
184
171
|
const HEIGHT_DEBOUNCE_MS = 100
|
|
185
|
-
const lastCorrectionTimestampByViewport = new WeakMap<HTMLElement, number>()
|
|
186
172
|
// Package-specific debug flag - safe for library distribution
|
|
187
173
|
// Enable with: PUBLIC_SVELTE_VIRTUAL_LIST_DEBUG=true (preferred) or SVELTE_VIRTUAL_LIST_DEBUG=true
|
|
188
174
|
// Avoid SvelteKit-only $env imports so library works in non-Kit/Vitest contexts
|
|
@@ -191,20 +177,6 @@
|
|
|
191
177
|
(process?.env?.PUBLIC_SVELTE_VIRTUAL_LIST_DEBUG === 'true' ||
|
|
192
178
|
process?.env?.SVELTE_VIRTUAL_LIST_DEBUG === 'true')
|
|
193
179
|
)
|
|
194
|
-
// Feature flags - default off; enable via env for incremental rollout
|
|
195
|
-
const anchorModeEnabled = Boolean(
|
|
196
|
-
typeof process !== 'undefined' &&
|
|
197
|
-
(process?.env?.PUBLIC_SVL_ANCHOR_MODE === 'true' ||
|
|
198
|
-
process?.env?.SVL_ANCHOR_MODE === 'true')
|
|
199
|
-
)
|
|
200
|
-
const idleCorrectionsOnly = Boolean(
|
|
201
|
-
typeof process !== 'undefined' &&
|
|
202
|
-
(process?.env?.PUBLIC_SVL_IDLE_ONLY === 'true' || process?.env?.SVL_IDLE_ONLY === 'true')
|
|
203
|
-
)
|
|
204
|
-
const batchUpdatesEnabled = Boolean(
|
|
205
|
-
typeof process !== 'undefined' &&
|
|
206
|
-
(process?.env?.PUBLIC_SVL_BATCH === 'true' || process?.env?.SVL_BATCH === 'true')
|
|
207
|
-
)
|
|
208
180
|
/**
|
|
209
181
|
* Core configuration props with default values
|
|
210
182
|
* @type {SvelteVirtualListProps<TItem>}
|
|
@@ -219,7 +191,6 @@
|
|
|
219
191
|
contentClass, // Custom class for the content wrapper
|
|
220
192
|
itemsClass, // Custom class for the items wrapper
|
|
221
193
|
debugFunction, // Custom debug logging function
|
|
222
|
-
mode = 'topToBottom', // Scroll direction mode
|
|
223
194
|
bufferSize = 20, // Number of items to render outside visible area
|
|
224
195
|
testId, // Base test ID for component elements (undefined = no data-testid attributes)
|
|
225
196
|
onLoadMore, // Callback when more data needed (supports sync and async)
|
|
@@ -243,102 +214,7 @@
|
|
|
243
214
|
|
|
244
215
|
const isCalculatingHeight = $state(false) // Prevents concurrent height calculations
|
|
245
216
|
let isLoadingMore = $state(false) // Prevents concurrent onLoadMore calls
|
|
246
|
-
let isScrolling = $state(false) // Tracks active scrolling state
|
|
247
|
-
let scrollIdleTimer: number | null = null
|
|
248
|
-
// Anchor state (read-only capture; used when anchorModeEnabled)
|
|
249
|
-
let lastAnchorIndex = $state(0)
|
|
250
|
-
let lastAnchorOffset = $state(0) // offset within anchored item (px)
|
|
251
|
-
let pendingAnchorReconcile = $state(false)
|
|
252
|
-
let batchDepth = $state(0)
|
|
253
|
-
|
|
254
|
-
const captureAnchor = () => {
|
|
255
|
-
if (!heightManager.viewportElement) return
|
|
256
|
-
const vr = visibleItems
|
|
257
|
-
const anchorIndex = Math.max(0, vr.start)
|
|
258
|
-
const cache = heightManager.getHeightCache()
|
|
259
|
-
const est = heightManager.averageHeight
|
|
260
|
-
const maxScrollTop = Math.max(0, totalHeight - (height || 0))
|
|
261
|
-
// Offset from start to anchored item
|
|
262
|
-
const blockSums = heightManager.getBlockSums()
|
|
263
|
-
const offsetToIndex = getScrollOffsetForIndex(cache, est, anchorIndex, blockSums)
|
|
264
|
-
const currentTop = heightManager.viewport.scrollTop
|
|
265
|
-
let offsetWithin: number
|
|
266
|
-
if (mode === 'bottomToTop') {
|
|
267
|
-
// Convert distance-from-end to distance-from-start
|
|
268
|
-
const distanceFromStart = maxScrollTop - currentTop
|
|
269
|
-
offsetWithin = distanceFromStart - offsetToIndex
|
|
270
|
-
} else {
|
|
271
|
-
offsetWithin = currentTop - offsetToIndex
|
|
272
|
-
}
|
|
273
|
-
lastAnchorIndex = anchorIndex
|
|
274
|
-
lastAnchorOffset = Math.max(0, Math.round(offsetWithin))
|
|
275
|
-
// Expose for tests
|
|
276
|
-
;(heightManager.viewport as unknown as Record<string, unknown>).__svlAnchor = {
|
|
277
|
-
index: lastAnchorIndex,
|
|
278
|
-
offset: lastAnchorOffset
|
|
279
|
-
}
|
|
280
|
-
pendingAnchorReconcile = true
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const reconcileToAnchorIfEnabled = () => {
|
|
284
|
-
if (!anchorModeEnabled || !heightManager.viewportElement) return
|
|
285
|
-
if (!pendingAnchorReconcile) return
|
|
286
|
-
const cache = heightManager.getHeightCache()
|
|
287
|
-
const est = heightManager.averageHeight
|
|
288
|
-
const blockSums = heightManager.getBlockSums()
|
|
289
|
-
const offsetToIndex = getScrollOffsetForIndex(
|
|
290
|
-
cache,
|
|
291
|
-
est,
|
|
292
|
-
Math.max(0, lastAnchorIndex),
|
|
293
|
-
blockSums
|
|
294
|
-
)
|
|
295
|
-
const maxScrollTop = clampValue(totalHeight - (height || 0), 0, Infinity)
|
|
296
|
-
let targetTop: number
|
|
297
|
-
if (mode === 'bottomToTop') {
|
|
298
|
-
const distanceFromStart = clampValue(offsetToIndex + lastAnchorOffset, 0, Infinity)
|
|
299
|
-
targetTop = clampValue(Math.round(maxScrollTop - distanceFromStart), 0, maxScrollTop)
|
|
300
|
-
} else {
|
|
301
|
-
targetTop = clampValue(Math.round(offsetToIndex + lastAnchorOffset), 0, maxScrollTop)
|
|
302
|
-
}
|
|
303
|
-
if (Math.abs(heightManager.viewport.scrollTop - targetTop) >= 2) {
|
|
304
|
-
syncScrollTop(targetTop)
|
|
305
|
-
}
|
|
306
|
-
pendingAnchorReconcile = false
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Runs a batch of updates with scroll corrections coalesced until the batch completes.
|
|
311
|
-
*
|
|
312
|
-
* Use this method when making multiple changes to the items array to prevent
|
|
313
|
-
* intermediate scroll corrections. The scroll position reconciliation is deferred
|
|
314
|
-
* until the batch exits, ensuring smooth visual updates.
|
|
315
|
-
*
|
|
316
|
-
* @param {() => void} fn - The function containing batch updates to execute.
|
|
317
|
-
* @returns {void}
|
|
318
|
-
*
|
|
319
|
-
* @example
|
|
320
|
-
* ```typescript
|
|
321
|
-
* // Add multiple items without intermediate scroll corrections
|
|
322
|
-
* list.runInBatch(() => {
|
|
323
|
-
* items.push(newItem1);
|
|
324
|
-
* items.push(newItem2);
|
|
325
|
-
* items.push(newItem3);
|
|
326
|
-
* });
|
|
327
|
-
* ```
|
|
328
|
-
*/
|
|
329
|
-
export const runInBatch = (fn: () => void): void => {
|
|
330
|
-
batchDepth += 1
|
|
331
|
-
try {
|
|
332
|
-
fn()
|
|
333
|
-
} finally {
|
|
334
|
-
batchDepth = Math.max(0, batchDepth - 1)
|
|
335
|
-
if (batchUpdatesEnabled && batchDepth === 0) {
|
|
336
|
-
reconcileToAnchorIfEnabled()
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
217
|
let lastMeasuredIndex = $state(-1) // Index of last measured item
|
|
341
|
-
let lastScrollTopSnapshot = $state(0) // Previous scroll position snapshot
|
|
342
218
|
|
|
343
219
|
/**
|
|
344
220
|
* Timers and Observers
|
|
@@ -352,15 +228,12 @@
|
|
|
352
228
|
*/
|
|
353
229
|
const dirtyItems = $state(new Set<number>()) // Set of item indices that need height recalculation
|
|
354
230
|
let dirtyItemsCount = $state(0) // Reactive count of dirty items
|
|
355
|
-
// Fallback measurement used only when height has not been established yet
|
|
356
|
-
let measuredFallbackHeight = $state(0)
|
|
357
231
|
// Scroll delta threshold optimization - track last scroll position used for range calculation
|
|
358
232
|
let lastProcessedScrollTop = $state(0)
|
|
359
233
|
|
|
360
234
|
let prevVisibleRange = $state<SvelteVirtualListPreviousVisibleRange | null>(null)
|
|
361
235
|
let prevHeight = $state<number>(0)
|
|
362
236
|
let prevTotalHeightForScrollCorrection = $state<number>(0)
|
|
363
|
-
let lastBottomDistance = $state<number | null>(null)
|
|
364
237
|
|
|
365
238
|
/**
|
|
366
239
|
* Reactive Height Manager - O(1) height calculation system
|
|
@@ -401,187 +274,11 @@
|
|
|
401
274
|
heightManager.scrollTop = scrollValue
|
|
402
275
|
}
|
|
403
276
|
|
|
404
|
-
// Dynamic update coordination to avoid UA scroll anchoring interference
|
|
405
|
-
let suppressBottomAnchoringUntilMs = $state(0)
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Handles scroll position corrections when item heights change, ensuring proper positioning
|
|
409
|
-
* relative to the user's scroll context. This function calculates the cumulative impact of
|
|
410
|
-
* height changes above the current viewport and adjusts the scroll position accordingly.
|
|
411
|
-
*
|
|
412
|
-
* The correction logic considers:
|
|
413
|
-
* - Height changes occurring above the visible area (which would shift content)
|
|
414
|
-
* - The current scroll position and visible range
|
|
415
|
-
* - Whether height changes warrant a scroll adjustment
|
|
416
|
-
*
|
|
417
|
-
* This prevents jarring jumps when items resize, maintaining the user's visual context
|
|
418
|
-
* and where they are positioned relative to the current scroll position.
|
|
419
|
-
*/
|
|
420
|
-
const handleHeightChangesScrollCorrection = (
|
|
421
|
-
heightChanges: Array<{ index: number; oldHeight: number; newHeight: number; delta: number }>
|
|
422
|
-
) => {
|
|
423
|
-
if (!heightManager.viewportElement || !heightManager.initialized || userHasScrolledAway) {
|
|
424
|
-
return
|
|
425
|
-
}
|
|
426
|
-
// Coalesce adjustments during active scroll; apply on idle
|
|
427
|
-
if (isScrolling) {
|
|
428
|
-
// Accumulate net change above viewport and defer application
|
|
429
|
-
let pending = 0
|
|
430
|
-
const currentVisibleRange = visibleItems
|
|
431
|
-
for (const change of heightChanges) {
|
|
432
|
-
if (change.index < currentVisibleRange.start) pending += change.delta
|
|
433
|
-
}
|
|
434
|
-
if (pending !== 0) {
|
|
435
|
-
// Store on the viewport element to avoid extra module globals
|
|
436
|
-
const key = '__svl_pendingHeightAdj__' as unknown as keyof HTMLElement
|
|
437
|
-
const prev = (heightManager.viewport as unknown as Record<string, number>)[
|
|
438
|
-
key as string
|
|
439
|
-
] as number | undefined
|
|
440
|
-
;(heightManager.viewport as unknown as Record<string, number>)[key as string] =
|
|
441
|
-
(prev ?? 0) + pending
|
|
442
|
-
}
|
|
443
|
-
return
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* CRITICAL: BottomToTop Mode Height Change Fix
|
|
448
|
-
* ============================================
|
|
449
|
-
*
|
|
450
|
-
* Problem: In bottomToTop mode, when items change height while user is at bottom,
|
|
451
|
-
* the list would jump to middle positions (e.g. items 1032-1096) instead of
|
|
452
|
-
* staying anchored at bottom showing Item 0.
|
|
453
|
-
*
|
|
454
|
-
* Root Cause: Height calculations using simple averages (items.length * calculatedItemHeight)
|
|
455
|
-
* were drastically skewed by single item changes. Example:
|
|
456
|
-
* - 1 item changes from 20px to 100px (+80px actual change)
|
|
457
|
-
* - Average jumps from 20px to 22.35px (+2.35px per item)
|
|
458
|
-
* - Across 10,000 items: 2.35px × 10,000 = 23,500px total height error!
|
|
459
|
-
* - This caused massive scroll position overshoots and incorrect positioning
|
|
460
|
-
*
|
|
461
|
-
* Solution: Two-step native scrollIntoView approach
|
|
462
|
-
* 1. Fixed skewed height calculations using actual heightCache measurements (see totalHeight)
|
|
463
|
-
* 2. When wasAtBottomBeforeHeightChange=true (captured before any height processing):
|
|
464
|
-
* a) First scroll to approximate bottom position to render Item 0 in virtual viewport
|
|
465
|
-
* b) Use native scrollIntoView() with block:'end' for precise bottom alignment
|
|
466
|
-
*
|
|
467
|
-
* Why This Works:
|
|
468
|
-
* - Uses browser's native scroll logic instead of error-prone manual calculations
|
|
469
|
-
* - Two-step ensures Item 0 exists in DOM before attempting to scroll to it
|
|
470
|
-
* - Native scrollIntoView handles all edge cases (subpixel precision, browser differences)
|
|
471
|
-
* - Eliminates complex math that was accumulating rounding errors
|
|
472
|
-
* - Smooth behavior provides better UX than instant jumps
|
|
473
|
-
*
|
|
474
|
-
* Dependencies:
|
|
475
|
-
* - wasAtBottomBeforeHeightChange: Set to true when first item marked dirty, prevents cascading corrections
|
|
476
|
-
* - totalHeight: Uses actual heightCache measurements instead of skewed averages
|
|
477
|
-
* - Aggressive scroll correction: Blocked when wasAtBottomBeforeHeightChange=true
|
|
478
|
-
*
|
|
479
|
-
* ⚠️ DO NOT MODIFY WITHOUT EXTENSIVE TESTING ⚠️
|
|
480
|
-
* This fix resolves a complex interaction between:
|
|
481
|
-
* - Virtual list rendering (only ~20 items visible, rest virtualized)
|
|
482
|
-
* - Height change calculations (prone to average skewing with large datasets)
|
|
483
|
-
* - Multiple scroll correction mechanisms (specific vs aggressive)
|
|
484
|
-
* - Bottom anchor positioning in reversed list mode (bottomToTop)
|
|
485
|
-
*
|
|
486
|
-
* Test coverage: tests/bottomToTop/firstItemHeightChange.spec.ts (45 comprehensive tests)
|
|
487
|
-
* Related fixes: See aggressive scroll correction logic ~line 410 with !wasAtBottomBeforeHeightChange
|
|
488
|
-
*/
|
|
489
|
-
if (
|
|
490
|
-
mode === 'bottomToTop' &&
|
|
491
|
-
wasAtBottomBeforeHeightChange &&
|
|
492
|
-
!programmaticScrollInProgress &&
|
|
493
|
-
performance.now() >= suppressBottomAnchoringUntilMs
|
|
494
|
-
) {
|
|
495
|
-
// Prevent same-frame corrections; defer if this viewport just corrected
|
|
496
|
-
const now = performance.now()
|
|
497
|
-
const viewportEl = heightManager.viewport
|
|
498
|
-
const lastCorrectionMs = lastCorrectionTimestampByViewport.get(viewportEl) ?? 0
|
|
499
|
-
if (now - lastCorrectionMs < GLOBAL_CORRECTION_COOLDOWN_MS) {
|
|
500
|
-
suppressBottomAnchoringUntilMs = now + 50
|
|
501
|
-
return
|
|
502
|
-
}
|
|
503
|
-
lastCorrectionTimestampByViewport.set(viewportEl, now)
|
|
504
|
-
|
|
505
|
-
// Step 1: Scroll to approximate position to ensure Item 0 gets rendered in virtual viewport
|
|
506
|
-
const approximateScrollTop = Math.max(0, totalHeight - height)
|
|
507
|
-
log('[SVL] b2t-correction-approx', { approximateScrollTop })
|
|
508
|
-
syncScrollTop(approximateScrollTop)
|
|
509
|
-
|
|
510
|
-
// Step 2: Use native scrollIntoView for precise bottom-edge positioning after DOM updates
|
|
511
|
-
tick().then(() => {
|
|
512
|
-
const item0Element = heightManager.viewport.querySelector(
|
|
513
|
-
'[data-original-index="0"]'
|
|
514
|
-
)
|
|
515
|
-
if (item0Element) {
|
|
516
|
-
// Verify alignment via rects; if off, perform one-time scrollIntoView
|
|
517
|
-
const contRect = heightManager.viewport.getBoundingClientRect()
|
|
518
|
-
const itemRect = (item0Element as HTMLElement).getBoundingClientRect()
|
|
519
|
-
const tol = 4
|
|
520
|
-
const aligned =
|
|
521
|
-
Math.abs(contRect.y + contRect.height - (itemRect.y + itemRect.height)) <=
|
|
522
|
-
tol
|
|
523
|
-
if (!aligned) {
|
|
524
|
-
// Use manual scrollTop instead of scrollIntoView to prevent parent scroll
|
|
525
|
-
// (scrollIntoView scrolls all ancestor containers, not just the viewport)
|
|
526
|
-
// Note: `container: 'nearest'` option could replace this once browser support improves
|
|
527
|
-
const currentScrollTop = heightManager.viewport.scrollTop
|
|
528
|
-
const offset = itemRect.bottom - contRect.bottom
|
|
529
|
-
syncScrollTop(currentScrollTop + offset)
|
|
530
|
-
log('[SVL] b2t-correction-manual', { offset })
|
|
531
|
-
} else {
|
|
532
|
-
// Sync our internal scroll state with actual DOM position
|
|
533
|
-
heightManager.scrollTop = heightManager.viewport.scrollTop
|
|
534
|
-
}
|
|
535
|
-
// After peer correction, delay further corrections briefly
|
|
536
|
-
suppressBottomAnchoringUntilMs = performance.now() + 200
|
|
537
|
-
}
|
|
538
|
-
})
|
|
539
|
-
|
|
540
|
-
return // Skip remaining scroll correction logic - we've handled bottomToTop case
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
const currentScrollTop = heightManager.viewport.scrollTop
|
|
544
|
-
const maxScrollTop = Math.max(0, totalHeight - height)
|
|
545
|
-
|
|
546
|
-
// Calculate total height change impact above current visible area
|
|
547
|
-
let heightChangeAboveViewport = 0
|
|
548
|
-
const currentVisibleRange = visibleItems
|
|
549
|
-
|
|
550
|
-
for (const change of heightChanges) {
|
|
551
|
-
// Only consider items that are above the current visible range
|
|
552
|
-
if (change.index < currentVisibleRange.start) {
|
|
553
|
-
heightChangeAboveViewport += change.delta
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// If there are height changes above the viewport, adjust scroll to maintain position
|
|
558
|
-
// Include any pending coalesced delta (when scrolling)
|
|
559
|
-
{
|
|
560
|
-
const key = '__svl_pendingHeightAdj__' as unknown as keyof HTMLElement
|
|
561
|
-
const pending =
|
|
562
|
-
(heightManager.viewport as unknown as Record<string, number>)[key as string] ?? 0
|
|
563
|
-
if (pending) {
|
|
564
|
-
heightChangeAboveViewport += pending
|
|
565
|
-
;(heightManager.viewport as unknown as Record<string, number>)[key as string] = 0
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
if (Math.abs(heightChangeAboveViewport) > 2) {
|
|
569
|
-
const newScrollTop = clampValue(
|
|
570
|
-
currentScrollTop + heightChangeAboveViewport,
|
|
571
|
-
0,
|
|
572
|
-
maxScrollTop
|
|
573
|
-
)
|
|
574
|
-
syncScrollTop(newScrollTop)
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
277
|
// Height update function - removed throttling to fix race condition on initial load
|
|
579
278
|
// Create throttled height update function with trailing execution to ensure measurement always happens
|
|
580
279
|
const triggerHeightUpdate = createAdvancedThrottledCallback(
|
|
581
280
|
() => {
|
|
582
281
|
if (BROWSER && dirtyItemsCount > 0) {
|
|
583
|
-
// Capture bottom state before any height processing to prevent cascading corrections
|
|
584
|
-
wasAtBottomBeforeHeightChange = atBottom
|
|
585
282
|
heightManager.startDynamicUpdate()
|
|
586
283
|
updateHeight()
|
|
587
284
|
}
|
|
@@ -601,14 +298,11 @@
|
|
|
601
298
|
// Keep height manager synchronized with items length
|
|
602
299
|
$effect(() => {
|
|
603
300
|
heightManager.updateItemLength(items.length)
|
|
604
|
-
stabilizedContentHeight = 0
|
|
605
301
|
})
|
|
606
302
|
|
|
607
303
|
// Infinite scroll: trigger onLoadMore when approaching end of list
|
|
608
304
|
$effect(() => {
|
|
609
305
|
if (!BROWSER || !onLoadMore || !hasMore || isLoadingMore) return
|
|
610
|
-
// Skip loading during bottomToTop initialization (init path renders all items artificially)
|
|
611
|
-
if (mode === 'bottomToTop' && !bottomToTopScrollComplete) return
|
|
612
306
|
|
|
613
307
|
const range = visibleItems
|
|
614
308
|
const atLoadingEdge = range.end >= items.length - loadMoreThreshold
|
|
@@ -623,7 +317,7 @@
|
|
|
623
317
|
})
|
|
624
318
|
|
|
625
319
|
const updateHeight = () => {
|
|
626
|
-
// Capture previous total height for scroll correction
|
|
320
|
+
// Capture previous total height for scroll correction.
|
|
627
321
|
prevTotalHeightForScrollCorrection = heightManager.totalHeight
|
|
628
322
|
heightUpdateTimeout = calculateAverageHeightDebounced(
|
|
629
323
|
isCalculatingHeight,
|
|
@@ -650,15 +344,8 @@
|
|
|
650
344
|
heightManager.processDirtyHeights(result.heightChanges)
|
|
651
345
|
}
|
|
652
346
|
|
|
653
|
-
//
|
|
654
|
-
if (
|
|
655
|
-
// Run correction after dynamic update finishes to avoid blocking conditions
|
|
656
|
-
const changes = result.heightChanges
|
|
657
|
-
queueMicrotask(() => handleHeightChangesScrollCorrection(changes))
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// TopToBottom: maintain bottom anchoring when total height changes
|
|
661
|
-
if (mode === 'topToBottom' && heightManager.isReady && heightManager.initialized) {
|
|
347
|
+
// Keep the end item visually stable when total height changes at the end.
|
|
348
|
+
if (heightManager.isReady && heightManager.initialized) {
|
|
662
349
|
const oldTotal = prevTotalHeightForScrollCorrection
|
|
663
350
|
const newTotal = heightManager.totalHeight
|
|
664
351
|
const deltaTotal = newTotal - oldTotal
|
|
@@ -669,7 +356,7 @@
|
|
|
669
356
|
const currentScrollTop = heightManager.viewport.scrollTop
|
|
670
357
|
const isAtBottom = Math.abs(currentScrollTop - maxScrollTop) <= tolerance
|
|
671
358
|
if (isAtBottom) {
|
|
672
|
-
// Adjust scrollTop by total height delta to
|
|
359
|
+
// Adjust scrollTop by total height delta to keep the same end position.
|
|
673
360
|
const adjusted = clampValue(
|
|
674
361
|
currentScrollTop + deltaTotal,
|
|
675
362
|
0,
|
|
@@ -685,38 +372,21 @@
|
|
|
685
372
|
// Clear processed dirty items (all dirty items were processed)
|
|
686
373
|
dirtyItems.clear()
|
|
687
374
|
dirtyItemsCount = 0
|
|
688
|
-
|
|
689
|
-
// Reset bottom state flag
|
|
690
|
-
wasAtBottomBeforeHeightChange = false
|
|
691
375
|
})
|
|
692
376
|
heightManager.endDynamicUpdate()
|
|
693
377
|
},
|
|
694
378
|
lastMeasuredIndex < 0 || dirtyItems.size > 0 ? 0 : HEIGHT_DEBOUNCE_MS,
|
|
695
379
|
dirtyItems, // Pass dirty items for processing
|
|
696
380
|
0, // Don't pass ReactiveListManager state - let each system manage its own totals
|
|
697
|
-
0
|
|
698
|
-
mode // Pass mode for correct element indexing
|
|
381
|
+
0 // Don't pass ReactiveListManager state - let each system manage its own totals
|
|
699
382
|
)
|
|
700
383
|
}
|
|
701
384
|
|
|
702
|
-
// Add new effect to handle height changes
|
|
703
|
-
// Track if user has scrolled away from bottom to prevent snap-back
|
|
704
|
-
let userHasScrolledAway = $state(false)
|
|
705
|
-
let programmaticScrollInProgress = $state(false) // Prevent bottom-anchoring during programmatic scrolls
|
|
706
|
-
let lastCalculatedHeight = $state(0)
|
|
707
|
-
let lastItemsLength = $state(0)
|
|
708
|
-
// Track last observed total height to compute precise deltas on item count changes
|
|
709
|
-
let lastTotalHeightObserved = $state(0)
|
|
710
|
-
// For bottomToTop mode: keep init path active until scroll positioning is complete
|
|
711
|
-
// This ensures Item 0 stays in the DOM throughout initialization
|
|
712
|
-
let bottomToTopScrollComplete = $state(false)
|
|
713
|
-
|
|
714
385
|
/**
|
|
715
386
|
* CRITICAL: O(1) Reactive Total Height Calculation
|
|
716
387
|
* ===============================================
|
|
717
388
|
*
|
|
718
389
|
* Uses ReactiveListManager for O(1) height calculations instead of O(n) loops.
|
|
719
|
-
* This fixes the root cause of massive scroll jumps in bottomToTop mode.
|
|
720
390
|
*
|
|
721
391
|
* Problem with Previous O(n) Approach:
|
|
722
392
|
* - Looped through ALL items on every reactive update
|
|
@@ -740,12 +410,10 @@
|
|
|
740
410
|
* - Updates incrementally using processDirtyHeights() instead of recalculating all
|
|
741
411
|
*
|
|
742
412
|
* This getter is reactive and updates whenever heightManager's internal state changes.
|
|
743
|
-
* Used by
|
|
413
|
+
* Used by scroll corrections and maxScrollTop calculations.
|
|
744
414
|
*/
|
|
745
415
|
const totalHeight = $derived(heightManager.totalHeight)
|
|
746
416
|
|
|
747
|
-
const atBottom = $derived(heightManager.scrollTop >= totalHeight - height - 1)
|
|
748
|
-
let wasAtBottomBeforeHeightChange = false
|
|
749
417
|
let lastVisibleRange: SvelteVirtualListPreviousVisibleRange | null = null
|
|
750
418
|
|
|
751
419
|
function updateDebugTailDistance() {
|
|
@@ -756,174 +424,14 @@
|
|
|
756
424
|
if (!last) return
|
|
757
425
|
const v = heightManager.viewport.getBoundingClientRect()
|
|
758
426
|
const r = last.getBoundingClientRect()
|
|
759
|
-
|
|
427
|
+
const bottomDistance = Math.round(Math.abs(r.bottom - v.bottom))
|
|
760
428
|
if (INTERNAL_DEBUG) {
|
|
761
|
-
console.info('[SVL] bottomDistance(px):',
|
|
429
|
+
console.info('[SVL] bottomDistance(px):', bottomDistance)
|
|
762
430
|
}
|
|
763
431
|
}
|
|
764
432
|
|
|
765
433
|
// no UI export; rely on console logs when debug=true
|
|
766
434
|
|
|
767
|
-
// $inspect('scrollState: atTop', atTop)
|
|
768
|
-
// $inspect('scrollState: atBottom', atBottom)
|
|
769
|
-
|
|
770
|
-
$effect(() => {
|
|
771
|
-
if (
|
|
772
|
-
BROWSER &&
|
|
773
|
-
heightManager.initialized &&
|
|
774
|
-
mode === 'bottomToTop' &&
|
|
775
|
-
heightManager.viewportElement
|
|
776
|
-
) {
|
|
777
|
-
const targetScrollTop = Math.max(0, totalHeight - height)
|
|
778
|
-
const currentScrollTop = heightManager.viewport.scrollTop
|
|
779
|
-
const scrollDifference = Math.abs(currentScrollTop - targetScrollTop)
|
|
780
|
-
|
|
781
|
-
// Only correct scroll if:
|
|
782
|
-
// 1. Item height changed significantly (not just user scrolling)
|
|
783
|
-
// 2. User hasn't intentionally scrolled away from bottom
|
|
784
|
-
// 3. We're significantly off target
|
|
785
|
-
// 4. We're not at the bottom (where height changes should be handled more carefully)
|
|
786
|
-
const heightChanged = Math.abs(heightManager.averageHeight - lastCalculatedHeight) > 1
|
|
787
|
-
const maxScrollTop = Math.max(0, totalHeight - height)
|
|
788
|
-
|
|
789
|
-
// In bottomToTop mode, we're "at bottom" when scroll is at max position
|
|
790
|
-
const isAtBottom =
|
|
791
|
-
Math.abs(currentScrollTop - maxScrollTop) < heightManager.averageHeight
|
|
792
|
-
const shouldCorrect =
|
|
793
|
-
heightChanged &&
|
|
794
|
-
!userHasScrolledAway &&
|
|
795
|
-
!isAtBottom && // Don't apply aggressive correction when at bottom
|
|
796
|
-
!isScrolling && // Skip aggressive corrections during active scroll
|
|
797
|
-
!programmaticScrollInProgress && // Don't interfere with programmatic scrolls
|
|
798
|
-
performance.now() >= suppressBottomAnchoringUntilMs &&
|
|
799
|
-
!heightManager.isDynamicUpdateInProgress &&
|
|
800
|
-
scrollDifference > heightManager.averageHeight * 3
|
|
801
|
-
|
|
802
|
-
if (shouldCorrect) {
|
|
803
|
-
// Round to avoid subpixel positioning issues in bottomToTop mode
|
|
804
|
-
syncScrollTop(targetScrollTop, true)
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// Track if user has scrolled significantly away from bottom
|
|
808
|
-
if (scrollDifference > heightManager.averageHeight * 5) {
|
|
809
|
-
userHasScrolledAway = true
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
lastCalculatedHeight = heightManager.averageHeight
|
|
813
|
-
}
|
|
814
|
-
})
|
|
815
|
-
|
|
816
|
-
// Handle items being added/removed in bottomToTop mode
|
|
817
|
-
$effect(() => {
|
|
818
|
-
// Only track items.length to prevent re-runs on other reactive changes
|
|
819
|
-
const currentItemsLength = items.length
|
|
820
|
-
|
|
821
|
-
if (
|
|
822
|
-
BROWSER &&
|
|
823
|
-
heightManager.initialized &&
|
|
824
|
-
mode === 'bottomToTop' &&
|
|
825
|
-
heightManager.isReady &&
|
|
826
|
-
lastItemsLength > 0
|
|
827
|
-
) {
|
|
828
|
-
const itemsAdded = currentItemsLength - lastItemsLength
|
|
829
|
-
|
|
830
|
-
if (itemsAdded !== 0) {
|
|
831
|
-
// Capture all reactive values immediately to prevent re-triggering
|
|
832
|
-
const currentScrollTop = heightManager.viewport.scrollTop
|
|
833
|
-
const currentCalculatedItemHeight = heightManager.averageHeight
|
|
834
|
-
const currentHeight = height
|
|
835
|
-
const currentTotalHeight = totalHeight
|
|
836
|
-
const prevTotalHeight =
|
|
837
|
-
lastTotalHeightObserved ||
|
|
838
|
-
currentTotalHeight - itemsAdded * currentCalculatedItemHeight
|
|
839
|
-
const prevMaxScrollTop = Math.max(0, prevTotalHeight - currentHeight)
|
|
840
|
-
const nextMaxScrollTop = Math.max(0, currentTotalHeight - currentHeight)
|
|
841
|
-
const deltaMax = nextMaxScrollTop - prevMaxScrollTop
|
|
842
|
-
log('[SVL] items-length-change:before', {
|
|
843
|
-
instanceId,
|
|
844
|
-
itemsAdded,
|
|
845
|
-
lastItemsLength,
|
|
846
|
-
currentItemsLength,
|
|
847
|
-
currentScrollTop,
|
|
848
|
-
prevTotalHeight,
|
|
849
|
-
currentTotalHeight,
|
|
850
|
-
prevMaxScrollTop,
|
|
851
|
-
nextMaxScrollTop,
|
|
852
|
-
deltaMax,
|
|
853
|
-
averageItemHeight: currentCalculatedItemHeight
|
|
854
|
-
})
|
|
855
|
-
|
|
856
|
-
// Maintain visual position for ALL cases by advancing scrollTop by deltaMax.
|
|
857
|
-
// If near the bottom, this naturally pins to the new max; otherwise it preserves the current content.
|
|
858
|
-
programmaticScrollInProgress = true
|
|
859
|
-
void heightManager.runDynamicUpdate(() => {
|
|
860
|
-
const newScrollTop = clampValue(
|
|
861
|
-
currentScrollTop + deltaMax,
|
|
862
|
-
0,
|
|
863
|
-
nextMaxScrollTop
|
|
864
|
-
)
|
|
865
|
-
syncScrollTop(newScrollTop)
|
|
866
|
-
log('[SVL] items-length-change:applied', {
|
|
867
|
-
instanceId,
|
|
868
|
-
previousScrollTop: currentScrollTop,
|
|
869
|
-
appliedScrollTop: newScrollTop,
|
|
870
|
-
prevMaxScrollTop,
|
|
871
|
-
nextMaxScrollTop,
|
|
872
|
-
deltaMax
|
|
873
|
-
})
|
|
874
|
-
|
|
875
|
-
// We are explicitly managing position; consider this a programmatic action.
|
|
876
|
-
// Do not flip userHasScrolledAway here; it should reflect user intent only.
|
|
877
|
-
|
|
878
|
-
// Reconcile on next frame in case measured heights adjust totals
|
|
879
|
-
requestAnimationFrame(() => {
|
|
880
|
-
const beforeReconcileScrollTop = heightManager.viewport.scrollTop
|
|
881
|
-
const reconciledNextMax = clampValue(totalHeight - height, 0, Infinity)
|
|
882
|
-
const reconciledDeltaMaxChange = reconciledNextMax - nextMaxScrollTop
|
|
883
|
-
// Desired position is to maintain distance-from-end; equivalently keep (max - scrollTop) constant.
|
|
884
|
-
const desiredScrollTop = clampValue(
|
|
885
|
-
newScrollTop + reconciledDeltaMaxChange,
|
|
886
|
-
0,
|
|
887
|
-
reconciledNextMax
|
|
888
|
-
)
|
|
889
|
-
// Snap to integer pixels to prevent oscillation due to subpixel rounding
|
|
890
|
-
const desiredRounded = Math.round(desiredScrollTop)
|
|
891
|
-
const diffToDesired = desiredRounded - heightManager.viewport.scrollTop
|
|
892
|
-
if (Math.abs(diffToDesired) >= 2) {
|
|
893
|
-
const adjusted = clampValue(desiredRounded, 0, reconciledNextMax)
|
|
894
|
-
syncScrollTop(adjusted)
|
|
895
|
-
log('[SVL] items-length-change:reconciled', {
|
|
896
|
-
instanceId,
|
|
897
|
-
beforeReconcileScrollTop,
|
|
898
|
-
adjustedScrollTop: adjusted,
|
|
899
|
-
reconciledNextMax,
|
|
900
|
-
reconciledDeltaMaxChange,
|
|
901
|
-
desiredScrollTop,
|
|
902
|
-
desiredRounded,
|
|
903
|
-
diffToDesired
|
|
904
|
-
})
|
|
905
|
-
} else {
|
|
906
|
-
log('[SVL] items-length-change:reconciled-skip', {
|
|
907
|
-
instanceId,
|
|
908
|
-
beforeReconcileScrollTop,
|
|
909
|
-
reconciledNextMax,
|
|
910
|
-
reconciledDeltaMaxChange,
|
|
911
|
-
desiredScrollTop,
|
|
912
|
-
desiredRounded,
|
|
913
|
-
diffToDesired
|
|
914
|
-
})
|
|
915
|
-
}
|
|
916
|
-
programmaticScrollInProgress = false
|
|
917
|
-
})
|
|
918
|
-
})
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
lastItemsLength = currentItemsLength
|
|
923
|
-
// Update last observed total height at the end of the effect
|
|
924
|
-
lastTotalHeightObserved = totalHeight
|
|
925
|
-
})
|
|
926
|
-
|
|
927
435
|
// Update container height continuously to reflect layout changes that
|
|
928
436
|
// may occur outside ResizeObserver timing (keeps buffers correct across engines)
|
|
929
437
|
$effect(() => {
|
|
@@ -933,17 +441,6 @@
|
|
|
933
441
|
}
|
|
934
442
|
})
|
|
935
443
|
|
|
936
|
-
// One-time fallback measurement when height hasn't been established yet
|
|
937
|
-
|
|
938
|
-
// Provide a one-time synchronous measurement only when height is still 0,
|
|
939
|
-
// to avoid DOM reads inside render-time expressions.
|
|
940
|
-
$effect(() => {
|
|
941
|
-
if (BROWSER && height === 0 && heightManager.isReady) {
|
|
942
|
-
const h = heightManager.container.getBoundingClientRect().height
|
|
943
|
-
if (Number.isFinite(h) && h > 0) measuredFallbackHeight = h
|
|
944
|
-
}
|
|
945
|
-
})
|
|
946
|
-
|
|
947
444
|
/**
|
|
948
445
|
* Calculates the range of items that should be rendered based on current scroll position.
|
|
949
446
|
*
|
|
@@ -953,7 +450,6 @@
|
|
|
953
450
|
* - Viewport height
|
|
954
451
|
* - Item height estimates
|
|
955
452
|
* - Buffer size
|
|
956
|
-
* - Scroll direction mode
|
|
957
453
|
*
|
|
958
454
|
* @example
|
|
959
455
|
* ```typescript
|
|
@@ -967,29 +463,11 @@
|
|
|
967
463
|
if (!items.length) return { start: 0, end: 0 } as SvelteVirtualListPreviousVisibleRange
|
|
968
464
|
const viewportHeight = height || 0
|
|
969
465
|
|
|
970
|
-
// For bottomToTop mode, always render items starting from index 0 during initialization
|
|
971
|
-
// This ensures Item 0 is in the DOM so we can use scrollIntoView for precise positioning
|
|
972
|
-
// The scrollIntoView in updateHeightAndScroll will handle correct alignment after heights are measured
|
|
973
|
-
// Use bottomToTopScrollComplete (not just initialized) to keep init path active until scroll is done
|
|
974
|
-
if (mode === 'bottomToTop' && !bottomToTopScrollComplete) {
|
|
975
|
-
// Use a reasonable default if viewport height isn't measured yet
|
|
976
|
-
const effectiveViewport = viewportHeight || 400
|
|
977
|
-
const visibleCount = Math.ceil(effectiveViewport / heightManager.averageHeight) + 1
|
|
978
|
-
lastVisibleRange = {
|
|
979
|
-
start: 0,
|
|
980
|
-
end: Math.min(items.length, visibleCount + bufferSize * 2)
|
|
981
|
-
} as SvelteVirtualListPreviousVisibleRange
|
|
982
|
-
|
|
983
|
-
return lastVisibleRange
|
|
984
|
-
}
|
|
985
|
-
|
|
986
466
|
// Scroll delta threshold optimization: skip recalculation if scroll delta is less than
|
|
987
467
|
// half the average item height and we have a cached range. This reduces unnecessary
|
|
988
468
|
// calculations during smooth scrolling.
|
|
989
|
-
// Note: Only applied in topToBottom mode - bottomToTop has complex scroll correction
|
|
990
|
-
// logic that requires precise visible range calculations.
|
|
991
469
|
// Note: We use lastProcessedScrollTop read-only here; it's updated in the scroll handler
|
|
992
|
-
|
|
470
|
+
{
|
|
993
471
|
const scrollDelta = Math.abs(heightManager.scrollTop - lastProcessedScrollTop)
|
|
994
472
|
const threshold = heightManager.averageHeight * 0.5
|
|
995
473
|
if (lastVisibleRange && scrollDelta < threshold && scrollDelta > 0) {
|
|
@@ -1004,7 +482,6 @@
|
|
|
1004
482
|
itemHeight: heightManager.averageHeight,
|
|
1005
483
|
totalItems: items.length,
|
|
1006
484
|
bufferSize,
|
|
1007
|
-
mode,
|
|
1008
485
|
totalContentHeight: totalHeight,
|
|
1009
486
|
heightCache: heightManager.getHeightCache()
|
|
1010
487
|
})
|
|
@@ -1016,74 +493,34 @@
|
|
|
1016
493
|
* Computed content height for the virtual list.
|
|
1017
494
|
* Uses the maximum of container height and total content height to ensure
|
|
1018
495
|
* proper scrolling behavior.
|
|
1019
|
-
*
|
|
1020
|
-
* In bottomToTop mode during active scroll, contentHeight is "ratcheted" —
|
|
1021
|
-
* it can grow but never shrink. This prevents a feedback loop where
|
|
1022
|
-
* averageHeight oscillation causes scrollHeight to bounce, triggering
|
|
1023
|
-
* browser scrollTop adjustments that fire new scroll events.
|
|
1024
|
-
* When scrolling stops (isScrolling goes false), it snaps to the true value.
|
|
1025
496
|
*/
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
const contentHeight = $derived.by(() => {
|
|
1029
|
-
const raw = Math.max(height, totalHeight)
|
|
1030
|
-
|
|
1031
|
-
if (mode !== 'bottomToTop' || !isScrolling) {
|
|
1032
|
-
stabilizedContentHeight = raw
|
|
1033
|
-
return raw
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
// During active scroll in bottomToTop: only allow growth (ratchet)
|
|
1037
|
-
// Prevents shrink → scrollTop adjust → new scroll event feedback loop
|
|
1038
|
-
if (raw > stabilizedContentHeight) {
|
|
1039
|
-
stabilizedContentHeight = raw
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
return stabilizedContentHeight
|
|
1043
|
-
})
|
|
497
|
+
const contentHeight = $derived(Math.max(height, totalHeight))
|
|
1044
498
|
|
|
1045
499
|
/**
|
|
1046
500
|
* Computed transform Y value for positioning the visible items.
|
|
1047
501
|
* Extracted from inline IIFE for better performance and readability.
|
|
1048
502
|
*/
|
|
1049
503
|
const transformY = $derived.by(() => {
|
|
1050
|
-
const viewportHeight = height || measuredFallbackHeight || 0
|
|
1051
504
|
const visibleRange = visibleItems
|
|
1052
505
|
|
|
1053
|
-
// Avoid synchronous DOM reads here; fall back once if height is 0
|
|
1054
|
-
const effectiveHeight = viewportHeight === 0 ? 400 : viewportHeight
|
|
1055
|
-
|
|
1056
506
|
// Use precise offset using measured heights when available.
|
|
1057
|
-
// For bottomToTop, pass ratcheted contentHeight so the transform stays
|
|
1058
|
-
// stable while scrollHeight is stabilized (prevents visual shift).
|
|
1059
507
|
return Math.round(
|
|
1060
508
|
calculateTransformY(
|
|
1061
|
-
mode,
|
|
1062
509
|
items.length,
|
|
1063
|
-
visibleRange.end,
|
|
1064
510
|
visibleRange.start,
|
|
1065
511
|
heightManager.averageHeight,
|
|
1066
|
-
|
|
1067
|
-
mode === 'bottomToTop' ? contentHeight : totalHeight,
|
|
1068
|
-
heightManager.getHeightCache(),
|
|
1069
|
-
measuredFallbackHeight
|
|
512
|
+
heightManager.getHeightCache()
|
|
1070
513
|
)
|
|
1071
514
|
)
|
|
1072
515
|
})
|
|
1073
516
|
|
|
1074
517
|
const displayItems = $derived.by(() => {
|
|
1075
518
|
const visibleRange = visibleItems
|
|
1076
|
-
const slice =
|
|
1077
|
-
mode === 'bottomToTop'
|
|
1078
|
-
? items.slice(visibleRange.start, visibleRange.end).reverse()
|
|
1079
|
-
: items.slice(visibleRange.start, visibleRange.end)
|
|
519
|
+
const slice = items.slice(visibleRange.start, visibleRange.end)
|
|
1080
520
|
|
|
1081
521
|
return slice.map((item, sliceIndex) => ({
|
|
1082
522
|
item,
|
|
1083
|
-
originalIndex:
|
|
1084
|
-
mode === 'bottomToTop'
|
|
1085
|
-
? visibleRange.end - 1 - sliceIndex
|
|
1086
|
-
: visibleRange.start + sliceIndex,
|
|
523
|
+
originalIndex: visibleRange.start + sliceIndex,
|
|
1087
524
|
sliceIndex
|
|
1088
525
|
}))
|
|
1089
526
|
})
|
|
@@ -1096,8 +533,7 @@
|
|
|
1096
533
|
* smooth scrolling performance.
|
|
1097
534
|
*
|
|
1098
535
|
* Implementation details:
|
|
1099
|
-
* -
|
|
1100
|
-
* - Updates scrollTop state only when scrolling has settled
|
|
536
|
+
* - Updates scrollTop state through RAF scheduling
|
|
1101
537
|
* - Browser-only functionality
|
|
1102
538
|
*
|
|
1103
539
|
* @example
|
|
@@ -1112,31 +548,8 @@
|
|
|
1112
548
|
const handleScroll = () => {
|
|
1113
549
|
if (!BROWSER || !heightManager.viewportElement) return
|
|
1114
550
|
|
|
1115
|
-
// Mark active scrolling and debounce idle transition (~120ms)
|
|
1116
|
-
isScrolling = true
|
|
1117
|
-
if (scrollIdleTimer) {
|
|
1118
|
-
clearTimeout(scrollIdleTimer)
|
|
1119
|
-
scrollIdleTimer = null
|
|
1120
|
-
}
|
|
1121
|
-
scrollIdleTimer = window.setTimeout(() => {
|
|
1122
|
-
isScrolling = false
|
|
1123
|
-
// Apply deferred anchor correction on idle
|
|
1124
|
-
if (idleCorrectionsOnly || anchorModeEnabled) {
|
|
1125
|
-
reconcileToAnchorIfEnabled()
|
|
1126
|
-
}
|
|
1127
|
-
}, SCROLL_IDLE_DELAY_MS)
|
|
1128
|
-
|
|
1129
551
|
rafSchedule(() => {
|
|
1130
552
|
const current = heightManager.viewport.scrollTop
|
|
1131
|
-
if (mode === 'bottomToTop') {
|
|
1132
|
-
const delta = lastScrollTopSnapshot - current
|
|
1133
|
-
if (delta > 0.5) {
|
|
1134
|
-
// Widen suppression to avoid fighting peer instance corrections
|
|
1135
|
-
suppressBottomAnchoringUntilMs = performance.now() + SUPPRESSION_WINDOW_MS
|
|
1136
|
-
userHasScrolledAway = true
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
lastScrollTopSnapshot = current
|
|
1140
553
|
heightManager.scrollTop = current
|
|
1141
554
|
// Update last processed scroll position for delta threshold optimization
|
|
1142
555
|
// Only update when we actually process a scroll (i.e., recalculate visible range)
|
|
@@ -1146,13 +559,9 @@
|
|
|
1146
559
|
lastProcessedScrollTop = current
|
|
1147
560
|
}
|
|
1148
561
|
updateDebugTailDistance()
|
|
1149
|
-
if (anchorModeEnabled) {
|
|
1150
|
-
captureAnchor()
|
|
1151
|
-
}
|
|
1152
562
|
if (INTERNAL_DEBUG) {
|
|
1153
563
|
const vr = visibleItems
|
|
1154
564
|
log('[SVL] scroll', {
|
|
1155
|
-
mode,
|
|
1156
565
|
scrollTop: heightManager.scrollTop,
|
|
1157
566
|
height,
|
|
1158
567
|
totalHeight: totalHeight,
|
|
@@ -1160,109 +569,23 @@
|
|
|
1160
569
|
visibleRange: vr
|
|
1161
570
|
})
|
|
1162
571
|
}
|
|
1163
|
-
// isScrolling cleared by idle timer
|
|
1164
572
|
})
|
|
1165
573
|
}
|
|
1166
574
|
|
|
1167
575
|
/**
|
|
1168
576
|
* Updates the height and scroll position of the virtual list.
|
|
1169
577
|
*
|
|
1170
|
-
* This function handles two scenarios:
|
|
1171
|
-
* 1. Initial setup (critical for bottomToTop mode in flexbox layouts)
|
|
1172
|
-
* 2. Subsequent resize events
|
|
1173
|
-
*
|
|
1174
|
-
* For bottomToTop mode, we need to ensure:
|
|
1175
|
-
* - The flexbox layout is fully calculated
|
|
1176
|
-
* - The height measurements are accurate
|
|
1177
|
-
* - The scroll position starts at the bottom
|
|
1178
|
-
*
|
|
1179
578
|
* @param immediate - Whether to skip the delay (used for resize events)
|
|
1180
579
|
*/
|
|
1181
580
|
const updateHeightAndScroll = (immediate = false) => {
|
|
1182
581
|
log('updateHeightAndScroll-enter', {
|
|
1183
582
|
immediate,
|
|
1184
|
-
initialized: heightManager.initialized
|
|
1185
|
-
mode
|
|
583
|
+
initialized: heightManager.initialized
|
|
1186
584
|
})
|
|
1187
|
-
if (!heightManager.initialized && mode === 'bottomToTop') {
|
|
1188
|
-
// bottomToTop initialization: use scrollIntoView on Item 0 for precise positioning
|
|
1189
|
-
// visibleItems guarantees Item 0 is rendered during initialization
|
|
1190
|
-
tick().then(() => {
|
|
1191
|
-
requestAnimationFrame(() => {
|
|
1192
|
-
requestAnimationFrame(() => {
|
|
1193
|
-
if (!heightManager.isReady) return
|
|
1194
|
-
const measuredHeight =
|
|
1195
|
-
heightManager.container.getBoundingClientRect().height
|
|
1196
|
-
height = measuredHeight
|
|
1197
|
-
|
|
1198
|
-
// Instance jitter to avoid same-frame collisions when two lists init together
|
|
1199
|
-
const cleanedId = String(instanceId)
|
|
1200
|
-
.toLowerCase()
|
|
1201
|
-
.replace(/[^a-z0-9]/g, '')
|
|
1202
|
-
const suffix = cleanedId.slice(-4)
|
|
1203
|
-
const parsed = parseInt(suffix, 36)
|
|
1204
|
-
const jitterMs = Number.isNaN(parsed)
|
|
1205
|
-
? Math.floor(Math.random() * 3)
|
|
1206
|
-
: parsed % 3
|
|
1207
|
-
|
|
1208
|
-
setTimeout(() => {
|
|
1209
|
-
// Step 1: Set initialized (for other purposes like scroll event handling)
|
|
1210
|
-
// The init path in visibleItems stays active until bottomToTopScrollComplete
|
|
1211
|
-
if (!heightManager.initialized) {
|
|
1212
|
-
heightManager.initialized = true
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
// Step 2: Use scrollIntoView on Item 0 for precise positioning
|
|
1216
|
-
// Use double RAF to ensure heights are measured and layout is stable
|
|
1217
|
-
requestAnimationFrame(() => {
|
|
1218
|
-
requestAnimationFrame(() => {
|
|
1219
|
-
// Item 0 is guaranteed to be in DOM due to init path
|
|
1220
|
-
// Skip if user has already scrolled (scrollTop significantly != 0)
|
|
1221
|
-
const currentScroll = heightManager.viewport.scrollTop
|
|
1222
|
-
const userHasScrolled =
|
|
1223
|
-
currentScroll > heightManager.averageHeight
|
|
1224
|
-
const el = heightManager.viewport.querySelector(
|
|
1225
|
-
'[data-original-index="0"]'
|
|
1226
|
-
) as HTMLElement | null
|
|
1227
|
-
|
|
1228
|
-
if (el && !userHasScrolled) {
|
|
1229
|
-
// Use manual scrollTop instead of scrollIntoView to prevent parent scroll
|
|
1230
|
-
// (scrollIntoView scrolls all ancestor containers, not just the viewport)
|
|
1231
|
-
// Note: `container: 'nearest'` option could replace this once browser support improves
|
|
1232
|
-
const viewportRect =
|
|
1233
|
-
heightManager.viewport.getBoundingClientRect()
|
|
1234
|
-
const elRect = el.getBoundingClientRect()
|
|
1235
|
-
const offset = elRect.bottom - viewportRect.bottom
|
|
1236
|
-
heightManager.viewport.scrollTop += offset
|
|
1237
|
-
heightManager.scrollTop = heightManager.viewport.scrollTop
|
|
1238
|
-
} else if (userHasScrolled) {
|
|
1239
|
-
// Sync internal state with current scroll
|
|
1240
|
-
heightManager.scrollTop = currentScroll
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
// Step 3: Mark scroll complete - switches visibleItems to normal mode
|
|
1244
|
-
requestAnimationFrame(() => {
|
|
1245
|
-
bottomToTopScrollComplete = true
|
|
1246
|
-
// Reset bottom-anchoring flag to prevent stale state from init
|
|
1247
|
-
// affecting later operations (e.g., adding items while scrolled away)
|
|
1248
|
-
wasAtBottomBeforeHeightChange = false
|
|
1249
|
-
// Suppress bottom-anchoring briefly to let heights stabilize
|
|
1250
|
-
// after switching to normal mode
|
|
1251
|
-
suppressBottomAnchoringUntilMs = performance.now() + 200
|
|
1252
|
-
})
|
|
1253
|
-
})
|
|
1254
|
-
})
|
|
1255
|
-
}, jitterMs)
|
|
1256
|
-
})
|
|
1257
|
-
})
|
|
1258
|
-
})
|
|
1259
|
-
return
|
|
1260
|
-
}
|
|
1261
585
|
|
|
1262
586
|
utilsUpdateHeightAndScroll(
|
|
1263
587
|
{
|
|
1264
588
|
initialized: heightManager.initialized,
|
|
1265
|
-
mode,
|
|
1266
589
|
containerElement: heightManager.containerElement,
|
|
1267
590
|
viewportElement: heightManager.viewportElement,
|
|
1268
591
|
calculatedItemHeight: heightManager.averageHeight,
|
|
@@ -1309,11 +632,6 @@
|
|
|
1309
632
|
|
|
1310
633
|
// Only mark as dirty if height change is significant
|
|
1311
634
|
if (isSignificant) {
|
|
1312
|
-
// Capture bottom state when FIRST item gets marked dirty
|
|
1313
|
-
if (dirtyItemsCount === 0) {
|
|
1314
|
-
wasAtBottomBeforeHeightChange = atBottom
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
635
|
dirtyItems.add(actualIndex)
|
|
1318
636
|
dirtyItemsCount = dirtyItems.size
|
|
1319
637
|
shouldRecalculate = true
|
|
@@ -1334,7 +652,7 @@
|
|
|
1334
652
|
onMount(() => {
|
|
1335
653
|
if (BROWSER) {
|
|
1336
654
|
// Initial setup of heights and scroll position
|
|
1337
|
-
log('onMount-enter', {
|
|
655
|
+
log('onMount-enter', { items: items.length })
|
|
1338
656
|
updateHeightAndScroll()
|
|
1339
657
|
// Ensure one initial measurement pass even if no ResizeObserver fires
|
|
1340
658
|
tick().then(() =>
|
|
@@ -1514,7 +832,6 @@
|
|
|
1514
832
|
|
|
1515
833
|
// Use extracted scroll calculation utility
|
|
1516
834
|
const scrollTarget = calculateScrollTarget({
|
|
1517
|
-
mode,
|
|
1518
835
|
align: align || 'auto',
|
|
1519
836
|
targetIndex,
|
|
1520
837
|
itemsLength: items.length,
|
|
@@ -1531,9 +848,6 @@
|
|
|
1531
848
|
return
|
|
1532
849
|
}
|
|
1533
850
|
|
|
1534
|
-
// Prevent bottom-anchoring logic from interfering with programmatic scroll
|
|
1535
|
-
programmaticScrollInProgress = true
|
|
1536
|
-
|
|
1537
851
|
if (INTERNAL_DEBUG && heightManager.viewportElement) {
|
|
1538
852
|
const domMax = Math.max(
|
|
1539
853
|
0,
|
|
@@ -1550,32 +864,6 @@
|
|
|
1550
864
|
})
|
|
1551
865
|
}
|
|
1552
866
|
|
|
1553
|
-
// CROSS-BROWSER COMPATIBILITY FIX:
|
|
1554
|
-
// All major browsers (Chrome, Firefox, Safari) have inconsistent behavior with scrollTo()
|
|
1555
|
-
// in bottomToTop mode when using smooth scrolling. Using scrollIntoView() on the highest
|
|
1556
|
-
// visible element provides consistent cross-browser smooth scrolling behavior.
|
|
1557
|
-
// This approach works universally and maintains the user's expected smooth scroll experience.
|
|
1558
|
-
if (mode === 'bottomToTop' && smoothScroll) {
|
|
1559
|
-
// Find the element with the highest original-index in the current viewport
|
|
1560
|
-
const visibleElements = heightManager.viewport.querySelectorAll('[data-original-index]')
|
|
1561
|
-
let maxIndex = -1
|
|
1562
|
-
let maxElement: HTMLElement | null = null
|
|
1563
|
-
for (const el of visibleElements) {
|
|
1564
|
-
const index = parseInt(el.getAttribute('data-original-index') || '-1')
|
|
1565
|
-
if (index > maxIndex) {
|
|
1566
|
-
maxIndex = index
|
|
1567
|
-
maxElement = el as HTMLElement
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
maxElement?.scrollIntoView({
|
|
1572
|
-
behavior: 'smooth'
|
|
1573
|
-
})
|
|
1574
|
-
await tick()
|
|
1575
|
-
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
1576
|
-
await tick()
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
867
|
heightManager.viewport.scrollTo({
|
|
1580
868
|
top: scrollTarget,
|
|
1581
869
|
behavior: smoothScroll ? 'smooth' : 'auto'
|
|
@@ -1597,14 +885,6 @@
|
|
|
1597
885
|
})
|
|
1598
886
|
|
|
1599
887
|
// No extra alignment step here; allow native smooth scroll to reach DOM max scrollTop
|
|
1600
|
-
|
|
1601
|
-
// Clear the flag after scroll completes
|
|
1602
|
-
setTimeout(
|
|
1603
|
-
() => {
|
|
1604
|
-
programmaticScrollInProgress = false
|
|
1605
|
-
},
|
|
1606
|
-
smoothScroll ? 500 : 100
|
|
1607
|
-
)
|
|
1608
888
|
}
|
|
1609
889
|
|
|
1610
890
|
/**
|