mtrl-addons 0.2.2 → 0.2.3

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.
Files changed (128) hide show
  1. package/{src/components/index.ts → dist/components/index.d.ts} +0 -2
  2. package/dist/components/vlist/config.d.ts +86 -0
  3. package/{src/components/vlist/constants.ts → dist/components/vlist/constants.d.ts} +10 -11
  4. package/dist/components/vlist/features/api.d.ts +7 -0
  5. package/{src/components/vlist/features/index.ts → dist/components/vlist/features/index.d.ts} +0 -2
  6. package/dist/components/vlist/features/selection.d.ts +6 -0
  7. package/dist/components/vlist/features/viewport.d.ts +9 -0
  8. package/dist/components/vlist/features.d.ts +31 -0
  9. package/{src/components/vlist/index.ts → dist/components/vlist/index.d.ts} +1 -10
  10. package/dist/components/vlist/types.d.ts +596 -0
  11. package/dist/components/vlist/vlist.d.ts +29 -0
  12. package/dist/core/compose/features/gestures/index.d.ts +86 -0
  13. package/dist/core/compose/features/gestures/longpress.d.ts +85 -0
  14. package/dist/core/compose/features/gestures/pan.d.ts +108 -0
  15. package/dist/core/compose/features/gestures/pinch.d.ts +111 -0
  16. package/dist/core/compose/features/gestures/rotate.d.ts +111 -0
  17. package/dist/core/compose/features/gestures/swipe.d.ts +149 -0
  18. package/dist/core/compose/features/gestures/tap.d.ts +79 -0
  19. package/{src/core/compose/features/index.ts → dist/core/compose/features/index.d.ts} +1 -2
  20. package/{src/core/compose/index.ts → dist/core/compose/index.d.ts} +2 -11
  21. package/{src/core/gestures/index.ts → dist/core/gestures/index.d.ts} +1 -20
  22. package/dist/core/gestures/longpress.d.ts +23 -0
  23. package/dist/core/gestures/manager.d.ts +14 -0
  24. package/dist/core/gestures/pan.d.ts +12 -0
  25. package/dist/core/gestures/pinch.d.ts +14 -0
  26. package/dist/core/gestures/rotate.d.ts +14 -0
  27. package/dist/core/gestures/swipe.d.ts +20 -0
  28. package/dist/core/gestures/tap.d.ts +12 -0
  29. package/dist/core/gestures/types.d.ts +320 -0
  30. package/dist/core/gestures/utils.d.ts +57 -0
  31. package/dist/core/index.d.ts +13 -0
  32. package/dist/core/layout/config.d.ts +33 -0
  33. package/dist/core/layout/index.d.ts +51 -0
  34. package/dist/core/layout/jsx.d.ts +65 -0
  35. package/dist/core/layout/schema.d.ts +112 -0
  36. package/dist/core/layout/types.d.ts +69 -0
  37. package/dist/core/viewport/constants.d.ts +105 -0
  38. package/dist/core/viewport/features/base.d.ts +14 -0
  39. package/dist/core/viewport/features/collection.d.ts +41 -0
  40. package/dist/core/viewport/features/events.d.ts +13 -0
  41. package/{src/core/viewport/features/index.ts → dist/core/viewport/features/index.d.ts} +0 -7
  42. package/dist/core/viewport/features/item-size.d.ts +30 -0
  43. package/dist/core/viewport/features/loading.d.ts +34 -0
  44. package/dist/core/viewport/features/momentum.d.ts +17 -0
  45. package/dist/core/viewport/features/performance.d.ts +53 -0
  46. package/dist/core/viewport/features/placeholders.d.ts +38 -0
  47. package/dist/core/viewport/features/rendering.d.ts +16 -0
  48. package/dist/core/viewport/features/scrollbar.d.ts +26 -0
  49. package/dist/core/viewport/features/scrolling.d.ts +16 -0
  50. package/dist/core/viewport/features/utils.d.ts +43 -0
  51. package/dist/core/viewport/features/virtual.d.ts +18 -0
  52. package/{src/core/viewport/index.ts → dist/core/viewport/index.d.ts} +1 -17
  53. package/dist/core/viewport/types.d.ts +96 -0
  54. package/dist/core/viewport/utils/speed-tracker.d.ts +22 -0
  55. package/dist/core/viewport/viewport.d.ts +11 -0
  56. package/{src/index.ts → dist/index.d.ts} +0 -4
  57. package/dist/index.js +5143 -0
  58. package/dist/index.mjs +5111 -0
  59. package/dist/styles.css +254 -0
  60. package/dist/styles.css.map +1 -0
  61. package/package.json +5 -1
  62. package/.cursorrules +0 -117
  63. package/AI.md +0 -39
  64. package/CLAUDE.md +0 -882
  65. package/build.js +0 -377
  66. package/scripts/analyze-orphaned-functions.ts +0 -387
  67. package/scripts/debug/vlist-selection.ts +0 -121
  68. package/src/components/vlist/config.ts +0 -323
  69. package/src/components/vlist/features/api.ts +0 -626
  70. package/src/components/vlist/features/selection.ts +0 -436
  71. package/src/components/vlist/features/viewport.ts +0 -59
  72. package/src/components/vlist/features.ts +0 -112
  73. package/src/components/vlist/types.ts +0 -723
  74. package/src/components/vlist/vlist.ts +0 -92
  75. package/src/core/compose/features/gestures/index.ts +0 -227
  76. package/src/core/compose/features/gestures/longpress.ts +0 -383
  77. package/src/core/compose/features/gestures/pan.ts +0 -424
  78. package/src/core/compose/features/gestures/pinch.ts +0 -475
  79. package/src/core/compose/features/gestures/rotate.ts +0 -485
  80. package/src/core/compose/features/gestures/swipe.ts +0 -492
  81. package/src/core/compose/features/gestures/tap.ts +0 -334
  82. package/src/core/gestures/longpress.ts +0 -68
  83. package/src/core/gestures/manager.ts +0 -418
  84. package/src/core/gestures/pan.ts +0 -48
  85. package/src/core/gestures/pinch.ts +0 -58
  86. package/src/core/gestures/rotate.ts +0 -58
  87. package/src/core/gestures/swipe.ts +0 -66
  88. package/src/core/gestures/tap.ts +0 -45
  89. package/src/core/gestures/types.ts +0 -387
  90. package/src/core/gestures/utils.ts +0 -128
  91. package/src/core/index.ts +0 -43
  92. package/src/core/layout/config.ts +0 -102
  93. package/src/core/layout/index.ts +0 -168
  94. package/src/core/layout/jsx.ts +0 -174
  95. package/src/core/layout/schema.ts +0 -1044
  96. package/src/core/layout/types.ts +0 -95
  97. package/src/core/viewport/constants.ts +0 -145
  98. package/src/core/viewport/features/base.ts +0 -73
  99. package/src/core/viewport/features/collection.ts +0 -1182
  100. package/src/core/viewport/features/events.ts +0 -130
  101. package/src/core/viewport/features/item-size.ts +0 -271
  102. package/src/core/viewport/features/loading.ts +0 -263
  103. package/src/core/viewport/features/momentum.ts +0 -269
  104. package/src/core/viewport/features/performance.ts +0 -161
  105. package/src/core/viewport/features/placeholders.ts +0 -335
  106. package/src/core/viewport/features/rendering.ts +0 -962
  107. package/src/core/viewport/features/scrollbar.ts +0 -434
  108. package/src/core/viewport/features/scrolling.ts +0 -634
  109. package/src/core/viewport/features/utils.ts +0 -94
  110. package/src/core/viewport/features/virtual.ts +0 -525
  111. package/src/core/viewport/types.ts +0 -133
  112. package/src/core/viewport/utils/speed-tracker.ts +0 -79
  113. package/src/core/viewport/viewport.ts +0 -265
  114. package/test/benchmarks/layout/advanced.test.ts +0 -656
  115. package/test/benchmarks/layout/comparison.test.ts +0 -519
  116. package/test/benchmarks/layout/performance-comparison.test.ts +0 -274
  117. package/test/benchmarks/layout/real-components.test.ts +0 -733
  118. package/test/benchmarks/layout/simple.test.ts +0 -321
  119. package/test/benchmarks/layout/stress.test.ts +0 -990
  120. package/test/collection/basic.test.ts +0 -304
  121. package/test/components/vlist-selection.test.ts +0 -240
  122. package/test/components/vlist.test.ts +0 -63
  123. package/test/core/collection/adapter.test.ts +0 -161
  124. package/test/core/collection/collection.test.ts +0 -394
  125. package/test/core/layout/layout.test.ts +0 -201
  126. package/test/utils/dom-helpers.ts +0 -275
  127. package/test/utils/performance-helpers.ts +0 -392
  128. package/tsconfig.json +0 -20
@@ -1,1182 +0,0 @@
1
- // src/core/viewport/features/collection.ts
2
-
3
- /**
4
- * Collection Feature - Data management and range loading
5
- * Handles collection integration, pagination, and data fetching
6
- */
7
-
8
- import type { ViewportContext, ViewportComponent } from "../types";
9
- import { VIEWPORT_CONSTANTS } from "../constants";
10
- import { wrapDestroy } from "./utils";
11
-
12
- export interface CollectionConfig {
13
- collection?: any; // Collection adapter
14
- rangeSize?: number; // Default range size for loading
15
- strategy?: "offset" | "page" | "cursor"; // Loading strategy
16
- transform?: (item: any) => any; // Item transformation function
17
- cancelLoadThreshold?: number; // Velocity threshold for cancelling loads
18
- maxConcurrentRequests?: number;
19
- enableRequestQueue?: boolean;
20
- maxQueueSize?: number;
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)
25
- }
26
-
27
- export interface CollectionComponent {
28
- collection: {
29
- loadRange: (offset: number, limit: number) => Promise<any[]>;
30
- loadMissingRanges: (
31
- range: { start: number; end: number },
32
- caller?: string,
33
- ) => Promise<void>;
34
- getLoadedRanges: () => Set<number>;
35
- getPendingRanges: () => Set<number>;
36
- clearFailedRanges: () => void;
37
- retryFailedRange: (rangeId: number) => Promise<any[]>;
38
- setTotalItems: (total: number) => void;
39
- getTotalItems: () => number;
40
- // Cursor-specific methods
41
- getCurrentCursor: () => string | null;
42
- getCursorForPage: (page: number) => string | null;
43
- };
44
- }
45
-
46
- /**
47
- * Adds collection functionality to viewport component
48
- */
49
- export function withCollection(config: CollectionConfig = {}) {
50
- return <T extends ViewportContext & ViewportComponent>(
51
- component: T,
52
- ): T & CollectionComponent => {
53
- const {
54
- collection,
55
- rangeSize = VIEWPORT_CONSTANTS.LOADING.DEFAULT_RANGE_SIZE,
56
- strategy = "offset",
57
- transform,
58
- cancelLoadThreshold = VIEWPORT_CONSTANTS.LOADING.CANCEL_THRESHOLD,
59
- maxConcurrentRequests = VIEWPORT_CONSTANTS.LOADING
60
- .MAX_CONCURRENT_REQUESTS,
61
- enableRequestQueue = VIEWPORT_CONSTANTS.REQUEST_QUEUE.ENABLED,
62
- maxQueueSize = VIEWPORT_CONSTANTS.REQUEST_QUEUE.MAX_QUEUE_SIZE,
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
67
- } = config;
68
-
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
- // });
79
-
80
- // Loading manager state
81
- interface QueuedRequest {
82
- range: { start: number; end: number };
83
- priority: "high" | "normal" | "low";
84
- timestamp: number;
85
- resolve: () => void;
86
- reject: (error: any) => void;
87
- }
88
-
89
- // State tracking
90
- let currentVelocity = 0;
91
- let activeLoadCount = 0;
92
- let completedLoads = 0;
93
- let failedLoads = 0;
94
- let cancelledLoads = 0;
95
- let isDragging = false; // Track drag state
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
-
160
- const activeLoadRanges = new Set<string>();
161
- let loadRequestQueue: QueuedRequest[] = [];
162
-
163
- // AbortController map for cancelling in-flight requests
164
- const abortControllers = new Map<number, AbortController>();
165
-
166
- // State
167
- let items: any[] = [];
168
- let totalItems = 0;
169
- let loadedRanges = new Set<number>();
170
- let pendingRanges = new Set<number>();
171
- let failedRanges = new Map<
172
- number,
173
- {
174
- attempts: number;
175
- lastError: Error;
176
- timestamp: number;
177
- }
178
- >();
179
- let activeRequests = new Map<number, Promise<any[]>>();
180
-
181
- // Cursor pagination state
182
- let currentCursor: string | null = null;
183
- let cursorMap = new Map<number, string>(); // Map page number to cursor
184
- let pageToOffsetMap = new Map<number, number>(); // Map page to actual offset
185
- let highestLoadedPage = 0;
186
- let discoveredTotal: number | null = null; // Track discovered total from API
187
- let hasReachedEnd = false; // Track if we've reached the end of data
188
-
189
- // Share items array with component
190
- component.items = items;
191
-
192
- /**
193
- * Get a unique ID for a range based on offset and the base range size
194
- */
195
- const getRangeId = (offset: number, limit: number): number => {
196
- // Always use the base rangeSize for consistent IDs
197
- // This ensures merged ranges can be tracked properly
198
- return Math.floor(offset / rangeSize);
199
- };
200
-
201
- // Loading manager helpers
202
- const getRangeKey = (range: { start: number; end: number }): string => {
203
- return `${range.start}-${range.end}`;
204
- };
205
-
206
- const canLoad = (): boolean => {
207
- return currentVelocity <= cancelLoadThreshold;
208
- };
209
-
210
- const processQueue = () => {
211
- if (!enableRequestQueue || loadRequestQueue.length === 0) return;
212
-
213
- // Sort queue by priority and timestamp
214
- loadRequestQueue.sort((a, b) => {
215
- const priorityOrder = { high: 0, normal: 1, low: 2 };
216
- const priorityDiff =
217
- priorityOrder[a.priority] - priorityOrder[b.priority];
218
- return priorityDiff !== 0 ? priorityDiff : a.timestamp - b.timestamp;
219
- });
220
-
221
- // Process requests up to capacity
222
- while (
223
- loadRequestQueue.length > 0 &&
224
- activeLoadCount < maxConcurrentRequests
225
- ) {
226
- const request = loadRequestQueue.shift();
227
- if (request) {
228
- executeQueuedLoad(request);
229
- }
230
- }
231
- };
232
-
233
- const executeQueuedLoad = (request: QueuedRequest) => {
234
- activeLoadCount++;
235
- activeLoadRanges.add(getRangeKey(request.range));
236
-
237
- // Call the actual loadMissingRanges function
238
- loadMissingRangesInternal(request.range)
239
- .then(() => {
240
- request.resolve();
241
- completedLoads++;
242
- })
243
- .catch((error: Error) => {
244
- request.reject(error);
245
- failedLoads++;
246
- })
247
- .finally(() => {
248
- activeLoadCount--;
249
- activeLoadRanges.delete(getRangeKey(request.range));
250
- processQueue();
251
- });
252
- };
253
-
254
- /**
255
- * Transform items if transform function provided
256
- */
257
- const transformItems = (rawItems: any[]): any[] => {
258
- if (!transform) return rawItems;
259
- return rawItems.map(transform);
260
- };
261
-
262
- /**
263
- * Load a range of data
264
- */
265
- const loadRange = async (offset: number, limit: number): Promise<any[]> => {
266
- // console.log(
267
- // `[Collection] loadRange called: offset=${offset}, limit=${limit}`,
268
- // );
269
-
270
- if (!collection) {
271
- console.warn("[Collection] No collection adapter configured");
272
- return [];
273
- }
274
-
275
- const rangeId = getRangeId(offset, limit);
276
- // console.log(`[Collection] Range ID: ${rangeId}`);
277
-
278
- // Check if already loaded
279
- if (loadedRanges.has(rangeId)) {
280
- // console.log(
281
- // `[Collection] Range ${rangeId} already loaded, returning cached data`,
282
- // );
283
- return items.slice(offset, offset + limit);
284
- }
285
-
286
- // Check if already pending
287
- if (pendingRanges.has(rangeId)) {
288
- // console.log(`[Collection] Range ${rangeId} already pending`);
289
- const existingRequest = activeRequests.get(rangeId);
290
- if (existingRequest) {
291
- // console.log(
292
- // `[Collection] Returning existing request for range ${rangeId}`,
293
- // );
294
- return existingRequest;
295
- }
296
- }
297
-
298
- // Mark as pending
299
- // console.log(
300
- // `[Collection] Marking range ${rangeId} as pending and loading...`,
301
- // );
302
- pendingRanges.add(rangeId);
303
-
304
- // Create AbortController for this request
305
- const abortController = new AbortController();
306
- abortControllers.set(rangeId, abortController);
307
-
308
- // Create request promise
309
- const requestPromise = (async () => {
310
- try {
311
- // Call collection adapter with appropriate parameters
312
- const page = Math.floor(offset / limit) + 1;
313
- let params: any;
314
-
315
- if (strategy === "cursor") {
316
- // For cursor pagination
317
- if (page === 1) {
318
- // First page - no cursor
319
- params = { limit };
320
- } else {
321
- // Check if we have cursor for previous page
322
- const prevPageCursor = cursorMap.get(page - 1);
323
- if (!prevPageCursor) {
324
- // Can't load this page without previous cursor
325
- console.warn(
326
- `[Collection] Cannot load page ${page} without cursor for page ${
327
- page - 1
328
- }`,
329
- );
330
- throw new Error(
331
- `Sequential loading required - missing cursor for page ${
332
- page - 1
333
- }`,
334
- );
335
- }
336
- params = { cursor: prevPageCursor, limit };
337
- }
338
- } else if (strategy === "page") {
339
- params = { page, limit };
340
- } else {
341
- // offset strategy
342
- params = { offset, limit };
343
- }
344
-
345
- // console.log(
346
- // `[Viewport Collection] Loading range offset=${offset}, limit=${limit}, strategy=${strategy}, calculated page=${page}, params:`,
347
- // JSON.stringify(params)
348
- // );
349
-
350
- // Pass abort signal to the adapter if it supports it
351
- const response = await collection.read({
352
- ...params,
353
- signal: abortController.signal,
354
- });
355
-
356
- // Extract items and total
357
- const rawItems = response.data || response.items || response;
358
- const meta = response.meta || {};
359
-
360
- // For cursor pagination, track the cursor (check both cursor and nextCursor)
361
- const responseCursor = meta.cursor || meta.nextCursor;
362
- if (strategy === "cursor" && responseCursor) {
363
- currentCursor = responseCursor;
364
- cursorMap.set(page, responseCursor);
365
- pageToOffsetMap.set(page, offset);
366
- highestLoadedPage = Math.max(highestLoadedPage, page);
367
- // console.log(
368
- // `[Collection] Stored cursor for page ${page}: ${responseCursor}`,
369
- // );
370
- }
371
-
372
- // Check if we've reached the end
373
- if (strategy === "cursor" && meta.hasNext === false) {
374
- hasReachedEnd = true;
375
- // console.log(
376
- // `[Collection] Reached end of cursor pagination at page ${page}`,
377
- // );
378
- }
379
-
380
- // Update discovered total if provided
381
- // console.log(
382
- // `[Collection] meta.total: ${meta.total}, discoveredTotal before: ${discoveredTotal}`,
383
- // );
384
- if (meta.total !== undefined) {
385
- discoveredTotal = meta.total;
386
- }
387
- // console.log(`[Collection] discoveredTotal after: ${discoveredTotal}`);
388
-
389
- // Transform items
390
- const transformedItems = transformItems(rawItems);
391
-
392
- // Add items to array
393
- transformedItems.forEach((item, index) => {
394
- items[offset + index] = item;
395
- });
396
-
397
- // For cursor strategy, calculate dynamic total based on loaded data
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
- // );
414
-
415
- if (strategy === "cursor") {
416
- // Calculate total based on loaded items + margin
417
- const loadedItemsCount = items.filter(
418
- (item) => item !== undefined,
419
- ).length;
420
- const marginItems = hasReachedEnd
421
- ? 0
422
- : rangeSize *
423
- VIEWPORT_CONSTANTS.PAGINATION.CURSOR_SCROLL_MARGIN_MULTIPLIER;
424
- const minVirtualItems =
425
- rangeSize *
426
- VIEWPORT_CONSTANTS.PAGINATION.CURSOR_MIN_VIRTUAL_SIZE_MULTIPLIER;
427
-
428
- // Dynamic total: loaded items + margin (unless we've reached the end)
429
- newTotal = Math.max(
430
- loadedItemsCount + marginItems,
431
- minVirtualItems,
432
- );
433
-
434
- // console.log(
435
- // `[Collection] Cursor mode virtual size: loaded=${loadedItemsCount}, margin=${marginItems}, total=${newTotal}, hasReachedEnd=${hasReachedEnd}`,
436
- // );
437
-
438
- // Update total if it has grown
439
- if (newTotal > totalItems) {
440
- // console.log(
441
- // `[Collection] Updating cursor virtual size from ${totalItems} to ${newTotal}`,
442
- // );
443
- totalItems = newTotal;
444
- setTotalItems(newTotal);
445
- }
446
- } else {
447
- // For other strategies, use discovered total or current total
448
- // Use nullish coalescing (??) instead of || to handle discoveredTotal = 0 correctly
449
- newTotal = discoveredTotal ?? totalItems;
450
- }
451
-
452
- // Update state
453
- // console.log(
454
- // `[Collection] Before state update: newTotal=${newTotal}, totalItems=${totalItems}, will update: ${newTotal !== totalItems}`,
455
- // );
456
- if (newTotal !== totalItems) {
457
- // console.log(`[Collection] Calling setTotalItems(${newTotal})`);
458
- totalItems = newTotal;
459
- setTotalItems(newTotal);
460
- }
461
-
462
- // Update component items reference
463
- component.items = items;
464
-
465
- // Emit items changed event
466
- component.emit?.("viewport:items-changed", {
467
- totalItems: newTotal,
468
- loadedCount: items.filter((item) => item !== undefined).length,
469
- });
470
-
471
- // Update viewport state
472
- const viewportState = (component.viewport as any).state;
473
- if (viewportState) {
474
- // Use nullish coalescing (??) instead of || to handle newTotal = 0 correctly
475
- viewportState.totalItems = newTotal ?? items.length;
476
- }
477
-
478
- // Mark as loaded
479
- loadedRanges.add(rangeId);
480
- pendingRanges.delete(rangeId);
481
- failedRanges.delete(rangeId);
482
-
483
- // console.log(
484
- // `[Collection] Range ${rangeId} loaded successfully with ${transformedItems.length} items`
485
- // );
486
-
487
- // Log memory stats periodically (every 5 loads)
488
- if (completedLoads % 5 === 0) {
489
- logMemoryStats(`load:${completedLoads}`);
490
- }
491
-
492
- // Emit events
493
- component.emit?.("viewport:range-loaded", {
494
- offset,
495
- limit,
496
- items: transformedItems,
497
- total: newTotal,
498
- });
499
-
500
- // Emit collection loaded event with more details
501
- component.emit?.("collection:range-loaded", {
502
- offset,
503
- limit,
504
- items: transformedItems,
505
- total: newTotal,
506
- rangeId,
507
- itemsInMemory: items.filter((item) => item !== undefined).length,
508
- cursor: strategy === "cursor" ? currentCursor : undefined,
509
- });
510
-
511
- // Trigger viewport update
512
- component.viewport?.updateViewport?.();
513
-
514
- return transformedItems;
515
- } catch (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
530
- pendingRanges.delete(rangeId);
531
- failedLoads++;
532
-
533
- const attempts = (failedRanges.get(rangeId)?.attempts || 0) + 1;
534
- failedRanges.set(rangeId, {
535
- attempts,
536
- lastError: error as Error,
537
- timestamp: Date.now(),
538
- });
539
-
540
- // Emit error event
541
- component.emit?.("viewport:range-error", {
542
- offset,
543
- limit,
544
- error,
545
- attempts,
546
- });
547
-
548
- throw error;
549
- } finally {
550
- activeRequests.delete(rangeId);
551
- abortControllers.delete(rangeId);
552
- }
553
- })();
554
-
555
- activeRequests.set(rangeId, requestPromise);
556
- return requestPromise;
557
- };
558
-
559
- /**
560
- * Load missing ranges from collection
561
- */
562
- const loadMissingRangesInternal = async (range: {
563
- start: number;
564
- end: number;
565
- }): Promise<void> => {
566
- if (!collection) return;
567
-
568
- // console.log(
569
- // `[Collection] loadMissingRangesInternal - range: ${start}-${end}, strategy: ${strategy}`,
570
- // );
571
-
572
- // For cursor pagination, we need to load sequentially
573
- if (strategy === "cursor") {
574
- const startPage = Math.floor(range.start / rangeSize) + 1;
575
- const endPage = Math.floor(range.end / rangeSize) + 1;
576
-
577
- // Limit how many pages we'll load at once
578
- const maxPagesToLoad = 10;
579
- const currentHighestPage = highestLoadedPage || 0;
580
- const limitedEndPage = Math.min(
581
- endPage,
582
- currentHighestPage + maxPagesToLoad,
583
- );
584
-
585
- // console.log(
586
- // `[Collection] Cursor mode: need to load pages ${startPage} to ${endPage}, limited to ${limitedEndPage}`,
587
- // );
588
-
589
- // Check if we need to load pages sequentially
590
- for (let page = startPage; page <= limitedEndPage; page++) {
591
- const rangeId = page - 1; // Convert to 0-based rangeId
592
- const offset = rangeId * rangeSize;
593
-
594
- // Skip if already being loaded
595
- if (pendingRanges.has(rangeId)) {
596
- // Range already being loaded
597
- return;
598
- }
599
-
600
- if (!loadedRanges.has(rangeId) && !pendingRanges.has(rangeId)) {
601
- // For cursor pagination, we must load sequentially
602
- // Check if we have all previous pages loaded
603
- if (page > 1) {
604
- let canLoad = true;
605
- for (let prevPage = 1; prevPage < page; prevPage++) {
606
- const prevRangeId = prevPage - 1;
607
- if (!loadedRanges.has(prevRangeId)) {
608
- // console.log(
609
- // `[Collection] Cannot load page ${page} - need to load page ${prevPage} first`,
610
- // );
611
- canLoad = false;
612
-
613
- // Try to load the missing page
614
- if (!pendingRanges.has(prevRangeId)) {
615
- try {
616
- await loadRange(prevRangeId * rangeSize, rangeSize);
617
- } catch (error) {
618
- console.error(
619
- `[Collection] Failed to load prerequisite page ${prevPage}:`,
620
- error,
621
- );
622
- return; // Stop trying to load further pages
623
- }
624
- }
625
- break;
626
- }
627
- }
628
-
629
- if (!canLoad) {
630
- continue; // Skip this page for now
631
- }
632
- }
633
-
634
- try {
635
- await loadRange(offset, rangeSize);
636
- } catch (error) {
637
- console.error(`[Collection] Failed to load page ${page}:`, error);
638
- break; // Stop sequential loading on error
639
- }
640
- }
641
- }
642
-
643
- if (endPage > limitedEndPage) {
644
- // console.log(
645
- // `[Collection] Stopped at page ${limitedEndPage} to prevent excessive loading (requested up to ${endPage})`,
646
- // );
647
- }
648
-
649
- return;
650
- }
651
-
652
- // Original logic for offset/page strategies
653
- // Calculate range boundaries
654
- const startRange = Math.floor(range.start / rangeSize);
655
- const endRange = Math.floor(range.end / rangeSize);
656
-
657
- // console.log(
658
- // `[Collection] page strategy - startRange: ${startRange}, endRange: ${endRange}, loadedRanges: [${Array.from(loadedRanges).join(", ")}]`,
659
- // );
660
-
661
- // Collect ranges that need loading
662
- const rangesToLoad: number[] = [];
663
- for (let rangeId = startRange; rangeId <= endRange; rangeId++) {
664
- if (!loadedRanges.has(rangeId) && !pendingRanges.has(rangeId)) {
665
- rangesToLoad.push(rangeId);
666
- }
667
- }
668
-
669
- // console.log(
670
- // `[Collection] rangesToLoad: [${rangesToLoad.join(", ")}], pendingRanges: [${Array.from(pendingRanges).join(", ")}]`,
671
- // );
672
-
673
- if (rangesToLoad.length === 0) {
674
- // console.log(`[Collection] No ranges to load - all loaded or pending`);
675
- // All ranges are already loaded or pending
676
- // Check if there are queued requests we should process
677
- if (
678
- loadRequestQueue.length > 0 &&
679
- activeLoadCount < maxConcurrentRequests
680
- ) {
681
- processQueue();
682
- }
683
- return;
684
- }
685
-
686
- // console.log(
687
- // `[Collection] Loading ${rangesToLoad.length} ranges: [${rangesToLoad.join(", ")}]`,
688
- // );
689
-
690
- // Load ranges individually - no merging to avoid loading old ranges
691
- const promises = rangesToLoad.map((rangeId) =>
692
- loadRange(rangeId * rangeSize, rangeSize),
693
- );
694
- await Promise.allSettled(promises);
695
- };
696
-
697
- /**
698
- * Velocity-aware wrapper for loadMissingRanges
699
- */
700
- const loadMissingRanges = (
701
- range: {
702
- start: number;
703
- end: number;
704
- },
705
- caller?: string,
706
- ): Promise<void> => {
707
- return new Promise((resolve, reject) => {
708
- const rangeKey = getRangeKey(range);
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
- // );
716
-
717
- // Check if already loading
718
- if (activeLoadRanges.has(rangeKey)) {
719
- // console.log(`[Collection] Range already being loaded, skipping`);
720
- // Range already being loaded
721
- resolve();
722
- return;
723
- }
724
-
725
- // Skip if dragging with low velocity (but not if we're idle)
726
- if (isDragging && currentVelocity < 0.5 && currentVelocity > 0) {
727
- // console.log(
728
- // "[Collection] Load skipped - actively dragging with low velocity"
729
- // );
730
- cancelledLoads++;
731
- resolve();
732
- return;
733
- }
734
-
735
- // Check velocity - if too high, cancel the request entirely
736
- if (!canLoad()) {
737
- // console.log(
738
- // `[Collection] Load cancelled - velocity ${currentVelocity.toFixed(
739
- // 2
740
- // )} exceeds threshold ${cancelLoadThreshold}`
741
- // );
742
- cancelledLoads++;
743
- resolve();
744
- return;
745
- }
746
-
747
- // Check capacity
748
- if (activeLoadCount < maxConcurrentRequests) {
749
- // Execute immediately
750
- executeQueuedLoad({
751
- range,
752
- priority: "normal",
753
- timestamp: Date.now(),
754
- resolve,
755
- reject,
756
- });
757
- } else if (
758
- enableRequestQueue &&
759
- loadRequestQueue.length < maxQueueSize
760
- ) {
761
- // Queue the request
762
- loadRequestQueue.push({
763
- range,
764
- priority: "normal",
765
- timestamp: Date.now(),
766
- resolve,
767
- reject,
768
- });
769
- // console.log(
770
- // `[LoadingManager] Queued request (at capacity), queue size: ${loadRequestQueue.length}`
771
- // );
772
- } else {
773
- // Queue overflow - resolve to avoid errors
774
- if (loadRequestQueue.length >= maxQueueSize) {
775
- const removed = loadRequestQueue.splice(
776
- 0,
777
- loadRequestQueue.length - maxQueueSize,
778
- );
779
- removed.forEach((r) => {
780
- cancelledLoads++;
781
- r.resolve();
782
- });
783
- }
784
- resolve();
785
- }
786
- });
787
- };
788
-
789
- /**
790
- * Retry a failed range
791
- */
792
- const retryFailedRange = async (rangeId: number): Promise<any[]> => {
793
- failedRanges.delete(rangeId);
794
- const offset = rangeId * rangeSize;
795
- return loadRange(offset, rangeSize);
796
- };
797
-
798
- /**
799
- * Set total items count
800
- */
801
- const setTotalItems = (total: number): void => {
802
- totalItems = total;
803
-
804
- // Update viewport state's total items
805
- const viewportState = (component.viewport as any).state;
806
- if (viewportState) {
807
- viewportState.totalItems = total;
808
- }
809
-
810
- component.emit?.("viewport:total-items-changed", { total });
811
- };
812
-
813
- // Hook into viewport initialization
814
- const originalInitialize = component.viewport.initialize;
815
- component.viewport.initialize = () => {
816
- const result = originalInitialize();
817
- // Skip if already initialized
818
- if (result === false) {
819
- return false;
820
- }
821
-
822
- // Set initial total if provided
823
- if (component.totalItems) {
824
- totalItems = component.totalItems;
825
- }
826
-
827
- // Listen for drag events
828
- component.on?.("viewport:drag-start", () => {
829
- isDragging = true;
830
- });
831
-
832
- component.on?.("viewport:drag-end", () => {
833
- isDragging = false;
834
- // Process any queued requests after drag ends (safety measure)
835
- // This ensures placeholders are replaced even if idle detection fails
836
- if (loadOnDragEnd) {
837
- processQueue();
838
- }
839
- });
840
-
841
- // Listen for range changes
842
- component.on?.("viewport:range-changed", async (data: any) => {
843
- // Don't load during fast scrolling - loadMissingRanges will handle velocity check
844
-
845
- // Extract range from event data - virtual feature emits { range: { start, end }, scrollPosition }
846
- const range = data.range || data;
847
- const { start, end } = range;
848
-
849
- // Validate range before loading
850
- if (typeof start !== "number" || typeof end !== "number") {
851
- console.warn(
852
- "[Collection] Invalid range in viewport:range-changed event:",
853
- data,
854
- );
855
- return;
856
- }
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
-
869
- // Load missing ranges if needed
870
- await loadMissingRanges({ start, end }, "viewport:range-changed");
871
-
872
- // Evict distant items to prevent memory bloat
873
- evictDistantItems(start, end);
874
-
875
- // For cursor mode, check if we need to update virtual size
876
- if (strategy === "cursor" && !hasReachedEnd) {
877
- const loadedItemsCount = items.filter(
878
- (item) => item !== undefined,
879
- ).length;
880
- const marginItems =
881
- rangeSize *
882
- VIEWPORT_CONSTANTS.PAGINATION.CURSOR_SCROLL_MARGIN_MULTIPLIER;
883
- const minVirtualItems =
884
- rangeSize *
885
- VIEWPORT_CONSTANTS.PAGINATION.CURSOR_MIN_VIRTUAL_SIZE_MULTIPLIER;
886
- const dynamicTotal = Math.max(
887
- loadedItemsCount + marginItems,
888
- minVirtualItems,
889
- );
890
-
891
- if (dynamicTotal !== totalItems) {
892
- // console.log(
893
- // `[Collection] Updating cursor virtual size from ${totalItems} to ${dynamicTotal}`,
894
- // );
895
- setTotalItems(dynamicTotal);
896
- }
897
- }
898
- });
899
-
900
- // Listen for velocity changes
901
- component.on?.("viewport:velocity-changed", (data: any) => {
902
- const previousVelocity = currentVelocity;
903
- currentVelocity = Math.abs(data.velocity || 0);
904
-
905
- // When velocity drops below threshold, process queue
906
- if (
907
- previousVelocity > cancelLoadThreshold &&
908
- currentVelocity <= cancelLoadThreshold
909
- ) {
910
- processQueue();
911
- }
912
- });
913
-
914
- // Listen for idle state to process queue
915
- component.on?.("viewport:idle", async (data: any) => {
916
- //console.log("[Collection] Idle event received, velocity=0");
917
- currentVelocity = 0;
918
-
919
- // Reset dragging state on idle since user has stopped moving
920
- if (isDragging) {
921
- // console.log("[Collection] Resetting drag state on idle");
922
- isDragging = false;
923
- }
924
-
925
- // Get current visible range from viewport
926
- const viewportState = (component.viewport as any).state;
927
- const visibleRange = viewportState?.visibleRange;
928
-
929
- if (visibleRange) {
930
- // console.log(
931
- // `[Collection] Loading visible range on idle: ${visibleRange.start}-${visibleRange.end}`
932
- // );
933
-
934
- // Clear stale requests from queue that are far from current visible range
935
- const buffer = rangeSize * 2; // Allow some buffer
936
- loadRequestQueue = loadRequestQueue.filter((request) => {
937
- const requestEnd = request.range.end;
938
- const requestStart = request.range.start;
939
- const isRelevant =
940
- requestEnd >= visibleRange.start - buffer &&
941
- requestStart <= visibleRange.end + buffer;
942
-
943
- if (!isRelevant) {
944
- // console.log(
945
- // `[Collection] Removing stale queued request: ${requestStart}-${requestEnd}`,
946
- // );
947
- request.resolve(); // Resolve to avoid hanging promises
948
- }
949
- return isRelevant;
950
- });
951
-
952
- // Load the current visible range if needed
953
- await loadMissingRanges(visibleRange, "viewport:idle");
954
- }
955
-
956
- // Also process any queued requests
957
- processQueue();
958
- });
959
-
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
1053
- loadRange(0, rangeSize)
1054
- .then(() => {
1055
- // console.log("[Collection] Initial data loaded");
1056
- })
1057
- .catch((error) => {
1058
- console.error("[Collection] Failed to load initial data:", error);
1059
- });
1060
- }
1061
-
1062
- return result;
1063
- };
1064
-
1065
- // Add collection API to viewport
1066
- component.viewport.collection = {
1067
- loadRange: (offset: number, limit: number) => loadRange(offset, limit),
1068
- loadMissingRanges: (
1069
- range: { start: number; end: number },
1070
- caller?: string,
1071
- ) => loadMissingRanges(range, caller || "viewport.collection"),
1072
- getLoadedRanges: () => loadedRanges,
1073
- getPendingRanges: () => pendingRanges,
1074
- clearFailedRanges: () => failedRanges.clear(),
1075
- retryFailedRange,
1076
- setTotalItems,
1077
- getTotalItems: () => totalItems,
1078
- // Cursor-specific methods
1079
- getCurrentCursor: () => currentCursor,
1080
- getCursorForPage: (page: number) => cursorMap.get(page) || null,
1081
- };
1082
-
1083
- // Add collection data access with direct assignment
1084
- (component as any).collection = {
1085
- items,
1086
- getItems: () => items,
1087
- getItem: (index: number) => items[index],
1088
- loadRange,
1089
- loadMissingRanges: (
1090
- range: { start: number; end: number },
1091
- caller?: string,
1092
- ) => loadMissingRanges(range, caller || "component.collection"),
1093
- getLoadingStats: () => ({
1094
- pendingRequests: activeLoadCount,
1095
- completedRequests: completedLoads,
1096
- failedRequests: failedLoads,
1097
- cancelledRequests: cancelledLoads,
1098
- currentVelocity,
1099
- canLoad: canLoad(),
1100
- queuedRequests: loadRequestQueue.length,
1101
- }),
1102
- // Total items management
1103
- getTotalItems: () => totalItems,
1104
- setTotalItems,
1105
- // Cursor methods
1106
- getCurrentCursor: () => currentCursor,
1107
- getCursorForPage: (page: number) => cursorMap.get(page) || null,
1108
- };
1109
-
1110
- // Also ensure component.items is updated
1111
- component.items = items;
1112
-
1113
- // Cleanup function - comprehensive memory cleanup
1114
- const destroy = () => {
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();
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");
1151
- };
1152
-
1153
- // Wire destroy into the component's destroy chain
1154
- wrapDestroy(component, destroy);
1155
-
1156
- // Return enhanced component
1157
- return {
1158
- ...component,
1159
- collection: {
1160
- // Data access methods
1161
- items,
1162
- getItems: () => items,
1163
- getItem: (index: number) => items[index],
1164
- // Loading methods
1165
- loadRange,
1166
- loadMissingRanges: (
1167
- range: { start: number; end: number },
1168
- caller?: string,
1169
- ) => loadMissingRanges(range, caller || "return.collection"),
1170
- getLoadedRanges: () => loadedRanges,
1171
- getPendingRanges: () => pendingRanges,
1172
- clearFailedRanges: () => failedRanges.clear(),
1173
- retryFailedRange,
1174
- setTotalItems,
1175
- getTotalItems: () => totalItems,
1176
- // Cursor-specific methods
1177
- getCurrentCursor: () => currentCursor,
1178
- getCursorForPage: (page: number) => cursorMap.get(page) || null,
1179
- },
1180
- };
1181
- };
1182
- }