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.
@@ -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
- // console.log("[Viewport Collection] Initialized with strategy:", strategy);
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
- const response = await collection.read(params);
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
- `[Collection] Stored cursor for page ${page}: ${responseCursor}`
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
- `[Collection] Reached end of cursor pagination at page ${page}`
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
- let newTotal = discoveredTotal || totalItems;
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
- `[Collection] Cursor mode virtual size: loaded=${loadedItemsCount}, margin=${marginItems}, total=${newTotal}, hasReachedEnd=${hasReachedEnd}`
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
- `[Collection] Updating cursor virtual size from ${totalItems} to ${newTotal}`
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
- newTotal = discoveredTotal || totalItems;
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
- viewportState.totalItems = newTotal || items.length;
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 error
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
- `[Collection] Cursor mode: need to load pages ${startPage} to ${endPage}, limited to ${limitedEndPage}`
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
- `[Collection] Cannot load page ${page} - need to load page ${prevPage} first`
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
- `[Collection] Stopped at page ${limitedEndPage} to prevent excessive loading (requested up to ${endPage})`
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
- // Removed noisy log
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
- `[Collection] Updating cursor virtual size from ${totalItems} to ${dynamicTotal}`
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
- `[Collection] Removing stale queued request: ${requestStart}-${requestEnd}`
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
- // Load initial data if collection is available
798
- if (collection) {
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
- // Cancel pending requests
857
- activeRequests.clear();
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,