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.
- package/README.github.md +104 -97
- package/README.md +46 -33
- package/dist/constants.d.ts +11 -6
- package/dist/constants.js +83 -0
- package/dist/core/create.d.ts +10 -0
- package/dist/core/create.js +740 -0
- package/dist/core/dom.d.ts +8 -0
- package/dist/core/dom.js +47 -0
- package/dist/core/hooks.d.ts +16 -0
- package/dist/core/hooks.js +67 -0
- package/dist/core/index.d.ts +17 -0
- package/dist/core/index.js +13 -0
- package/dist/core/pipeline.d.ts +51 -0
- package/dist/core/pipeline.js +307 -0
- package/dist/core/pool.d.ts +9 -0
- package/dist/core/pool.js +42 -0
- package/dist/core/scroll.d.ts +32 -0
- package/dist/core/scroll.js +137 -0
- package/dist/core/sizes.d.ts +8 -0
- package/dist/core/sizes.js +6 -0
- package/dist/core/state.d.ts +47 -0
- package/dist/core/state.js +56 -0
- package/dist/core/types.d.ts +187 -0
- package/dist/core/types.js +7 -0
- package/dist/{builder → core}/velocity.d.ts +1 -1
- package/dist/core/velocity.js +33 -0
- package/dist/events/emitter.js +60 -0
- package/dist/events/index.js +6 -0
- package/dist/index.d.ts +28 -19
- package/dist/index.js +28 -1
- package/dist/internals.d.ts +11 -7
- package/dist/internals.js +60 -1
- package/dist/plugins/a11y/index.d.ts +2 -0
- package/dist/plugins/a11y/index.js +1 -0
- package/dist/plugins/a11y/plugin.d.ts +13 -0
- package/dist/plugins/a11y/plugin.js +259 -0
- package/dist/{features → plugins}/async/index.d.ts +1 -1
- package/dist/plugins/async/index.js +12 -0
- package/dist/{features → plugins}/async/manager.d.ts +5 -1
- package/dist/plugins/async/manager.js +568 -0
- package/dist/plugins/async/placeholder.js +154 -0
- package/dist/plugins/async/plugin.d.ts +48 -0
- package/dist/plugins/async/plugin.js +311 -0
- package/dist/plugins/async/sparse.js +540 -0
- package/dist/plugins/autosize/index.d.ts +5 -0
- package/dist/plugins/autosize/index.js +4 -0
- package/dist/plugins/autosize/plugin.d.ts +19 -0
- package/dist/plugins/autosize/plugin.js +185 -0
- package/dist/plugins/grid/index.d.ts +7 -0
- package/dist/plugins/grid/index.js +5 -0
- package/dist/plugins/grid/layout.js +275 -0
- package/dist/plugins/grid/plugin.d.ts +23 -0
- package/dist/plugins/grid/plugin.js +347 -0
- package/dist/plugins/grid/renderer.js +525 -0
- package/dist/plugins/grid/types.js +11 -0
- package/dist/plugins/groups/async-bridge.js +246 -0
- package/dist/{features → plugins}/groups/index.d.ts +1 -1
- package/dist/plugins/groups/index.js +13 -0
- package/dist/plugins/groups/layout.js +294 -0
- package/dist/plugins/groups/plugin.d.ts +22 -0
- package/dist/plugins/groups/plugin.js +571 -0
- package/dist/plugins/groups/sticky.js +255 -0
- package/dist/plugins/groups/types.js +12 -0
- package/dist/plugins/masonry/index.d.ts +8 -0
- package/dist/plugins/masonry/index.js +6 -0
- package/dist/plugins/masonry/layout.js +261 -0
- package/dist/plugins/masonry/plugin.d.ts +32 -0
- package/dist/plugins/masonry/plugin.js +381 -0
- package/dist/plugins/masonry/renderer.js +354 -0
- package/dist/plugins/masonry/types.js +9 -0
- package/dist/plugins/page/index.d.ts +5 -0
- package/dist/plugins/page/index.js +5 -0
- package/dist/plugins/page/plugin.d.ts +21 -0
- package/dist/plugins/page/plugin.js +166 -0
- package/dist/plugins/scale/index.d.ts +5 -0
- package/dist/plugins/scale/index.js +4 -0
- package/dist/plugins/scale/plugin.d.ts +24 -0
- package/dist/plugins/scale/plugin.js +507 -0
- package/dist/plugins/scrollbar/controller.js +574 -0
- package/dist/plugins/scrollbar/index.d.ts +7 -0
- package/dist/plugins/scrollbar/index.js +6 -0
- package/dist/plugins/scrollbar/plugin.d.ts +20 -0
- package/dist/plugins/scrollbar/plugin.js +93 -0
- package/dist/plugins/scrollbar/scrollbar.js +556 -0
- package/dist/plugins/selection/index.d.ts +6 -0
- package/dist/plugins/selection/index.js +7 -0
- package/dist/plugins/selection/plugin.d.ts +16 -0
- package/dist/plugins/selection/plugin.js +601 -0
- package/dist/{features → plugins}/selection/state.d.ts +8 -0
- package/dist/plugins/selection/state.js +332 -0
- package/dist/plugins/snapshots/index.d.ts +5 -0
- package/dist/plugins/snapshots/index.js +5 -0
- package/dist/plugins/snapshots/plugin.d.ts +17 -0
- package/dist/plugins/snapshots/plugin.js +301 -0
- package/dist/plugins/sortable/index.d.ts +6 -0
- package/dist/plugins/sortable/index.js +6 -0
- package/dist/plugins/sortable/plugin.d.ts +34 -0
- package/dist/plugins/sortable/plugin.js +753 -0
- package/dist/plugins/table/header.js +501 -0
- package/dist/{features → plugins}/table/index.d.ts +1 -1
- package/dist/plugins/table/index.js +12 -0
- package/dist/plugins/table/layout.js +211 -0
- package/dist/plugins/table/plugin.d.ts +20 -0
- package/dist/plugins/table/plugin.js +391 -0
- package/dist/plugins/table/renderer.js +625 -0
- package/dist/plugins/table/types.js +12 -0
- package/dist/plugins/transition/index.d.ts +5 -0
- package/dist/plugins/transition/index.js +5 -0
- package/dist/plugins/transition/plugin.d.ts +22 -0
- package/dist/plugins/transition/plugin.js +405 -0
- package/dist/rendering/aria.js +23 -0
- package/dist/rendering/index.js +18 -0
- package/dist/rendering/measured.js +98 -0
- package/dist/rendering/renderer.js +586 -0
- package/dist/rendering/scale.js +267 -0
- package/dist/rendering/scroll.js +71 -0
- package/dist/rendering/sizes.js +193 -0
- package/dist/rendering/sort.js +65 -0
- package/dist/rendering/viewport.js +268 -0
- package/dist/size.json +1 -1
- package/dist/types.js +5 -0
- package/dist/utils/padding.d.ts +2 -4
- package/dist/utils/padding.js +49 -0
- package/dist/utils/stats.js +124 -0
- package/dist/vlist-grid.css +1 -1
- package/dist/vlist-masonry.css +1 -1
- package/dist/vlist-table.css +1 -1
- package/dist/vlist.css +1 -1
- package/package.json +9 -4
- package/dist/builder/a11y.d.ts +0 -16
- package/dist/builder/api.d.ts +0 -21
- package/dist/builder/context.d.ts +0 -36
- package/dist/builder/core.d.ts +0 -16
- package/dist/builder/data.d.ts +0 -71
- package/dist/builder/dom.d.ts +0 -15
- package/dist/builder/index.d.ts +0 -25
- package/dist/builder/materialize.d.ts +0 -166
- package/dist/builder/pool.d.ts +0 -10
- package/dist/builder/range.d.ts +0 -10
- package/dist/builder/scroll.d.ts +0 -24
- package/dist/builder/types.d.ts +0 -512
- package/dist/features/async/feature.d.ts +0 -72
- package/dist/features/autosize/feature.d.ts +0 -34
- package/dist/features/autosize/index.d.ts +0 -2
- package/dist/features/grid/feature.d.ts +0 -48
- package/dist/features/grid/index.d.ts +0 -9
- package/dist/features/groups/feature.d.ts +0 -75
- package/dist/features/masonry/feature.d.ts +0 -45
- package/dist/features/masonry/index.d.ts +0 -9
- package/dist/features/page/feature.d.ts +0 -109
- package/dist/features/page/index.d.ts +0 -9
- package/dist/features/scale/feature.d.ts +0 -42
- package/dist/features/scale/index.d.ts +0 -10
- package/dist/features/scrollbar/feature.d.ts +0 -81
- package/dist/features/scrollbar/index.d.ts +0 -8
- package/dist/features/selection/feature.d.ts +0 -91
- package/dist/features/selection/index.d.ts +0 -7
- package/dist/features/snapshots/feature.d.ts +0 -79
- package/dist/features/snapshots/index.d.ts +0 -9
- package/dist/features/sortable/feature.d.ts +0 -101
- package/dist/features/sortable/index.d.ts +0 -6
- package/dist/features/table/feature.d.ts +0 -67
- package/dist/features/transition/feature.d.ts +0 -30
- package/dist/features/transition/index.d.ts +0 -9
- /package/dist/{features → plugins}/async/placeholder.d.ts +0 -0
- /package/dist/{features → plugins}/async/sparse.d.ts +0 -0
- /package/dist/{features → plugins}/grid/layout.d.ts +0 -0
- /package/dist/{features → plugins}/grid/renderer.d.ts +0 -0
- /package/dist/{features → plugins}/grid/types.d.ts +0 -0
- /package/dist/{features → plugins}/groups/async-bridge.d.ts +0 -0
- /package/dist/{features → plugins}/groups/layout.d.ts +0 -0
- /package/dist/{features → plugins}/groups/sticky.d.ts +0 -0
- /package/dist/{features → plugins}/groups/types.d.ts +0 -0
- /package/dist/{features → plugins}/masonry/layout.d.ts +0 -0
- /package/dist/{features → plugins}/masonry/renderer.d.ts +0 -0
- /package/dist/{features → plugins}/masonry/types.d.ts +0 -0
- /package/dist/{features → plugins}/scrollbar/controller.d.ts +0 -0
- /package/dist/{features → plugins}/scrollbar/scrollbar.d.ts +0 -0
- /package/dist/{features → plugins}/table/header.d.ts +0 -0
- /package/dist/{features → plugins}/table/layout.d.ts +0 -0
- /package/dist/{features → plugins}/table/renderer.d.ts +0 -0
- /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";
|