mtrl-addons 0.2.1 → 0.2.2
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/AI.md +28 -230
- package/CLAUDE.md +882 -0
- package/build.js +286 -110
- package/package.json +2 -1
- package/src/components/vlist/features/api.ts +316 -12
- package/src/components/vlist/features/selection.ts +248 -256
- package/src/components/vlist/features/viewport.ts +1 -7
- package/src/components/vlist/index.ts +1 -0
- package/src/components/vlist/types.ts +140 -8
- package/src/core/layout/schema.ts +81 -38
- package/src/core/viewport/constants.ts +7 -2
- package/src/core/viewport/features/collection.ts +376 -76
- package/src/core/viewport/features/item-size.ts +4 -4
- package/src/core/viewport/features/momentum.ts +11 -2
- package/src/core/viewport/features/rendering.ts +424 -30
- package/src/core/viewport/features/scrolling.ts +41 -25
- package/src/core/viewport/features/utils.ts +11 -5
- package/src/core/viewport/features/virtual.ts +169 -28
- package/src/core/viewport/types.ts +2 -2
- package/src/core/viewport/viewport.ts +29 -10
- package/src/styles/components/_vlist.scss +234 -213
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { ViewportContext, ViewportComponent } from "../types";
|
|
9
9
|
import { VIEWPORT_CONSTANTS } from "../constants";
|
|
10
|
+
import { wrapDestroy } from "./utils";
|
|
10
11
|
|
|
11
12
|
export interface CollectionConfig {
|
|
12
13
|
collection?: any; // Collection adapter
|
|
@@ -18,6 +19,9 @@ export interface CollectionConfig {
|
|
|
18
19
|
enableRequestQueue?: boolean;
|
|
19
20
|
maxQueueSize?: number;
|
|
20
21
|
loadOnDragEnd?: boolean; // Enable loading when drag ends (safety measure)
|
|
22
|
+
initialScrollIndex?: number; // Initial scroll position (0-based index)
|
|
23
|
+
selectId?: string | number; // ID of item to select after initial load completes
|
|
24
|
+
autoLoad?: boolean; // Whether to automatically load data on initialization (default: true)
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export interface CollectionComponent {
|
|
@@ -25,7 +29,7 @@ export interface CollectionComponent {
|
|
|
25
29
|
loadRange: (offset: number, limit: number) => Promise<any[]>;
|
|
26
30
|
loadMissingRanges: (
|
|
27
31
|
range: { start: number; end: number },
|
|
28
|
-
caller?: string
|
|
32
|
+
caller?: string,
|
|
29
33
|
) => Promise<void>;
|
|
30
34
|
getLoadedRanges: () => Set<number>;
|
|
31
35
|
getPendingRanges: () => Set<number>;
|
|
@@ -44,7 +48,7 @@ export interface CollectionComponent {
|
|
|
44
48
|
*/
|
|
45
49
|
export function withCollection(config: CollectionConfig = {}) {
|
|
46
50
|
return <T extends ViewportContext & ViewportComponent>(
|
|
47
|
-
component: T
|
|
51
|
+
component: T,
|
|
48
52
|
): T & CollectionComponent => {
|
|
49
53
|
const {
|
|
50
54
|
collection,
|
|
@@ -57,9 +61,21 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
57
61
|
enableRequestQueue = VIEWPORT_CONSTANTS.REQUEST_QUEUE.ENABLED,
|
|
58
62
|
maxQueueSize = VIEWPORT_CONSTANTS.REQUEST_QUEUE.MAX_QUEUE_SIZE,
|
|
59
63
|
loadOnDragEnd = true, // Default to true as safety measure
|
|
64
|
+
initialScrollIndex = 0, // Start from beginning by default
|
|
65
|
+
selectId, // ID of item to select after initial load
|
|
66
|
+
autoLoad = true, // Auto-load initial data by default
|
|
60
67
|
} = config;
|
|
61
68
|
|
|
62
|
-
//
|
|
69
|
+
// Track if we've completed the initial load for initialScrollIndex
|
|
70
|
+
// This prevents the viewport:range-changed listener from loading page 1
|
|
71
|
+
let hasCompletedInitialPositionLoad = false;
|
|
72
|
+
const hasInitialScrollIndex = initialScrollIndex > 0;
|
|
73
|
+
|
|
74
|
+
// console.log("[Viewport Collection] Initialized with config:", {
|
|
75
|
+
// strategy,
|
|
76
|
+
// rangeSize,
|
|
77
|
+
// initialScrollIndex,
|
|
78
|
+
// });
|
|
63
79
|
|
|
64
80
|
// Loading manager state
|
|
65
81
|
interface QueuedRequest {
|
|
@@ -78,9 +94,75 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
78
94
|
let cancelledLoads = 0;
|
|
79
95
|
let isDragging = false; // Track drag state
|
|
80
96
|
|
|
97
|
+
// Cache eviction configuration
|
|
98
|
+
const MAX_CACHED_ITEMS = 500; // Maximum items to keep in memory
|
|
99
|
+
const EVICTION_BUFFER = 100; // Extra items to keep around visible range
|
|
100
|
+
|
|
101
|
+
// Memory diagnostics
|
|
102
|
+
const logMemoryStats = (caller: string) => {
|
|
103
|
+
const itemCount = items.filter(Boolean).length;
|
|
104
|
+
const loadedRangeCount = loadedRanges.size;
|
|
105
|
+
const pendingRangeCount = pendingRanges.size;
|
|
106
|
+
const abortControllerCount = abortControllers.size;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Evict items far from the current visible range to prevent memory bloat
|
|
111
|
+
* Keeps items within EVICTION_BUFFER of the visible range
|
|
112
|
+
*/
|
|
113
|
+
const evictDistantItems = (visibleStart: number, visibleEnd: number) => {
|
|
114
|
+
const itemCount = items.filter(Boolean).length;
|
|
115
|
+
|
|
116
|
+
// Only evict if we have more than MAX_CACHED_ITEMS
|
|
117
|
+
if (itemCount <= MAX_CACHED_ITEMS) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const keepStart = Math.max(0, visibleStart - EVICTION_BUFFER);
|
|
122
|
+
const keepEnd = visibleEnd + EVICTION_BUFFER;
|
|
123
|
+
|
|
124
|
+
let evictedCount = 0;
|
|
125
|
+
const rangesToRemove: number[] = [];
|
|
126
|
+
|
|
127
|
+
// Find items to evict
|
|
128
|
+
for (let i = 0; i < items.length; i++) {
|
|
129
|
+
if (items[i] !== undefined && (i < keepStart || i > keepEnd)) {
|
|
130
|
+
delete items[i];
|
|
131
|
+
evictedCount++;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Update loadedRanges to reflect evicted data
|
|
136
|
+
loadedRanges.forEach((rangeId) => {
|
|
137
|
+
const rangeStart = rangeId * rangeSize;
|
|
138
|
+
const rangeEnd = rangeStart + rangeSize - 1;
|
|
139
|
+
|
|
140
|
+
// If this range is completely outside the keep window, remove it
|
|
141
|
+
if (rangeEnd < keepStart || rangeStart > keepEnd) {
|
|
142
|
+
rangesToRemove.push(rangeId);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
rangesToRemove.forEach((rangeId) => {
|
|
147
|
+
loadedRanges.delete(rangeId);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (evictedCount > 0) {
|
|
151
|
+
// Emit event for rendering to also clean up
|
|
152
|
+
component.emit?.("collection:items-evicted", {
|
|
153
|
+
keepStart,
|
|
154
|
+
keepEnd,
|
|
155
|
+
evictedCount,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
81
160
|
const activeLoadRanges = new Set<string>();
|
|
82
161
|
let loadRequestQueue: QueuedRequest[] = [];
|
|
83
162
|
|
|
163
|
+
// AbortController map for cancelling in-flight requests
|
|
164
|
+
const abortControllers = new Map<number, AbortController>();
|
|
165
|
+
|
|
84
166
|
// State
|
|
85
167
|
let items: any[] = [];
|
|
86
168
|
let totalItems = 0;
|
|
@@ -152,12 +234,6 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
152
234
|
activeLoadCount++;
|
|
153
235
|
activeLoadRanges.add(getRangeKey(request.range));
|
|
154
236
|
|
|
155
|
-
// console.log(
|
|
156
|
-
// `[LoadingManager] Executing load for range ${request.range.start}-${
|
|
157
|
-
// request.range.end
|
|
158
|
-
// } (velocity: ${currentVelocity.toFixed(2)})`
|
|
159
|
-
// );
|
|
160
|
-
|
|
161
237
|
// Call the actual loadMissingRanges function
|
|
162
238
|
loadMissingRangesInternal(request.range)
|
|
163
239
|
.then(() => {
|
|
@@ -188,7 +264,7 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
188
264
|
*/
|
|
189
265
|
const loadRange = async (offset: number, limit: number): Promise<any[]> => {
|
|
190
266
|
// console.log(
|
|
191
|
-
// `[Collection] loadRange called: offset=${offset}, limit=${limit}
|
|
267
|
+
// `[Collection] loadRange called: offset=${offset}, limit=${limit}`,
|
|
192
268
|
// );
|
|
193
269
|
|
|
194
270
|
if (!collection) {
|
|
@@ -197,12 +273,12 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
197
273
|
}
|
|
198
274
|
|
|
199
275
|
const rangeId = getRangeId(offset, limit);
|
|
200
|
-
//console.log(`[Collection] Range ID: ${rangeId}`);
|
|
276
|
+
// console.log(`[Collection] Range ID: ${rangeId}`);
|
|
201
277
|
|
|
202
278
|
// Check if already loaded
|
|
203
279
|
if (loadedRanges.has(rangeId)) {
|
|
204
280
|
// console.log(
|
|
205
|
-
// `[Collection] Range ${rangeId} already loaded, returning cached data
|
|
281
|
+
// `[Collection] Range ${rangeId} already loaded, returning cached data`,
|
|
206
282
|
// );
|
|
207
283
|
return items.slice(offset, offset + limit);
|
|
208
284
|
}
|
|
@@ -213,7 +289,7 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
213
289
|
const existingRequest = activeRequests.get(rangeId);
|
|
214
290
|
if (existingRequest) {
|
|
215
291
|
// console.log(
|
|
216
|
-
// `[Collection] Returning existing request for range ${rangeId}
|
|
292
|
+
// `[Collection] Returning existing request for range ${rangeId}`,
|
|
217
293
|
// );
|
|
218
294
|
return existingRequest;
|
|
219
295
|
}
|
|
@@ -221,10 +297,14 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
221
297
|
|
|
222
298
|
// Mark as pending
|
|
223
299
|
// console.log(
|
|
224
|
-
// `[Collection] Marking range ${rangeId} as pending and loading
|
|
300
|
+
// `[Collection] Marking range ${rangeId} as pending and loading...`,
|
|
225
301
|
// );
|
|
226
302
|
pendingRanges.add(rangeId);
|
|
227
303
|
|
|
304
|
+
// Create AbortController for this request
|
|
305
|
+
const abortController = new AbortController();
|
|
306
|
+
abortControllers.set(rangeId, abortController);
|
|
307
|
+
|
|
228
308
|
// Create request promise
|
|
229
309
|
const requestPromise = (async () => {
|
|
230
310
|
try {
|
|
@@ -245,12 +325,12 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
245
325
|
console.warn(
|
|
246
326
|
`[Collection] Cannot load page ${page} without cursor for page ${
|
|
247
327
|
page - 1
|
|
248
|
-
}
|
|
328
|
+
}`,
|
|
249
329
|
);
|
|
250
330
|
throw new Error(
|
|
251
331
|
`Sequential loading required - missing cursor for page ${
|
|
252
332
|
page - 1
|
|
253
|
-
}
|
|
333
|
+
}`,
|
|
254
334
|
);
|
|
255
335
|
}
|
|
256
336
|
params = { cursor: prevPageCursor, limit };
|
|
@@ -267,7 +347,11 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
267
347
|
// JSON.stringify(params)
|
|
268
348
|
// );
|
|
269
349
|
|
|
270
|
-
|
|
350
|
+
// Pass abort signal to the adapter if it supports it
|
|
351
|
+
const response = await collection.read({
|
|
352
|
+
...params,
|
|
353
|
+
signal: abortController.signal,
|
|
354
|
+
});
|
|
271
355
|
|
|
272
356
|
// Extract items and total
|
|
273
357
|
const rawItems = response.data || response.items || response;
|
|
@@ -280,23 +364,27 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
280
364
|
cursorMap.set(page, responseCursor);
|
|
281
365
|
pageToOffsetMap.set(page, offset);
|
|
282
366
|
highestLoadedPage = Math.max(highestLoadedPage, page);
|
|
283
|
-
console.log(
|
|
284
|
-
|
|
285
|
-
);
|
|
367
|
+
// console.log(
|
|
368
|
+
// `[Collection] Stored cursor for page ${page}: ${responseCursor}`,
|
|
369
|
+
// );
|
|
286
370
|
}
|
|
287
371
|
|
|
288
372
|
// Check if we've reached the end
|
|
289
373
|
if (strategy === "cursor" && meta.hasNext === false) {
|
|
290
374
|
hasReachedEnd = true;
|
|
291
|
-
console.log(
|
|
292
|
-
|
|
293
|
-
);
|
|
375
|
+
// console.log(
|
|
376
|
+
// `[Collection] Reached end of cursor pagination at page ${page}`,
|
|
377
|
+
// );
|
|
294
378
|
}
|
|
295
379
|
|
|
296
380
|
// Update discovered total if provided
|
|
381
|
+
// console.log(
|
|
382
|
+
// `[Collection] meta.total: ${meta.total}, discoveredTotal before: ${discoveredTotal}`,
|
|
383
|
+
// );
|
|
297
384
|
if (meta.total !== undefined) {
|
|
298
385
|
discoveredTotal = meta.total;
|
|
299
386
|
}
|
|
387
|
+
// console.log(`[Collection] discoveredTotal after: ${discoveredTotal}`);
|
|
300
388
|
|
|
301
389
|
// Transform items
|
|
302
390
|
const transformedItems = transformItems(rawItems);
|
|
@@ -307,12 +395,27 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
307
395
|
});
|
|
308
396
|
|
|
309
397
|
// For cursor strategy, calculate dynamic total based on loaded data
|
|
310
|
-
|
|
398
|
+
// Use nullish coalescing (??) instead of || to handle discoveredTotal = 0 correctly
|
|
399
|
+
let newTotal = discoveredTotal ?? totalItems;
|
|
400
|
+
|
|
401
|
+
// CRITICAL FIX: When API returns 0 items on page 1 (offset 0), the list is empty.
|
|
402
|
+
// We must set totalItems to 0 regardless of meta.total being undefined.
|
|
403
|
+
// This handles the case where count=false is sent but the list is actually empty.
|
|
404
|
+
if (offset === 0 && transformedItems.length === 0) {
|
|
405
|
+
// console.log(
|
|
406
|
+
// `[Collection] Empty result on page 1 - forcing totalItems to 0`,
|
|
407
|
+
// );
|
|
408
|
+
newTotal = 0;
|
|
409
|
+
discoveredTotal = 0;
|
|
410
|
+
}
|
|
411
|
+
// console.log(
|
|
412
|
+
// `[Collection] newTotal initial: ${newTotal}, totalItems: ${totalItems}, strategy: ${strategy}`,
|
|
413
|
+
// );
|
|
311
414
|
|
|
312
415
|
if (strategy === "cursor") {
|
|
313
416
|
// Calculate total based on loaded items + margin
|
|
314
417
|
const loadedItemsCount = items.filter(
|
|
315
|
-
(item) => item !== undefined
|
|
418
|
+
(item) => item !== undefined,
|
|
316
419
|
).length;
|
|
317
420
|
const marginItems = hasReachedEnd
|
|
318
421
|
? 0
|
|
@@ -325,28 +428,33 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
325
428
|
// Dynamic total: loaded items + margin (unless we've reached the end)
|
|
326
429
|
newTotal = Math.max(
|
|
327
430
|
loadedItemsCount + marginItems,
|
|
328
|
-
minVirtualItems
|
|
431
|
+
minVirtualItems,
|
|
329
432
|
);
|
|
330
433
|
|
|
331
|
-
console.log(
|
|
332
|
-
|
|
333
|
-
);
|
|
434
|
+
// console.log(
|
|
435
|
+
// `[Collection] Cursor mode virtual size: loaded=${loadedItemsCount}, margin=${marginItems}, total=${newTotal}, hasReachedEnd=${hasReachedEnd}`,
|
|
436
|
+
// );
|
|
334
437
|
|
|
335
438
|
// Update total if it has grown
|
|
336
439
|
if (newTotal > totalItems) {
|
|
337
|
-
console.log(
|
|
338
|
-
|
|
339
|
-
);
|
|
440
|
+
// console.log(
|
|
441
|
+
// `[Collection] Updating cursor virtual size from ${totalItems} to ${newTotal}`,
|
|
442
|
+
// );
|
|
340
443
|
totalItems = newTotal;
|
|
341
444
|
setTotalItems(newTotal);
|
|
342
445
|
}
|
|
343
446
|
} else {
|
|
344
447
|
// For other strategies, use discovered total or current total
|
|
345
|
-
|
|
448
|
+
// Use nullish coalescing (??) instead of || to handle discoveredTotal = 0 correctly
|
|
449
|
+
newTotal = discoveredTotal ?? totalItems;
|
|
346
450
|
}
|
|
347
451
|
|
|
348
452
|
// Update state
|
|
453
|
+
// console.log(
|
|
454
|
+
// `[Collection] Before state update: newTotal=${newTotal}, totalItems=${totalItems}, will update: ${newTotal !== totalItems}`,
|
|
455
|
+
// );
|
|
349
456
|
if (newTotal !== totalItems) {
|
|
457
|
+
// console.log(`[Collection] Calling setTotalItems(${newTotal})`);
|
|
350
458
|
totalItems = newTotal;
|
|
351
459
|
setTotalItems(newTotal);
|
|
352
460
|
}
|
|
@@ -363,7 +471,8 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
363
471
|
// Update viewport state
|
|
364
472
|
const viewportState = (component.viewport as any).state;
|
|
365
473
|
if (viewportState) {
|
|
366
|
-
|
|
474
|
+
// Use nullish coalescing (??) instead of || to handle newTotal = 0 correctly
|
|
475
|
+
viewportState.totalItems = newTotal ?? items.length;
|
|
367
476
|
}
|
|
368
477
|
|
|
369
478
|
// Mark as loaded
|
|
@@ -375,6 +484,11 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
375
484
|
// `[Collection] Range ${rangeId} loaded successfully with ${transformedItems.length} items`
|
|
376
485
|
// );
|
|
377
486
|
|
|
487
|
+
// Log memory stats periodically (every 5 loads)
|
|
488
|
+
if (completedLoads % 5 === 0) {
|
|
489
|
+
logMemoryStats(`load:${completedLoads}`);
|
|
490
|
+
}
|
|
491
|
+
|
|
378
492
|
// Emit events
|
|
379
493
|
component.emit?.("viewport:range-loaded", {
|
|
380
494
|
offset,
|
|
@@ -399,8 +513,22 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
399
513
|
|
|
400
514
|
return transformedItems;
|
|
401
515
|
} catch (error) {
|
|
402
|
-
// Handle
|
|
516
|
+
// Handle AbortError and "Failed to fetch" (which can occur on abort) gracefully
|
|
517
|
+
const isAbortError =
|
|
518
|
+
(error as Error).name === "AbortError" ||
|
|
519
|
+
((error as Error).message === "Failed to fetch" &&
|
|
520
|
+
abortController.signal.aborted);
|
|
521
|
+
|
|
522
|
+
if (isAbortError) {
|
|
523
|
+
pendingRanges.delete(rangeId);
|
|
524
|
+
cancelledLoads++;
|
|
525
|
+
// Don't throw, just return empty array
|
|
526
|
+
return [];
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Handle other errors
|
|
403
530
|
pendingRanges.delete(rangeId);
|
|
531
|
+
failedLoads++;
|
|
404
532
|
|
|
405
533
|
const attempts = (failedRanges.get(rangeId)?.attempts || 0) + 1;
|
|
406
534
|
failedRanges.set(rangeId, {
|
|
@@ -420,6 +548,7 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
420
548
|
throw error;
|
|
421
549
|
} finally {
|
|
422
550
|
activeRequests.delete(rangeId);
|
|
551
|
+
abortControllers.delete(rangeId);
|
|
423
552
|
}
|
|
424
553
|
})();
|
|
425
554
|
|
|
@@ -436,6 +565,10 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
436
565
|
}): Promise<void> => {
|
|
437
566
|
if (!collection) return;
|
|
438
567
|
|
|
568
|
+
// console.log(
|
|
569
|
+
// `[Collection] loadMissingRangesInternal - range: ${start}-${end}, strategy: ${strategy}`,
|
|
570
|
+
// );
|
|
571
|
+
|
|
439
572
|
// For cursor pagination, we need to load sequentially
|
|
440
573
|
if (strategy === "cursor") {
|
|
441
574
|
const startPage = Math.floor(range.start / rangeSize) + 1;
|
|
@@ -446,12 +579,12 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
446
579
|
const currentHighestPage = highestLoadedPage || 0;
|
|
447
580
|
const limitedEndPage = Math.min(
|
|
448
581
|
endPage,
|
|
449
|
-
currentHighestPage + maxPagesToLoad
|
|
582
|
+
currentHighestPage + maxPagesToLoad,
|
|
450
583
|
);
|
|
451
584
|
|
|
452
|
-
console.log(
|
|
453
|
-
|
|
454
|
-
);
|
|
585
|
+
// console.log(
|
|
586
|
+
// `[Collection] Cursor mode: need to load pages ${startPage} to ${endPage}, limited to ${limitedEndPage}`,
|
|
587
|
+
// );
|
|
455
588
|
|
|
456
589
|
// Check if we need to load pages sequentially
|
|
457
590
|
for (let page = startPage; page <= limitedEndPage; page++) {
|
|
@@ -472,9 +605,9 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
472
605
|
for (let prevPage = 1; prevPage < page; prevPage++) {
|
|
473
606
|
const prevRangeId = prevPage - 1;
|
|
474
607
|
if (!loadedRanges.has(prevRangeId)) {
|
|
475
|
-
console.log(
|
|
476
|
-
|
|
477
|
-
);
|
|
608
|
+
// console.log(
|
|
609
|
+
// `[Collection] Cannot load page ${page} - need to load page ${prevPage} first`,
|
|
610
|
+
// );
|
|
478
611
|
canLoad = false;
|
|
479
612
|
|
|
480
613
|
// Try to load the missing page
|
|
@@ -484,7 +617,7 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
484
617
|
} catch (error) {
|
|
485
618
|
console.error(
|
|
486
619
|
`[Collection] Failed to load prerequisite page ${prevPage}:`,
|
|
487
|
-
error
|
|
620
|
+
error,
|
|
488
621
|
);
|
|
489
622
|
return; // Stop trying to load further pages
|
|
490
623
|
}
|
|
@@ -508,9 +641,9 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
508
641
|
}
|
|
509
642
|
|
|
510
643
|
if (endPage > limitedEndPage) {
|
|
511
|
-
console.log(
|
|
512
|
-
|
|
513
|
-
);
|
|
644
|
+
// console.log(
|
|
645
|
+
// `[Collection] Stopped at page ${limitedEndPage} to prevent excessive loading (requested up to ${endPage})`,
|
|
646
|
+
// );
|
|
514
647
|
}
|
|
515
648
|
|
|
516
649
|
return;
|
|
@@ -521,6 +654,10 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
521
654
|
const startRange = Math.floor(range.start / rangeSize);
|
|
522
655
|
const endRange = Math.floor(range.end / rangeSize);
|
|
523
656
|
|
|
657
|
+
// console.log(
|
|
658
|
+
// `[Collection] page strategy - startRange: ${startRange}, endRange: ${endRange}, loadedRanges: [${Array.from(loadedRanges).join(", ")}]`,
|
|
659
|
+
// );
|
|
660
|
+
|
|
524
661
|
// Collect ranges that need loading
|
|
525
662
|
const rangesToLoad: number[] = [];
|
|
526
663
|
for (let rangeId = startRange; rangeId <= endRange; rangeId++) {
|
|
@@ -529,7 +666,12 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
529
666
|
}
|
|
530
667
|
}
|
|
531
668
|
|
|
669
|
+
// console.log(
|
|
670
|
+
// `[Collection] rangesToLoad: [${rangesToLoad.join(", ")}], pendingRanges: [${Array.from(pendingRanges).join(", ")}]`,
|
|
671
|
+
// );
|
|
672
|
+
|
|
532
673
|
if (rangesToLoad.length === 0) {
|
|
674
|
+
// console.log(`[Collection] No ranges to load - all loaded or pending`);
|
|
533
675
|
// All ranges are already loaded or pending
|
|
534
676
|
// Check if there are queued requests we should process
|
|
535
677
|
if (
|
|
@@ -541,9 +683,13 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
541
683
|
return;
|
|
542
684
|
}
|
|
543
685
|
|
|
686
|
+
// console.log(
|
|
687
|
+
// `[Collection] Loading ${rangesToLoad.length} ranges: [${rangesToLoad.join(", ")}]`,
|
|
688
|
+
// );
|
|
689
|
+
|
|
544
690
|
// Load ranges individually - no merging to avoid loading old ranges
|
|
545
691
|
const promises = rangesToLoad.map((rangeId) =>
|
|
546
|
-
loadRange(rangeId * rangeSize, rangeSize)
|
|
692
|
+
loadRange(rangeId * rangeSize, rangeSize),
|
|
547
693
|
);
|
|
548
694
|
await Promise.allSettled(promises);
|
|
549
695
|
};
|
|
@@ -556,14 +702,21 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
556
702
|
start: number;
|
|
557
703
|
end: number;
|
|
558
704
|
},
|
|
559
|
-
caller?: string
|
|
705
|
+
caller?: string,
|
|
560
706
|
): Promise<void> => {
|
|
561
707
|
return new Promise((resolve, reject) => {
|
|
562
708
|
const rangeKey = getRangeKey(range);
|
|
563
|
-
|
|
709
|
+
|
|
710
|
+
// console.log(
|
|
711
|
+
// `[Collection] loadMissingRanges called - range: ${range.start}-${range.end}, caller: ${caller}`,
|
|
712
|
+
// );
|
|
713
|
+
// console.log(
|
|
714
|
+
// `[Collection] loadedRanges: [${Array.from(loadedRanges).join(", ")}]`,
|
|
715
|
+
// );
|
|
564
716
|
|
|
565
717
|
// Check if already loading
|
|
566
718
|
if (activeLoadRanges.has(rangeKey)) {
|
|
719
|
+
// console.log(`[Collection] Range already being loaded, skipping`);
|
|
567
720
|
// Range already being loaded
|
|
568
721
|
resolve();
|
|
569
722
|
return;
|
|
@@ -621,7 +774,7 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
621
774
|
if (loadRequestQueue.length >= maxQueueSize) {
|
|
622
775
|
const removed = loadRequestQueue.splice(
|
|
623
776
|
0,
|
|
624
|
-
loadRequestQueue.length - maxQueueSize
|
|
777
|
+
loadRequestQueue.length - maxQueueSize,
|
|
625
778
|
);
|
|
626
779
|
removed.forEach((r) => {
|
|
627
780
|
cancelledLoads++;
|
|
@@ -660,7 +813,11 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
660
813
|
// Hook into viewport initialization
|
|
661
814
|
const originalInitialize = component.viewport.initialize;
|
|
662
815
|
component.viewport.initialize = () => {
|
|
663
|
-
originalInitialize();
|
|
816
|
+
const result = originalInitialize();
|
|
817
|
+
// Skip if already initialized
|
|
818
|
+
if (result === false) {
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
664
821
|
|
|
665
822
|
// Set initial total if provided
|
|
666
823
|
if (component.totalItems) {
|
|
@@ -685,14 +842,6 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
685
842
|
component.on?.("viewport:range-changed", async (data: any) => {
|
|
686
843
|
// Don't load during fast scrolling - loadMissingRanges will handle velocity check
|
|
687
844
|
|
|
688
|
-
// Skip initial load if we're dragging and velocity is low (drag just started)
|
|
689
|
-
// if (isDragging && currentVelocity < 0.5) {
|
|
690
|
-
// console.log(
|
|
691
|
-
// "[Collection] Skipping range-changed load during drag start"
|
|
692
|
-
// );
|
|
693
|
-
// return;
|
|
694
|
-
// }
|
|
695
|
-
|
|
696
845
|
// Extract range from event data - virtual feature emits { range: { start, end }, scrollPosition }
|
|
697
846
|
const range = data.range || data;
|
|
698
847
|
const { start, end } = range;
|
|
@@ -701,18 +850,32 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
701
850
|
if (typeof start !== "number" || typeof end !== "number") {
|
|
702
851
|
console.warn(
|
|
703
852
|
"[Collection] Invalid range in viewport:range-changed event:",
|
|
704
|
-
data
|
|
853
|
+
data,
|
|
705
854
|
);
|
|
706
855
|
return;
|
|
707
856
|
}
|
|
708
857
|
|
|
858
|
+
// Skip loading page 1 if we have an initialScrollIndex and haven't completed initial load yet
|
|
859
|
+
// This prevents the requestAnimationFrame in virtual.ts from triggering a page 1 load
|
|
860
|
+
// after we've already started loading the correct initial page
|
|
861
|
+
if (hasInitialScrollIndex && !hasCompletedInitialPositionLoad) {
|
|
862
|
+
const page1EndIndex = rangeSize - 1; // e.g., 29 for rangeSize=30
|
|
863
|
+
if (start === 0 && end <= page1EndIndex) {
|
|
864
|
+
// This is a request for page 1, skip it
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
709
869
|
// Load missing ranges if needed
|
|
710
870
|
await loadMissingRanges({ start, end }, "viewport:range-changed");
|
|
711
871
|
|
|
872
|
+
// Evict distant items to prevent memory bloat
|
|
873
|
+
evictDistantItems(start, end);
|
|
874
|
+
|
|
712
875
|
// For cursor mode, check if we need to update virtual size
|
|
713
876
|
if (strategy === "cursor" && !hasReachedEnd) {
|
|
714
877
|
const loadedItemsCount = items.filter(
|
|
715
|
-
(item) => item !== undefined
|
|
878
|
+
(item) => item !== undefined,
|
|
716
879
|
).length;
|
|
717
880
|
const marginItems =
|
|
718
881
|
rangeSize *
|
|
@@ -722,13 +885,13 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
722
885
|
VIEWPORT_CONSTANTS.PAGINATION.CURSOR_MIN_VIRTUAL_SIZE_MULTIPLIER;
|
|
723
886
|
const dynamicTotal = Math.max(
|
|
724
887
|
loadedItemsCount + marginItems,
|
|
725
|
-
minVirtualItems
|
|
888
|
+
minVirtualItems,
|
|
726
889
|
);
|
|
727
890
|
|
|
728
891
|
if (dynamicTotal !== totalItems) {
|
|
729
|
-
console.log(
|
|
730
|
-
|
|
731
|
-
);
|
|
892
|
+
// console.log(
|
|
893
|
+
// `[Collection] Updating cursor virtual size from ${totalItems} to ${dynamicTotal}`,
|
|
894
|
+
// );
|
|
732
895
|
setTotalItems(dynamicTotal);
|
|
733
896
|
}
|
|
734
897
|
}
|
|
@@ -778,9 +941,9 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
778
941
|
requestStart <= visibleRange.end + buffer;
|
|
779
942
|
|
|
780
943
|
if (!isRelevant) {
|
|
781
|
-
console.log(
|
|
782
|
-
|
|
783
|
-
);
|
|
944
|
+
// console.log(
|
|
945
|
+
// `[Collection] Removing stale queued request: ${requestStart}-${requestEnd}`,
|
|
946
|
+
// );
|
|
784
947
|
request.resolve(); // Resolve to avoid hanging promises
|
|
785
948
|
}
|
|
786
949
|
return isRelevant;
|
|
@@ -794,8 +957,99 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
794
957
|
processQueue();
|
|
795
958
|
});
|
|
796
959
|
|
|
797
|
-
//
|
|
798
|
-
|
|
960
|
+
// Listen for item removal - DON'T clear loadedRanges to prevent unnecessary reload
|
|
961
|
+
// The data is already shifted locally in api.ts and rendering.ts
|
|
962
|
+
// Reloading would cause race conditions and overwrite the correct totalItems
|
|
963
|
+
component.on?.("item:removed", (data: any) => {
|
|
964
|
+
// console.log(`[Collection] item:removed event - index: ${data.index}`);
|
|
965
|
+
// console.log(`[Collection] items.length: ${items.length}`);
|
|
966
|
+
|
|
967
|
+
// Update discoveredTotal to match the new count
|
|
968
|
+
// This prevents stale discoveredTotal from being used on next load
|
|
969
|
+
if (discoveredTotal !== null && discoveredTotal > 0) {
|
|
970
|
+
discoveredTotal = discoveredTotal - 1;
|
|
971
|
+
// console.log(
|
|
972
|
+
// `[Collection] Updated discoveredTotal to: ${discoveredTotal}`,
|
|
973
|
+
// );
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// NOTE: Do NOT decrement totalItems here!
|
|
977
|
+
// api.ts already calls setTotalItems() after emitting item:remove-request,
|
|
978
|
+
// which properly updates totalItems. Decrementing here would cause a double-decrement.
|
|
979
|
+
|
|
980
|
+
// DON'T clear loadedRanges - we want to keep using the local data
|
|
981
|
+
// The data has been shifted locally and is still valid
|
|
982
|
+
// Clearing would trigger a reload which causes race conditions
|
|
983
|
+
// console.log(
|
|
984
|
+
// `[Collection] Keeping loadedRanges intact:`,
|
|
985
|
+
// Array.from(loadedRanges),
|
|
986
|
+
// );
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
// Load initial data if collection is available and autoLoad is enabled
|
|
990
|
+
if (collection && autoLoad) {
|
|
991
|
+
// If we have an initial scroll index OR a selectId, load data for that position directly
|
|
992
|
+
// Don't use scrollToIndex() as it triggers animation/velocity tracking
|
|
993
|
+
// virtual.ts has already set the scroll position and calculated the visible range
|
|
994
|
+
if (initialScrollIndex > 0 || selectId !== undefined) {
|
|
995
|
+
// Get the visible range that was already calculated by virtual.ts
|
|
996
|
+
// We missed the initial viewport:range-changed event because our listener wasn't ready yet
|
|
997
|
+
const visibleRange = component.viewport?.getVisibleRange?.();
|
|
998
|
+
|
|
999
|
+
if (
|
|
1000
|
+
visibleRange &&
|
|
1001
|
+
(visibleRange.start > 0 || visibleRange.end > 0)
|
|
1002
|
+
) {
|
|
1003
|
+
// Use the pre-calculated visible range from virtual.ts
|
|
1004
|
+
loadMissingRanges(visibleRange, "initial-position")
|
|
1005
|
+
.then(() => {
|
|
1006
|
+
hasCompletedInitialPositionLoad = true;
|
|
1007
|
+
// Emit event to select item after initial load if selectId is provided
|
|
1008
|
+
if (selectId !== undefined) {
|
|
1009
|
+
component.emit?.("collection:initial-load-complete", {
|
|
1010
|
+
selectId,
|
|
1011
|
+
initialScrollIndex,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
})
|
|
1015
|
+
.catch((error) => {
|
|
1016
|
+
console.error(
|
|
1017
|
+
"[Collection] Failed to load initial position data:",
|
|
1018
|
+
error,
|
|
1019
|
+
);
|
|
1020
|
+
hasCompletedInitialPositionLoad = true; // Allow normal loading even on error
|
|
1021
|
+
});
|
|
1022
|
+
} else {
|
|
1023
|
+
// Fallback: calculate range from initialScrollIndex
|
|
1024
|
+
// This handles edge cases where visibleRange wasn't ready
|
|
1025
|
+
const overscan = 2;
|
|
1026
|
+
const estimatedVisibleCount = Math.ceil(600 / 50); // ~12 items
|
|
1027
|
+
const start = Math.max(0, initialScrollIndex - overscan);
|
|
1028
|
+
const end = initialScrollIndex + estimatedVisibleCount + overscan;
|
|
1029
|
+
|
|
1030
|
+
loadMissingRanges({ start, end }, "initial-position-fallback")
|
|
1031
|
+
.then(() => {
|
|
1032
|
+
hasCompletedInitialPositionLoad = true;
|
|
1033
|
+
// Emit event to select item after initial load if selectId is provided
|
|
1034
|
+
if (selectId !== undefined) {
|
|
1035
|
+
component.emit?.("collection:initial-load-complete", {
|
|
1036
|
+
selectId,
|
|
1037
|
+
initialScrollIndex,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
})
|
|
1041
|
+
.catch((error) => {
|
|
1042
|
+
console.error(
|
|
1043
|
+
"[Collection] Failed to load initial position data (fallback):",
|
|
1044
|
+
error,
|
|
1045
|
+
);
|
|
1046
|
+
hasCompletedInitialPositionLoad = true; // Allow normal loading even on error
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// No initial scroll index - load from beginning as normal
|
|
799
1053
|
loadRange(0, rangeSize)
|
|
800
1054
|
.then(() => {
|
|
801
1055
|
// console.log("[Collection] Initial data loaded");
|
|
@@ -804,6 +1058,8 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
804
1058
|
console.error("[Collection] Failed to load initial data:", error);
|
|
805
1059
|
});
|
|
806
1060
|
}
|
|
1061
|
+
|
|
1062
|
+
return result;
|
|
807
1063
|
};
|
|
808
1064
|
|
|
809
1065
|
// Add collection API to viewport
|
|
@@ -811,7 +1067,7 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
811
1067
|
loadRange: (offset: number, limit: number) => loadRange(offset, limit),
|
|
812
1068
|
loadMissingRanges: (
|
|
813
1069
|
range: { start: number; end: number },
|
|
814
|
-
caller?: string
|
|
1070
|
+
caller?: string,
|
|
815
1071
|
) => loadMissingRanges(range, caller || "viewport.collection"),
|
|
816
1072
|
getLoadedRanges: () => loadedRanges,
|
|
817
1073
|
getPendingRanges: () => pendingRanges,
|
|
@@ -832,7 +1088,7 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
832
1088
|
loadRange,
|
|
833
1089
|
loadMissingRanges: (
|
|
834
1090
|
range: { start: number; end: number },
|
|
835
|
-
caller?: string
|
|
1091
|
+
caller?: string,
|
|
836
1092
|
) => loadMissingRanges(range, caller || "component.collection"),
|
|
837
1093
|
getLoadingStats: () => ({
|
|
838
1094
|
pendingRequests: activeLoadCount,
|
|
@@ -843,6 +1099,9 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
843
1099
|
canLoad: canLoad(),
|
|
844
1100
|
queuedRequests: loadRequestQueue.length,
|
|
845
1101
|
}),
|
|
1102
|
+
// Total items management
|
|
1103
|
+
getTotalItems: () => totalItems,
|
|
1104
|
+
setTotalItems,
|
|
846
1105
|
// Cursor methods
|
|
847
1106
|
getCurrentCursor: () => currentCursor,
|
|
848
1107
|
getCursorForPage: (page: number) => cursorMap.get(page) || null,
|
|
@@ -851,21 +1110,62 @@ export function withCollection(config: CollectionConfig = {}) {
|
|
|
851
1110
|
// Also ensure component.items is updated
|
|
852
1111
|
component.items = items;
|
|
853
1112
|
|
|
854
|
-
// Cleanup function
|
|
1113
|
+
// Cleanup function - comprehensive memory cleanup
|
|
855
1114
|
const destroy = () => {
|
|
856
|
-
|
|
857
|
-
|
|
1115
|
+
logMemoryStats("destroy:before");
|
|
1116
|
+
|
|
1117
|
+
// Abort all in-flight requests
|
|
1118
|
+
abortControllers.forEach((controller) => {
|
|
1119
|
+
try {
|
|
1120
|
+
controller.abort();
|
|
1121
|
+
} catch (e) {
|
|
1122
|
+
// Ignore abort errors
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
abortControllers.clear();
|
|
1126
|
+
|
|
1127
|
+
// Clear all data structures
|
|
1128
|
+
items.length = 0;
|
|
1129
|
+
loadRequestQueue.length = 0;
|
|
1130
|
+
loadedRanges.clear();
|
|
858
1131
|
pendingRanges.clear();
|
|
1132
|
+
failedRanges.clear();
|
|
1133
|
+
activeLoadRanges.clear();
|
|
1134
|
+
activeRequests.clear();
|
|
1135
|
+
cursorMap.clear();
|
|
1136
|
+
pageToOffsetMap.clear();
|
|
1137
|
+
|
|
1138
|
+
// Reset state
|
|
1139
|
+
totalItems = 0;
|
|
1140
|
+
currentCursor = null;
|
|
1141
|
+
highestLoadedPage = 0;
|
|
1142
|
+
discoveredTotal = null;
|
|
1143
|
+
hasReachedEnd = false;
|
|
1144
|
+
|
|
1145
|
+
// Clear component reference
|
|
1146
|
+
if (component.items === items) {
|
|
1147
|
+
component.items = [];
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
logMemoryStats("destroy:after");
|
|
859
1151
|
};
|
|
860
1152
|
|
|
1153
|
+
// Wire destroy into the component's destroy chain
|
|
1154
|
+
wrapDestroy(component, destroy);
|
|
1155
|
+
|
|
861
1156
|
// Return enhanced component
|
|
862
1157
|
return {
|
|
863
1158
|
...component,
|
|
864
1159
|
collection: {
|
|
1160
|
+
// Data access methods
|
|
1161
|
+
items,
|
|
1162
|
+
getItems: () => items,
|
|
1163
|
+
getItem: (index: number) => items[index],
|
|
1164
|
+
// Loading methods
|
|
865
1165
|
loadRange,
|
|
866
1166
|
loadMissingRanges: (
|
|
867
1167
|
range: { start: number; end: number },
|
|
868
|
-
caller?: string
|
|
1168
|
+
caller?: string,
|
|
869
1169
|
) => loadMissingRanges(range, caller || "return.collection"),
|
|
870
1170
|
getLoadedRanges: () => loadedRanges,
|
|
871
1171
|
getPendingRanges: () => pendingRanges,
|