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.
- package/{src/components/index.ts → dist/components/index.d.ts} +0 -2
- package/dist/components/vlist/config.d.ts +86 -0
- package/{src/components/vlist/constants.ts → dist/components/vlist/constants.d.ts} +10 -11
- package/dist/components/vlist/features/api.d.ts +7 -0
- package/{src/components/vlist/features/index.ts → dist/components/vlist/features/index.d.ts} +0 -2
- package/dist/components/vlist/features/selection.d.ts +6 -0
- package/dist/components/vlist/features/viewport.d.ts +9 -0
- package/dist/components/vlist/features.d.ts +31 -0
- package/{src/components/vlist/index.ts → dist/components/vlist/index.d.ts} +1 -10
- package/dist/components/vlist/types.d.ts +596 -0
- package/dist/components/vlist/vlist.d.ts +29 -0
- package/dist/core/compose/features/gestures/index.d.ts +86 -0
- package/dist/core/compose/features/gestures/longpress.d.ts +85 -0
- package/dist/core/compose/features/gestures/pan.d.ts +108 -0
- package/dist/core/compose/features/gestures/pinch.d.ts +111 -0
- package/dist/core/compose/features/gestures/rotate.d.ts +111 -0
- package/dist/core/compose/features/gestures/swipe.d.ts +149 -0
- package/dist/core/compose/features/gestures/tap.d.ts +79 -0
- package/{src/core/compose/features/index.ts → dist/core/compose/features/index.d.ts} +1 -2
- package/{src/core/compose/index.ts → dist/core/compose/index.d.ts} +2 -11
- package/{src/core/gestures/index.ts → dist/core/gestures/index.d.ts} +1 -20
- package/dist/core/gestures/longpress.d.ts +23 -0
- package/dist/core/gestures/manager.d.ts +14 -0
- package/dist/core/gestures/pan.d.ts +12 -0
- package/dist/core/gestures/pinch.d.ts +14 -0
- package/dist/core/gestures/rotate.d.ts +14 -0
- package/dist/core/gestures/swipe.d.ts +20 -0
- package/dist/core/gestures/tap.d.ts +12 -0
- package/dist/core/gestures/types.d.ts +320 -0
- package/dist/core/gestures/utils.d.ts +57 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/layout/config.d.ts +33 -0
- package/dist/core/layout/index.d.ts +51 -0
- package/dist/core/layout/jsx.d.ts +65 -0
- package/dist/core/layout/schema.d.ts +112 -0
- package/dist/core/layout/types.d.ts +69 -0
- package/dist/core/viewport/constants.d.ts +105 -0
- package/dist/core/viewport/features/base.d.ts +14 -0
- package/dist/core/viewport/features/collection.d.ts +41 -0
- package/dist/core/viewport/features/events.d.ts +13 -0
- package/{src/core/viewport/features/index.ts → dist/core/viewport/features/index.d.ts} +0 -7
- package/dist/core/viewport/features/item-size.d.ts +30 -0
- package/dist/core/viewport/features/loading.d.ts +34 -0
- package/dist/core/viewport/features/momentum.d.ts +17 -0
- package/dist/core/viewport/features/performance.d.ts +53 -0
- package/dist/core/viewport/features/placeholders.d.ts +38 -0
- package/dist/core/viewport/features/rendering.d.ts +16 -0
- package/dist/core/viewport/features/scrollbar.d.ts +26 -0
- package/dist/core/viewport/features/scrolling.d.ts +16 -0
- package/dist/core/viewport/features/utils.d.ts +43 -0
- package/dist/core/viewport/features/virtual.d.ts +18 -0
- package/{src/core/viewport/index.ts → dist/core/viewport/index.d.ts} +1 -17
- package/dist/core/viewport/types.d.ts +96 -0
- package/dist/core/viewport/utils/speed-tracker.d.ts +22 -0
- package/dist/core/viewport/viewport.d.ts +11 -0
- package/{src/index.ts → dist/index.d.ts} +0 -4
- package/dist/index.js +5143 -0
- package/dist/index.mjs +5111 -0
- package/dist/styles.css +254 -0
- package/dist/styles.css.map +1 -0
- package/package.json +5 -1
- package/.cursorrules +0 -117
- package/AI.md +0 -39
- package/CLAUDE.md +0 -882
- package/build.js +0 -377
- package/scripts/analyze-orphaned-functions.ts +0 -387
- package/scripts/debug/vlist-selection.ts +0 -121
- package/src/components/vlist/config.ts +0 -323
- package/src/components/vlist/features/api.ts +0 -626
- package/src/components/vlist/features/selection.ts +0 -436
- package/src/components/vlist/features/viewport.ts +0 -59
- package/src/components/vlist/features.ts +0 -112
- package/src/components/vlist/types.ts +0 -723
- package/src/components/vlist/vlist.ts +0 -92
- package/src/core/compose/features/gestures/index.ts +0 -227
- package/src/core/compose/features/gestures/longpress.ts +0 -383
- package/src/core/compose/features/gestures/pan.ts +0 -424
- package/src/core/compose/features/gestures/pinch.ts +0 -475
- package/src/core/compose/features/gestures/rotate.ts +0 -485
- package/src/core/compose/features/gestures/swipe.ts +0 -492
- package/src/core/compose/features/gestures/tap.ts +0 -334
- package/src/core/gestures/longpress.ts +0 -68
- package/src/core/gestures/manager.ts +0 -418
- package/src/core/gestures/pan.ts +0 -48
- package/src/core/gestures/pinch.ts +0 -58
- package/src/core/gestures/rotate.ts +0 -58
- package/src/core/gestures/swipe.ts +0 -66
- package/src/core/gestures/tap.ts +0 -45
- package/src/core/gestures/types.ts +0 -387
- package/src/core/gestures/utils.ts +0 -128
- package/src/core/index.ts +0 -43
- package/src/core/layout/config.ts +0 -102
- package/src/core/layout/index.ts +0 -168
- package/src/core/layout/jsx.ts +0 -174
- package/src/core/layout/schema.ts +0 -1044
- package/src/core/layout/types.ts +0 -95
- package/src/core/viewport/constants.ts +0 -145
- package/src/core/viewport/features/base.ts +0 -73
- package/src/core/viewport/features/collection.ts +0 -1182
- package/src/core/viewport/features/events.ts +0 -130
- package/src/core/viewport/features/item-size.ts +0 -271
- package/src/core/viewport/features/loading.ts +0 -263
- package/src/core/viewport/features/momentum.ts +0 -269
- package/src/core/viewport/features/performance.ts +0 -161
- package/src/core/viewport/features/placeholders.ts +0 -335
- package/src/core/viewport/features/rendering.ts +0 -962
- package/src/core/viewport/features/scrollbar.ts +0 -434
- package/src/core/viewport/features/scrolling.ts +0 -634
- package/src/core/viewport/features/utils.ts +0 -94
- package/src/core/viewport/features/virtual.ts +0 -525
- package/src/core/viewport/types.ts +0 -133
- package/src/core/viewport/utils/speed-tracker.ts +0 -79
- package/src/core/viewport/viewport.ts +0 -265
- package/test/benchmarks/layout/advanced.test.ts +0 -656
- package/test/benchmarks/layout/comparison.test.ts +0 -519
- package/test/benchmarks/layout/performance-comparison.test.ts +0 -274
- package/test/benchmarks/layout/real-components.test.ts +0 -733
- package/test/benchmarks/layout/simple.test.ts +0 -321
- package/test/benchmarks/layout/stress.test.ts +0 -990
- package/test/collection/basic.test.ts +0 -304
- package/test/components/vlist-selection.test.ts +0 -240
- package/test/components/vlist.test.ts +0 -63
- package/test/core/collection/adapter.test.ts +0 -161
- package/test/core/collection/collection.test.ts +0 -394
- package/test/core/layout/layout.test.ts +0 -201
- package/test/utils/dom-helpers.ts +0 -275
- package/test/utils/performance-helpers.ts +0 -392
- 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
|
-
}
|