vlist 1.9.1 → 2.0.0

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 (182) hide show
  1. package/README.github.md +104 -97
  2. package/README.md +46 -33
  3. package/dist/constants.d.ts +11 -6
  4. package/dist/constants.js +83 -0
  5. package/dist/core/create.d.ts +10 -0
  6. package/dist/core/create.js +740 -0
  7. package/dist/core/dom.d.ts +8 -0
  8. package/dist/core/dom.js +47 -0
  9. package/dist/core/hooks.d.ts +16 -0
  10. package/dist/core/hooks.js +67 -0
  11. package/dist/core/index.d.ts +17 -0
  12. package/dist/core/index.js +13 -0
  13. package/dist/core/pipeline.d.ts +51 -0
  14. package/dist/core/pipeline.js +307 -0
  15. package/dist/core/pool.d.ts +9 -0
  16. package/dist/core/pool.js +42 -0
  17. package/dist/core/scroll.d.ts +32 -0
  18. package/dist/core/scroll.js +137 -0
  19. package/dist/core/sizes.d.ts +8 -0
  20. package/dist/core/sizes.js +6 -0
  21. package/dist/core/state.d.ts +47 -0
  22. package/dist/core/state.js +56 -0
  23. package/dist/core/types.d.ts +187 -0
  24. package/dist/core/types.js +7 -0
  25. package/dist/{builder → core}/velocity.d.ts +1 -1
  26. package/dist/core/velocity.js +33 -0
  27. package/dist/events/emitter.js +60 -0
  28. package/dist/events/index.js +6 -0
  29. package/dist/index.d.ts +28 -19
  30. package/dist/index.js +28 -1
  31. package/dist/internals.d.ts +11 -7
  32. package/dist/internals.js +60 -1
  33. package/dist/plugins/a11y/index.d.ts +2 -0
  34. package/dist/plugins/a11y/index.js +1 -0
  35. package/dist/plugins/a11y/plugin.d.ts +13 -0
  36. package/dist/plugins/a11y/plugin.js +259 -0
  37. package/dist/{features → plugins}/async/index.d.ts +1 -1
  38. package/dist/plugins/async/index.js +12 -0
  39. package/dist/{features → plugins}/async/manager.d.ts +5 -1
  40. package/dist/plugins/async/manager.js +568 -0
  41. package/dist/plugins/async/placeholder.js +154 -0
  42. package/dist/plugins/async/plugin.d.ts +48 -0
  43. package/dist/plugins/async/plugin.js +311 -0
  44. package/dist/plugins/async/sparse.js +540 -0
  45. package/dist/plugins/autosize/index.d.ts +5 -0
  46. package/dist/plugins/autosize/index.js +4 -0
  47. package/dist/plugins/autosize/plugin.d.ts +19 -0
  48. package/dist/plugins/autosize/plugin.js +185 -0
  49. package/dist/plugins/grid/index.d.ts +7 -0
  50. package/dist/plugins/grid/index.js +5 -0
  51. package/dist/plugins/grid/layout.js +275 -0
  52. package/dist/plugins/grid/plugin.d.ts +23 -0
  53. package/dist/plugins/grid/plugin.js +347 -0
  54. package/dist/plugins/grid/renderer.js +525 -0
  55. package/dist/plugins/grid/types.js +11 -0
  56. package/dist/plugins/groups/async-bridge.js +246 -0
  57. package/dist/{features → plugins}/groups/index.d.ts +1 -1
  58. package/dist/plugins/groups/index.js +13 -0
  59. package/dist/plugins/groups/layout.js +294 -0
  60. package/dist/plugins/groups/plugin.d.ts +22 -0
  61. package/dist/plugins/groups/plugin.js +571 -0
  62. package/dist/plugins/groups/sticky.js +255 -0
  63. package/dist/plugins/groups/types.js +12 -0
  64. package/dist/plugins/masonry/index.d.ts +8 -0
  65. package/dist/plugins/masonry/index.js +6 -0
  66. package/dist/plugins/masonry/layout.js +261 -0
  67. package/dist/plugins/masonry/plugin.d.ts +32 -0
  68. package/dist/plugins/masonry/plugin.js +381 -0
  69. package/dist/plugins/masonry/renderer.js +354 -0
  70. package/dist/plugins/masonry/types.js +9 -0
  71. package/dist/plugins/page/index.d.ts +5 -0
  72. package/dist/plugins/page/index.js +5 -0
  73. package/dist/plugins/page/plugin.d.ts +21 -0
  74. package/dist/plugins/page/plugin.js +166 -0
  75. package/dist/plugins/scale/index.d.ts +5 -0
  76. package/dist/plugins/scale/index.js +4 -0
  77. package/dist/plugins/scale/plugin.d.ts +24 -0
  78. package/dist/plugins/scale/plugin.js +507 -0
  79. package/dist/plugins/scrollbar/controller.js +574 -0
  80. package/dist/plugins/scrollbar/index.d.ts +7 -0
  81. package/dist/plugins/scrollbar/index.js +6 -0
  82. package/dist/plugins/scrollbar/plugin.d.ts +20 -0
  83. package/dist/plugins/scrollbar/plugin.js +93 -0
  84. package/dist/plugins/scrollbar/scrollbar.js +556 -0
  85. package/dist/plugins/selection/index.d.ts +6 -0
  86. package/dist/plugins/selection/index.js +7 -0
  87. package/dist/plugins/selection/plugin.d.ts +16 -0
  88. package/dist/plugins/selection/plugin.js +601 -0
  89. package/dist/{features → plugins}/selection/state.d.ts +8 -0
  90. package/dist/plugins/selection/state.js +332 -0
  91. package/dist/plugins/snapshots/index.d.ts +5 -0
  92. package/dist/plugins/snapshots/index.js +5 -0
  93. package/dist/plugins/snapshots/plugin.d.ts +17 -0
  94. package/dist/plugins/snapshots/plugin.js +301 -0
  95. package/dist/plugins/sortable/index.d.ts +6 -0
  96. package/dist/plugins/sortable/index.js +6 -0
  97. package/dist/plugins/sortable/plugin.d.ts +34 -0
  98. package/dist/plugins/sortable/plugin.js +753 -0
  99. package/dist/plugins/table/header.js +501 -0
  100. package/dist/{features → plugins}/table/index.d.ts +1 -1
  101. package/dist/plugins/table/index.js +12 -0
  102. package/dist/plugins/table/layout.js +211 -0
  103. package/dist/plugins/table/plugin.d.ts +20 -0
  104. package/dist/plugins/table/plugin.js +391 -0
  105. package/dist/plugins/table/renderer.js +625 -0
  106. package/dist/plugins/table/types.js +12 -0
  107. package/dist/plugins/transition/index.d.ts +5 -0
  108. package/dist/plugins/transition/index.js +5 -0
  109. package/dist/plugins/transition/plugin.d.ts +22 -0
  110. package/dist/plugins/transition/plugin.js +405 -0
  111. package/dist/rendering/aria.js +23 -0
  112. package/dist/rendering/index.js +18 -0
  113. package/dist/rendering/measured.js +98 -0
  114. package/dist/rendering/renderer.js +586 -0
  115. package/dist/rendering/scale.js +267 -0
  116. package/dist/rendering/scroll.js +71 -0
  117. package/dist/rendering/sizes.js +193 -0
  118. package/dist/rendering/sort.js +65 -0
  119. package/dist/rendering/viewport.js +268 -0
  120. package/dist/size.json +1 -1
  121. package/dist/types.js +5 -0
  122. package/dist/utils/padding.d.ts +2 -4
  123. package/dist/utils/padding.js +49 -0
  124. package/dist/utils/stats.js +124 -0
  125. package/dist/vlist-grid.css +1 -1
  126. package/dist/vlist-masonry.css +1 -1
  127. package/dist/vlist-table.css +1 -1
  128. package/dist/vlist.css +1 -1
  129. package/package.json +9 -4
  130. package/dist/builder/a11y.d.ts +0 -16
  131. package/dist/builder/api.d.ts +0 -21
  132. package/dist/builder/context.d.ts +0 -36
  133. package/dist/builder/core.d.ts +0 -16
  134. package/dist/builder/data.d.ts +0 -71
  135. package/dist/builder/dom.d.ts +0 -15
  136. package/dist/builder/index.d.ts +0 -25
  137. package/dist/builder/materialize.d.ts +0 -166
  138. package/dist/builder/pool.d.ts +0 -10
  139. package/dist/builder/range.d.ts +0 -10
  140. package/dist/builder/scroll.d.ts +0 -24
  141. package/dist/builder/types.d.ts +0 -512
  142. package/dist/features/async/feature.d.ts +0 -72
  143. package/dist/features/autosize/feature.d.ts +0 -34
  144. package/dist/features/autosize/index.d.ts +0 -2
  145. package/dist/features/grid/feature.d.ts +0 -48
  146. package/dist/features/grid/index.d.ts +0 -9
  147. package/dist/features/groups/feature.d.ts +0 -75
  148. package/dist/features/masonry/feature.d.ts +0 -45
  149. package/dist/features/masonry/index.d.ts +0 -9
  150. package/dist/features/page/feature.d.ts +0 -109
  151. package/dist/features/page/index.d.ts +0 -9
  152. package/dist/features/scale/feature.d.ts +0 -42
  153. package/dist/features/scale/index.d.ts +0 -10
  154. package/dist/features/scrollbar/feature.d.ts +0 -81
  155. package/dist/features/scrollbar/index.d.ts +0 -8
  156. package/dist/features/selection/feature.d.ts +0 -91
  157. package/dist/features/selection/index.d.ts +0 -7
  158. package/dist/features/snapshots/feature.d.ts +0 -79
  159. package/dist/features/snapshots/index.d.ts +0 -9
  160. package/dist/features/sortable/feature.d.ts +0 -101
  161. package/dist/features/sortable/index.d.ts +0 -6
  162. package/dist/features/table/feature.d.ts +0 -67
  163. package/dist/features/transition/feature.d.ts +0 -30
  164. package/dist/features/transition/index.d.ts +0 -9
  165. /package/dist/{features → plugins}/async/placeholder.d.ts +0 -0
  166. /package/dist/{features → plugins}/async/sparse.d.ts +0 -0
  167. /package/dist/{features → plugins}/grid/layout.d.ts +0 -0
  168. /package/dist/{features → plugins}/grid/renderer.d.ts +0 -0
  169. /package/dist/{features → plugins}/grid/types.d.ts +0 -0
  170. /package/dist/{features → plugins}/groups/async-bridge.d.ts +0 -0
  171. /package/dist/{features → plugins}/groups/layout.d.ts +0 -0
  172. /package/dist/{features → plugins}/groups/sticky.d.ts +0 -0
  173. /package/dist/{features → plugins}/groups/types.d.ts +0 -0
  174. /package/dist/{features → plugins}/masonry/layout.d.ts +0 -0
  175. /package/dist/{features → plugins}/masonry/renderer.d.ts +0 -0
  176. /package/dist/{features → plugins}/masonry/types.d.ts +0 -0
  177. /package/dist/{features → plugins}/scrollbar/controller.d.ts +0 -0
  178. /package/dist/{features → plugins}/scrollbar/scrollbar.d.ts +0 -0
  179. /package/dist/{features → plugins}/table/header.d.ts +0 -0
  180. /package/dist/{features → plugins}/table/layout.d.ts +0 -0
  181. /package/dist/{features → plugins}/table/renderer.d.ts +0 -0
  182. /package/dist/{features → plugins}/table/types.d.ts +0 -0
@@ -0,0 +1,568 @@
1
+ /**
2
+ * vlist - Data Management
3
+ * Handles data with sparse storage for million+ item support
4
+ */
5
+ import { createSparseStorage, } from "./sparse";
6
+ import { createPlaceholderManager, isPlaceholderItem, } from "./placeholder";
7
+ // =============================================================================
8
+ // Constants
9
+ // =============================================================================
10
+ const LOAD_SIZE = 50;
11
+ // =============================================================================
12
+ // Data Manager Implementation
13
+ // =============================================================================
14
+ /**
15
+ * Create a data manager with sparse storage support
16
+ */
17
+ export const createDataManager = (config = {}) => {
18
+ const { adapter, initialItems, initialTotal, storage: storageConfig, placeholder: placeholderConfig, pageSize = LOAD_SIZE, maxConcurrent = 0, onStateChange, onDataChange, onItemsLoaded, onItemsEvicted, } = config;
19
+ // Create sparse storage
20
+ const storage = createSparseStorage({
21
+ ...storageConfig,
22
+ onEvict: (count, _ranges) => {
23
+ onItemsEvicted?.(count);
24
+ notifyDataChange();
25
+ notifyStateChange();
26
+ },
27
+ });
28
+ // Lazy-init placeholder manager — only created when first needed
29
+ // Static lists (no adapter) never request unloaded items, so this avoids
30
+ // instantiating ~300 lines of placeholder logic for the common case.
31
+ let placeholders = null;
32
+ const getOrCreatePlaceholders = () => {
33
+ if (!placeholders) {
34
+ placeholders = createPlaceholderManager(placeholderConfig);
35
+ }
36
+ return placeholders;
37
+ };
38
+ // ID to index mapping (only for loaded items)
39
+ const idToIndex = new Map();
40
+ // State
41
+ let isLoading = false;
42
+ let error;
43
+ let hasMore = true;
44
+ let cursor;
45
+ // The offset for which the stored cursor is valid. A server cursor returned
46
+ // for offset N is only meaningful as input for the request at offset N+limit.
47
+ // Passing it to any other offset would return wrong data.
48
+ let cursorValidForOffset;
49
+ let pendingRanges = [];
50
+ // Track the furthest loaded offset so that concurrent chunk responses
51
+ // don't race: an earlier chunk's hasMore:true can't override a later
52
+ // chunk's hasMore:false.
53
+ let hasMoreHighWater = 0;
54
+ // Track active chunk requests to dedupe and abort stale work.
55
+ const activeLoads = new Map();
56
+ /** Abort all in-flight requests and clear both tracking maps. */
57
+ const abortAndClearLoads = () => {
58
+ for (const load of activeLoads.values())
59
+ load[1].abort();
60
+ activeLoads.clear();
61
+ };
62
+ // ==========================================================================
63
+ // Internal Helpers
64
+ // ==========================================================================
65
+ const notifyStateChange = () => {
66
+ onStateChange?.(getState());
67
+ };
68
+ const notifyDataChange = () => {
69
+ onDataChange?.();
70
+ };
71
+ /**
72
+ * Rebuild ID index for loaded items
73
+ */
74
+ const rebuildIdIndex = () => {
75
+ idToIndex.clear();
76
+ const loadedRanges = storage.getLoadedRanges();
77
+ for (const range of loadedRanges) {
78
+ for (let i = range.start; i <= range.end; i++) {
79
+ const item = storage.get(i);
80
+ if (item && !isPlaceholderItem(item)) {
81
+ idToIndex.set(item.id, i);
82
+ }
83
+ }
84
+ }
85
+ };
86
+ /**
87
+ * Update ID index for a specific item
88
+ */
89
+ const updateIdIndex = (index, item) => {
90
+ if (!isPlaceholderItem(item)) {
91
+ idToIndex.set(item.id, index);
92
+ }
93
+ };
94
+ /**
95
+ * Remove from ID index
96
+ */
97
+ const removeFromIdIndex = (id) => {
98
+ idToIndex.delete(id);
99
+ };
100
+ /**
101
+ * Get range key for deduplication
102
+ */
103
+ const getRangeKey = (start, end) => {
104
+ return `${start}-${end}`;
105
+ };
106
+ // ==========================================================================
107
+ // State
108
+ // ==========================================================================
109
+ // Direct getters for hot-path access (avoid object allocation)
110
+ const getTotal = () => storage.getTotal();
111
+ const getCached = () => storage.getCachedCount();
112
+ const getIsLoading = () => isLoading;
113
+ const getHasMore = () => hasMore;
114
+ const getState = () => ({
115
+ total: storage.getTotal(),
116
+ cached: storage.getCachedCount(),
117
+ isLoading,
118
+ pendingRanges,
119
+ error,
120
+ hasMore,
121
+ cursor,
122
+ });
123
+ const getStorage = () => storage;
124
+ const getPlaceholders = () => getOrCreatePlaceholders();
125
+ // ==========================================================================
126
+ // Item Access
127
+ // ==========================================================================
128
+ const getItem = (index) => {
129
+ const item = storage.get(index);
130
+ // Return loaded item
131
+ if (item !== undefined) {
132
+ return item;
133
+ }
134
+ // Return placeholder for unloaded within total
135
+ if (index >= 0 && index < storage.getTotal()) {
136
+ return getOrCreatePlaceholders().generate(index);
137
+ }
138
+ return undefined;
139
+ };
140
+ const getItemById = (id) => {
141
+ const index = idToIndex.get(id);
142
+ if (index === undefined) {
143
+ return undefined;
144
+ }
145
+ return storage.get(index);
146
+ };
147
+ const getIndexById = (id) => {
148
+ return idToIndex.get(id) ?? -1;
149
+ };
150
+ const isItemLoaded = (index) => {
151
+ const item = storage.get(index);
152
+ return item !== undefined && !isPlaceholderItem(item);
153
+ };
154
+ const getItemsInRange = (start, end) => {
155
+ const items = [];
156
+ const total = storage.getTotal();
157
+ // S2: Batch LRU timestamp update — single Date.now() for all chunks
158
+ // instead of per-item in storage.get()
159
+ storage.touchChunksForRange(start, Math.min(end, total - 1));
160
+ for (let i = start; i <= end && i < total; i++) {
161
+ const item = storage.get(i);
162
+ if (item !== undefined) {
163
+ items.push(item);
164
+ }
165
+ else {
166
+ // Generate placeholder for unloaded
167
+ items.push(getOrCreatePlaceholders().generate(i));
168
+ }
169
+ }
170
+ return items;
171
+ };
172
+ // ==========================================================================
173
+ // Data Operations
174
+ // ==========================================================================
175
+ const setTotal = (total) => {
176
+ storage.setTotal(total);
177
+ hasMore = storage.getCachedCount() < total;
178
+ notifyDataChange();
179
+ notifyStateChange();
180
+ };
181
+ const setItems = (items, offset = 0, total) => {
182
+ // Analyze structure for placeholders from first batch.
183
+ // Use getOrCreatePlaceholders() so the profile is captured eagerly —
184
+ // before the user scrolls fast into unloaded territory. Without this,
185
+ // the lazy-init guard (`placeholders` is null) causes the first batch
186
+ // to be missed, and placeholders fall back to a generic single-field
187
+ // skeleton until a later setItems finally triggers analysis.
188
+ // Only do this when an adapter exists (async loading); static lists
189
+ // never generate placeholders so we keep the lazy path for them.
190
+ if (adapter && items.length > 0) {
191
+ const pm = getOrCreatePlaceholders();
192
+ if (!pm.hasAnalyzedStructure()) {
193
+ pm.analyzeStructure(items);
194
+ }
195
+ }
196
+ // Store items
197
+ for (let i = 0; i < items.length; i++) {
198
+ const item = items[i];
199
+ if (item !== undefined) {
200
+ const index = offset + i;
201
+ storage.set(index, item);
202
+ updateIdIndex(index, item);
203
+ }
204
+ }
205
+ // Update total
206
+ if (total !== undefined) {
207
+ storage.setTotal(total);
208
+ }
209
+ else if (offset + items.length > storage.getTotal()) {
210
+ storage.setTotal(offset + items.length);
211
+ }
212
+ // Only allow hasMore to decrease here, never increase. With sparse
213
+ // loading, getCachedCount() is almost always less than total (we never
214
+ // cache all 1M items), so setting hasMore=true would trigger endless
215
+ // loadMore calls at the bottom. But when all items ARE cached (e.g.
216
+ // direct setItems use without an adapter), we must turn it off.
217
+ if (storage.getCachedCount() >= storage.getTotal() &&
218
+ storage.getTotal() > 0) {
219
+ hasMore = false;
220
+ }
221
+ // Data change MUST fire before onItemsLoaded so that the size cache
222
+ // is rebuilt (via onDataChange → sizeCache.rebuild) before
223
+ // onItemsLoaded triggers a forced render. Without this order the
224
+ // render sees sizeCache.totalSize=0 and only shows a few rows.
225
+ notifyDataChange();
226
+ notifyStateChange();
227
+ onItemsLoaded?.(items, offset, storage.getTotal());
228
+ };
229
+ const updateItem = (index, updates) => {
230
+ const existing = storage.get(index);
231
+ if (!existing) {
232
+ return false;
233
+ }
234
+ const oldId = existing.id;
235
+ const updated = { ...existing, ...updates };
236
+ storage.set(index, updated);
237
+ // Update ID index if ID changed
238
+ if (updates.id !== undefined && updates.id !== oldId) {
239
+ removeFromIdIndex(oldId);
240
+ }
241
+ updateIdIndex(index, updated);
242
+ notifyDataChange();
243
+ notifyStateChange();
244
+ return true;
245
+ };
246
+ const insertItem = (item, index) => {
247
+ storage.insert(index, item);
248
+ rebuildIdIndex();
249
+ abortAndClearLoads();
250
+ notifyDataChange();
251
+ notifyStateChange();
252
+ };
253
+ const removeItem = (id) => {
254
+ const index = idToIndex.get(id);
255
+ if (index === undefined)
256
+ return false;
257
+ // storage.delete now shifts all items after `index` down by 1
258
+ // and decrements totalItems — no manual setTotal needed.
259
+ const deleted = storage.delete(index);
260
+ if (!deleted)
261
+ return false;
262
+ rebuildIdIndex();
263
+ abortAndClearLoads();
264
+ notifyDataChange();
265
+ notifyStateChange();
266
+ return true;
267
+ };
268
+ // ==========================================================================
269
+ // Loading
270
+ // ==========================================================================
271
+ const loadRange = async (start, end) => {
272
+ if (!adapter) {
273
+ return;
274
+ }
275
+ // Find missing chunks — O(range/chunkSize) scan, not O(all-cached-items)
276
+ const chunkSize = storage.chunkSize;
277
+ const firstChunk = Math.floor(start / chunkSize);
278
+ const lastChunk = Math.floor(end / chunkSize);
279
+ // Abort in-flight loads that belong to a previous scroll position.
280
+ // Keep a buffer of 2 chunks around the new range — anything further is
281
+ // stale (the user has scrolled away) and should be cancelled immediately
282
+ // to free browser connections and server resources.
283
+ // 2 chunks keeps at most 3 concurrent requests (current + 1 on each side),
284
+ // well under the browser's 6-connection HTTP/1.1 limit.
285
+ const keepBuffer = chunkSize * 2;
286
+ for (const [loadKey, load] of activeLoads) {
287
+ const dash = loadKey.indexOf("-");
288
+ const loadStart = parseInt(loadKey.slice(0, dash), 10);
289
+ if (Math.abs(loadStart - start) > keepBuffer) {
290
+ load[1].abort();
291
+ activeLoads.delete(loadKey);
292
+ }
293
+ }
294
+ const chunksToLoad = [];
295
+ const loadPromises = [];
296
+ for (let chunkIdx = firstChunk; chunkIdx <= lastChunk; chunkIdx++) {
297
+ const chunkStart = chunkIdx * chunkSize;
298
+ const chunkEnd = chunkStart + chunkSize - 1;
299
+ const key = getRangeKey(chunkStart, chunkEnd);
300
+ const chunkFull = storage.isChunkFullyLoaded(chunkIdx);
301
+ if (chunkFull) {
302
+ continue;
303
+ }
304
+ const active = activeLoads.get(key);
305
+ if (active) {
306
+ loadPromises.push(active[0]);
307
+ }
308
+ else {
309
+ chunksToLoad.push({ start: chunkStart, end: chunkEnd });
310
+ }
311
+ }
312
+ if (chunksToLoad.length === 0) {
313
+ if (loadPromises.length > 0) {
314
+ await Promise.all(loadPromises);
315
+ }
316
+ return;
317
+ }
318
+ // Enforce max concurrent request limit (zero-allocation path)
319
+ if (maxConcurrent > 0) {
320
+ const totalAfterNew = activeLoads.size + chunksToLoad.length;
321
+ if (totalAfterNew > maxConcurrent) {
322
+ const center = (start + end) / 2;
323
+ // Abort furthest existing loads first — new loads are closer to
324
+ // the current viewport and therefore higher priority.
325
+ // Single-pass find-max loop instead of spread+map+sort to avoid
326
+ // heap allocations on the hot path.
327
+ let toAbort = Math.min(totalAfterNew - maxConcurrent, activeLoads.size);
328
+ while (toAbort > 0) {
329
+ let furthestKey = null;
330
+ let furthestDist = -1;
331
+ let furthestCtrl = null;
332
+ for (const [key, load] of activeLoads) {
333
+ const dash = key.indexOf("-");
334
+ const loadStart = parseInt(key.slice(0, dash), 10);
335
+ const loadEnd = parseInt(key.slice(dash + 1), 10);
336
+ const dist = Math.abs((loadStart + loadEnd) / 2 - center);
337
+ if (dist > furthestDist) {
338
+ furthestDist = dist;
339
+ furthestKey = key;
340
+ furthestCtrl = load[1];
341
+ }
342
+ }
343
+ if (furthestKey) {
344
+ furthestCtrl.abort();
345
+ activeLoads.delete(furthestKey);
346
+ }
347
+ toAbort--;
348
+ }
349
+ // If still over limit after aborting existing, trim new chunks.
350
+ // In-place sort on the already-allocated chunksToLoad array.
351
+ const remaining = maxConcurrent - activeLoads.size;
352
+ if (chunksToLoad.length > remaining) {
353
+ chunksToLoad.sort((a, b) => {
354
+ const distA = Math.abs((a.start + a.end) / 2 - center);
355
+ const distB = Math.abs((b.start + b.end) / 2 - center);
356
+ return distA - distB;
357
+ });
358
+ chunksToLoad.length = Math.max(0, remaining);
359
+ }
360
+ }
361
+ }
362
+ if (chunksToLoad.length === 0) {
363
+ if (loadPromises.length > 0) {
364
+ await Promise.all(loadPromises);
365
+ }
366
+ return;
367
+ }
368
+ // Load chunks that aren't already loading
369
+ for (const chunk of chunksToLoad) {
370
+ const key = getRangeKey(chunk.start, chunk.end);
371
+ // Create the load promise for this chunk
372
+ const controller = new AbortController();
373
+ const loadPromise = (async () => {
374
+ pendingRanges.push(chunk);
375
+ isLoading = true;
376
+ error = undefined;
377
+ notifyStateChange();
378
+ try {
379
+ const limit = chunk.end - chunk.start + 1;
380
+ const params = {
381
+ offset: chunk.start,
382
+ limit,
383
+ cursor: chunk.start === cursorValidForOffset ? cursor : undefined,
384
+ signal: controller.signal,
385
+ };
386
+ const response = await adapter.read(params);
387
+ // Store items
388
+ setItems(response.items, chunk.start, response.total);
389
+ // Update cursor — record the offset at which this cursor is valid
390
+ // (the next sequential offset after this chunk).
391
+ if (response.cursor) {
392
+ cursor = response.cursor;
393
+ cursorValidForOffset = chunk.start + response.items.length;
394
+ }
395
+ // Update hasMore — but only if this chunk covers a range at or
396
+ // beyond anything we've seen before. This prevents an earlier
397
+ // concurrent chunk (hasMore:true) from overriding a later chunk
398
+ // (hasMore:false) that already reached the end of the dataset.
399
+ const chunkEndOffset = chunk.start + response.items.length;
400
+ if (chunkEndOffset >= hasMoreHighWater) {
401
+ hasMoreHighWater = chunkEndOffset;
402
+ if (response.hasMore !== undefined) {
403
+ hasMore = response.hasMore;
404
+ }
405
+ else if (response.total !== undefined) {
406
+ hasMore = chunkEndOffset < response.total;
407
+ }
408
+ }
409
+ }
410
+ catch (err) {
411
+ // AbortError is intentional cancellation — don't surface as an error
412
+ if (err?.name === "AbortError")
413
+ return;
414
+ error = err instanceof Error ? err : new Error(String(err));
415
+ }
416
+ finally {
417
+ activeLoads.delete(key);
418
+ const prIdx = pendingRanges.findIndex((r) => r.start === chunk.start && r.end === chunk.end);
419
+ if (prIdx >= 0)
420
+ pendingRanges.splice(prIdx, 1);
421
+ isLoading = activeLoads.size > 0;
422
+ notifyStateChange();
423
+ }
424
+ })();
425
+ activeLoads.set(key, [loadPromise, controller]);
426
+ loadPromises.push(loadPromise);
427
+ }
428
+ // Wait for all loads to complete
429
+ await Promise.all(loadPromises);
430
+ };
431
+ const ensureRange = async (start, end) => {
432
+ // Check if range is already fully loaded
433
+ const rangeLoaded = storage.isRangeLoaded(start, end);
434
+ if (rangeLoaded) {
435
+ return;
436
+ }
437
+ await loadRange(start, end);
438
+ };
439
+ const loadInitial = async () => {
440
+ if (!adapter) {
441
+ return;
442
+ }
443
+ await loadRange(0, pageSize - 1);
444
+ };
445
+ const loadMore = async () => {
446
+ if (!adapter || isLoading || !hasMore) {
447
+ return false;
448
+ }
449
+ const currentCached = storage.getCachedCount();
450
+ const total = storage.getTotal();
451
+ // Calculate next range to load
452
+ const start = currentCached;
453
+ const end = Math.min(start + pageSize - 1, total > 0 ? total - 1 : start + pageSize - 1);
454
+ if (start >= total && total > 0) {
455
+ hasMore = false;
456
+ return false;
457
+ }
458
+ await loadRange(start, end);
459
+ return storage.getCachedCount() > currentCached;
460
+ };
461
+ const reload = async () => {
462
+ // Clear everything — but do NOT load initial data.
463
+ // The caller (e.g. withAsync feature) decides what range to load,
464
+ // which avoids a wasted request to offset=0 when the viewport
465
+ // is scrolled elsewhere.
466
+ storage.clear();
467
+ storage.setTotal(0); // Reset total to 0 so scrollbar updates correctly
468
+ idToIndex.clear();
469
+ if (placeholders)
470
+ placeholders.clear();
471
+ abortAndClearLoads();
472
+ pendingRanges = [];
473
+ isLoading = false;
474
+ cursor = undefined;
475
+ cursorValidForOffset = undefined;
476
+ hasMore = true;
477
+ hasMoreHighWater = 0;
478
+ error = undefined;
479
+ notifyDataChange();
480
+ notifyStateChange();
481
+ };
482
+ // ==========================================================================
483
+ // Memory Management
484
+ // ==========================================================================
485
+ const evictDistant = (visibleStart, visibleEnd) => {
486
+ const evicted = storage.evictDistant(visibleStart, visibleEnd);
487
+ if (evicted > 0) {
488
+ // Rebuild ID index after eviction
489
+ rebuildIdIndex();
490
+ }
491
+ };
492
+ // ==========================================================================
493
+ // Lifecycle
494
+ // ==========================================================================
495
+ const clear = () => {
496
+ storage.clear();
497
+ idToIndex.clear();
498
+ cursor = undefined;
499
+ cursorValidForOffset = undefined;
500
+ error = undefined;
501
+ pendingRanges = [];
502
+ isLoading = false;
503
+ notifyDataChange();
504
+ notifyStateChange();
505
+ };
506
+ const reset = () => {
507
+ storage.reset();
508
+ idToIndex.clear();
509
+ if (placeholders)
510
+ placeholders.clear();
511
+ cursor = undefined;
512
+ cursorValidForOffset = undefined;
513
+ hasMore = true;
514
+ hasMoreHighWater = 0;
515
+ error = undefined;
516
+ pendingRanges = [];
517
+ isLoading = false;
518
+ notifyDataChange();
519
+ notifyStateChange();
520
+ };
521
+ // ==========================================================================
522
+ // Initialization
523
+ // ==========================================================================
524
+ // Initialize with provided items
525
+ if (initialItems && initialItems.length > 0) {
526
+ setItems(initialItems, 0, initialTotal ?? initialItems.length);
527
+ }
528
+ else if (initialTotal !== undefined) {
529
+ storage.setTotal(initialTotal);
530
+ notifyDataChange();
531
+ notifyStateChange();
532
+ }
533
+ // ==========================================================================
534
+ // Return Public API
535
+ // ==========================================================================
536
+ return {
537
+ getState,
538
+ getTotal,
539
+ getCached,
540
+ getIsLoading,
541
+ getHasMore,
542
+ getStorage,
543
+ getPlaceholders,
544
+ getItem,
545
+ getItemById,
546
+ getIndexById,
547
+ isItemLoaded,
548
+ getItemsInRange,
549
+ setTotal,
550
+ setItems,
551
+ updateItem,
552
+ insertItem,
553
+ removeItem,
554
+ loadRange,
555
+ ensureRange,
556
+ loadInitial,
557
+ loadMore,
558
+ reload,
559
+ evictDistant,
560
+ clear,
561
+ reset,
562
+ };
563
+ };
564
+ // =============================================================================
565
+ // Utility Functions (Re-export from sparse for convenience)
566
+ // =============================================================================
567
+ export { mergeRanges, calculateMissingRanges } from "./sparse";
568
+ export { isPlaceholderItem, filterPlaceholders, countRealItems, } from "./placeholder";