mtrl-addons 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI.md +28 -230
- package/CLAUDE.md +882 -0
- package/build.js +286 -110
- package/package.json +2 -1
- package/src/components/vlist/features/api.ts +316 -12
- package/src/components/vlist/features/selection.ts +248 -256
- package/src/components/vlist/features/viewport.ts +1 -7
- package/src/components/vlist/index.ts +1 -0
- package/src/components/vlist/types.ts +140 -8
- package/src/core/layout/schema.ts +81 -38
- package/src/core/viewport/constants.ts +7 -2
- package/src/core/viewport/features/collection.ts +376 -76
- package/src/core/viewport/features/item-size.ts +4 -4
- package/src/core/viewport/features/momentum.ts +11 -2
- package/src/core/viewport/features/rendering.ts +424 -30
- package/src/core/viewport/features/scrolling.ts +41 -25
- package/src/core/viewport/features/utils.ts +11 -5
- package/src/core/viewport/features/virtual.ts +169 -28
- package/src/core/viewport/types.ts +2 -2
- package/src/core/viewport/viewport.ts +29 -10
- package/src/styles/components/_vlist.scss +234 -213
|
@@ -17,7 +17,7 @@ import { createLayout } from "../../layout";
|
|
|
17
17
|
export interface RenderingConfig {
|
|
18
18
|
template?: (
|
|
19
19
|
item: any,
|
|
20
|
-
index: number
|
|
20
|
+
index: number,
|
|
21
21
|
) => string | HTMLElement | any[] | Record<string, any>;
|
|
22
22
|
overscan?: number;
|
|
23
23
|
measureItems?: boolean;
|
|
@@ -52,11 +52,18 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
52
52
|
const renderedElements = new Map<number, HTMLElement>();
|
|
53
53
|
const collectionItems: Record<number, any> = {};
|
|
54
54
|
const elementPool: HTMLElement[] = [];
|
|
55
|
-
const poolStats = { created: 0, recycled: 0, poolSize: 0 };
|
|
55
|
+
const poolStats = { created: 0, recycled: 0, poolSize: 0, released: 0 };
|
|
56
|
+
|
|
57
|
+
// Store layout results for proper cleanup (prevents memory leak)
|
|
58
|
+
const layoutResults = new WeakMap<HTMLElement, { destroy: () => void }>();
|
|
59
|
+
|
|
60
|
+
// Reusable template element for HTML string parsing (more efficient than div)
|
|
61
|
+
const templateParser = document.createElement("template");
|
|
56
62
|
|
|
57
63
|
let viewportState: ViewportState | null = null;
|
|
58
64
|
let currentVisibleRange = { start: 0, end: 0 };
|
|
59
65
|
let lastRenderTime = 0;
|
|
66
|
+
let isRemovingItem = false;
|
|
60
67
|
|
|
61
68
|
// Element pool management
|
|
62
69
|
const getPooledElement = (): HTMLElement => {
|
|
@@ -71,30 +78,309 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
71
78
|
};
|
|
72
79
|
|
|
73
80
|
const releaseElement = (element: HTMLElement): void => {
|
|
81
|
+
poolStats.released++;
|
|
82
|
+
|
|
83
|
+
// CRITICAL: Call destroy on layoutResult to clean up components and event listeners
|
|
84
|
+
// This fixes the memory leak when using layout templates
|
|
85
|
+
const layoutResult = layoutResults.get(element);
|
|
86
|
+
if (layoutResult && typeof layoutResult.destroy === "function") {
|
|
87
|
+
try {
|
|
88
|
+
layoutResult.destroy();
|
|
89
|
+
} catch (e) {
|
|
90
|
+
// Ignore destroy errors - element may already be cleaned up
|
|
91
|
+
}
|
|
92
|
+
layoutResults.delete(element);
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
if (!enableRecycling) {
|
|
75
96
|
element.remove();
|
|
76
97
|
return;
|
|
77
98
|
}
|
|
78
|
-
// Clean
|
|
99
|
+
// Clean element for reuse (no cloning - reuse the actual element)
|
|
79
100
|
element.className = "mtrl-viewport-item";
|
|
80
101
|
element.removeAttribute("data-index");
|
|
81
102
|
element.style.cssText = "";
|
|
82
103
|
element.innerHTML = "";
|
|
83
|
-
const cleanElement = element.cloneNode(false) as HTMLElement;
|
|
84
104
|
|
|
105
|
+
// Remove from DOM first
|
|
106
|
+
if (element.parentNode) {
|
|
107
|
+
element.parentNode.removeChild(element);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Add to pool if not full
|
|
85
111
|
if (elementPool.length < maxPoolSize) {
|
|
86
|
-
elementPool.push(
|
|
112
|
+
elementPool.push(element);
|
|
87
113
|
poolStats.poolSize = elementPool.length;
|
|
88
114
|
}
|
|
89
|
-
element
|
|
115
|
+
// If pool is full, element is just dereferenced and GC'd
|
|
90
116
|
};
|
|
91
117
|
|
|
92
118
|
// Initialize
|
|
93
119
|
wrapInitialize(component, () => {
|
|
94
120
|
viewportState = getViewportState(component) as ViewportState;
|
|
95
121
|
|
|
122
|
+
// Set initial items container height from current virtual size
|
|
123
|
+
if (viewportState?.itemsContainer && viewportState.virtualTotalSize > 0) {
|
|
124
|
+
viewportState.itemsContainer.style.height = `${viewportState.virtualTotalSize}px`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Listen for item update requests from API
|
|
128
|
+
component.on?.("item:update-request", (data: any) => {
|
|
129
|
+
const { index, item, previousItem } = data;
|
|
130
|
+
|
|
131
|
+
// Update the item in collectionItems
|
|
132
|
+
collectionItems[index] = item;
|
|
133
|
+
|
|
134
|
+
// Check if this item is currently rendered
|
|
135
|
+
const existingElement = renderedElements.get(index);
|
|
136
|
+
const wasVisible = !!existingElement;
|
|
137
|
+
|
|
138
|
+
// Check if item was selected before update
|
|
139
|
+
const wasSelected = existingElement
|
|
140
|
+
? hasClass(
|
|
141
|
+
existingElement,
|
|
142
|
+
VIEWPORT_CONSTANTS.SELECTION.SELECTED_CLASS,
|
|
143
|
+
) || hasClass(existingElement, "mtrl-viewport-item--selected")
|
|
144
|
+
: false;
|
|
145
|
+
|
|
146
|
+
if (existingElement && existingElement.parentNode) {
|
|
147
|
+
// Re-render the item
|
|
148
|
+
const newElement = renderItem(item, index);
|
|
149
|
+
|
|
150
|
+
if (newElement) {
|
|
151
|
+
// Copy position styles from existing element
|
|
152
|
+
Object.assign(newElement.style, {
|
|
153
|
+
position: existingElement.style.position,
|
|
154
|
+
transform: existingElement.style.transform,
|
|
155
|
+
width: existingElement.style.width,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Preserve selected state
|
|
159
|
+
if (wasSelected) {
|
|
160
|
+
addClass(newElement, VIEWPORT_CONSTANTS.SELECTION.SELECTED_CLASS);
|
|
161
|
+
addClass(newElement, "mtrl-viewport-item--selected");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Add update animation class to inner content for visibility
|
|
165
|
+
addClass(newElement, "viewport-item--updated");
|
|
166
|
+
// Also try to add to first child if it exists (the actual item content)
|
|
167
|
+
const innerItem = newElement.firstElementChild as HTMLElement;
|
|
168
|
+
if (innerItem) {
|
|
169
|
+
addClass(innerItem, "item--updated");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Replace in DOM
|
|
173
|
+
existingElement.parentNode.replaceChild(
|
|
174
|
+
newElement,
|
|
175
|
+
existingElement,
|
|
176
|
+
);
|
|
177
|
+
renderedElements.set(index, newElement);
|
|
178
|
+
releaseElement(existingElement);
|
|
179
|
+
|
|
180
|
+
// Remove the animation class after transition
|
|
181
|
+
setTimeout(() => {
|
|
182
|
+
removeClass(newElement, "viewport-item--updated");
|
|
183
|
+
if (innerItem) {
|
|
184
|
+
removeClass(innerItem, "item--updated");
|
|
185
|
+
}
|
|
186
|
+
}, 500);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Emit completion event
|
|
191
|
+
component.emit?.("item:updated", {
|
|
192
|
+
item,
|
|
193
|
+
index,
|
|
194
|
+
previousItem,
|
|
195
|
+
wasVisible,
|
|
196
|
+
wasSelected,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Listen for item remove requests from API
|
|
201
|
+
component.on?.("item:remove-request", (data: any) => {
|
|
202
|
+
const { index, item } = data;
|
|
203
|
+
isRemovingItem = true;
|
|
204
|
+
|
|
205
|
+
// console.log(
|
|
206
|
+
// `[RENDER-FIX] ========== ITEM REMOVE START (v4) ==========`,
|
|
207
|
+
// );
|
|
208
|
+
// console.log(`[RENDER-FIX] Removing index: ${index}`);
|
|
209
|
+
|
|
210
|
+
// Get the source of truth - collection.items has already been spliced by api.ts
|
|
211
|
+
const collectionItemsSource =
|
|
212
|
+
(component as any).collection?.items ||
|
|
213
|
+
(component as any).items ||
|
|
214
|
+
[];
|
|
215
|
+
|
|
216
|
+
// console.log(
|
|
217
|
+
// `[RENDER-FIX] collectionItemsSource.length: ${collectionItemsSource.length}`,
|
|
218
|
+
// );
|
|
219
|
+
// console.log(
|
|
220
|
+
// `[RENDER-FIX] collectionItems cache size BEFORE: ${Object.keys(collectionItems).length}`,
|
|
221
|
+
// );
|
|
222
|
+
|
|
223
|
+
// Strategy: Shift collectionItems cache indices down, but use actual data
|
|
224
|
+
// from collection.items (which has been spliced) for the loaded range.
|
|
225
|
+
// For indices beyond the loaded range, shift the cached data.
|
|
226
|
+
|
|
227
|
+
const keys = Object.keys(collectionItems)
|
|
228
|
+
.map(Number)
|
|
229
|
+
.filter((k) => !isNaN(k))
|
|
230
|
+
.sort((a, b) => a - b);
|
|
231
|
+
|
|
232
|
+
// Delete the removed index
|
|
233
|
+
delete collectionItems[index];
|
|
234
|
+
|
|
235
|
+
// Shift all indices after the removed one down by 1
|
|
236
|
+
// For indices within the loaded range, use data from collection.items
|
|
237
|
+
// For indices beyond, shift the existing cached data
|
|
238
|
+
const loadedLength = collectionItemsSource.length;
|
|
239
|
+
|
|
240
|
+
for (const key of keys) {
|
|
241
|
+
if (key > index) {
|
|
242
|
+
const newIndex = key - 1;
|
|
243
|
+
// If newIndex is within loaded data, use source; otherwise shift cache
|
|
244
|
+
if (newIndex < loadedLength && collectionItemsSource[newIndex]) {
|
|
245
|
+
collectionItems[newIndex] = collectionItemsSource[newIndex];
|
|
246
|
+
} else {
|
|
247
|
+
// Shift cached data for indices beyond loaded range
|
|
248
|
+
collectionItems[newIndex] = collectionItems[key];
|
|
249
|
+
}
|
|
250
|
+
delete collectionItems[key];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const finalKeys = Object.keys(collectionItems)
|
|
255
|
+
.map(Number)
|
|
256
|
+
.filter((k) => !isNaN(k))
|
|
257
|
+
.sort((a, b) => a - b);
|
|
258
|
+
// console.log(
|
|
259
|
+
// `[RENDER-FIX] collectionItems cache size AFTER: ${finalKeys.length}`,
|
|
260
|
+
// );
|
|
261
|
+
// console.log(
|
|
262
|
+
// `[RENDER-FIX] collectionItems keys: [${finalKeys.slice(0, 10).join(", ")}${finalKeys.length > 10 ? "..." : ""}]`,
|
|
263
|
+
// );
|
|
264
|
+
// console.log(
|
|
265
|
+
// `[RENDER-FIX] viewportState.totalItems: ${viewportState?.totalItems}`,
|
|
266
|
+
// );
|
|
267
|
+
// console.log(
|
|
268
|
+
// `[RENDER-FIX] viewportState.visibleRange: ${JSON.stringify(viewportState?.visibleRange)}`,
|
|
269
|
+
// );
|
|
270
|
+
|
|
271
|
+
// Check for any undefined/null values in first 30 items
|
|
272
|
+
const badIndices: number[] = [];
|
|
273
|
+
for (let i = 0; i < Math.min(30, finalKeys.length); i++) {
|
|
274
|
+
const k = finalKeys[i];
|
|
275
|
+
if (!collectionItems[k] || collectionItems[k]._placeholder) {
|
|
276
|
+
badIndices.push(k);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Remove the rendered element at this index
|
|
281
|
+
const existingElement = renderedElements.get(index);
|
|
282
|
+
if (existingElement && existingElement.parentNode) {
|
|
283
|
+
releaseElement(existingElement);
|
|
284
|
+
}
|
|
285
|
+
renderedElements.delete(index);
|
|
286
|
+
|
|
287
|
+
// Shift rendered elements indices down
|
|
288
|
+
const renderedKeys = Array.from(renderedElements.keys()).sort(
|
|
289
|
+
(a, b) => a - b,
|
|
290
|
+
);
|
|
291
|
+
const newRenderedElements = new Map<number, HTMLElement>();
|
|
292
|
+
for (const key of renderedKeys) {
|
|
293
|
+
if (key > index) {
|
|
294
|
+
const element = renderedElements.get(key);
|
|
295
|
+
if (element) {
|
|
296
|
+
// Update data-index attribute
|
|
297
|
+
element.dataset.index = String(key - 1);
|
|
298
|
+
newRenderedElements.set(key - 1, element);
|
|
299
|
+
}
|
|
300
|
+
} else if (key < index) {
|
|
301
|
+
const element = renderedElements.get(key);
|
|
302
|
+
if (element) {
|
|
303
|
+
newRenderedElements.set(key, element);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
renderedElements.clear();
|
|
308
|
+
for (const [key, element] of newRenderedElements) {
|
|
309
|
+
renderedElements.set(key, element);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Decrement totalItems NOW so renderItems() uses the correct count
|
|
313
|
+
// setTotalItems() will be called later by the API, which will emit
|
|
314
|
+
// viewport:total-items-changed for virtual size recalculation
|
|
315
|
+
if (viewportState && viewportState.totalItems > 0) {
|
|
316
|
+
viewportState.totalItems--;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Reset visible range to force re-render
|
|
320
|
+
currentVisibleRange = { start: -1, end: -1 };
|
|
321
|
+
|
|
322
|
+
// Emit completion event
|
|
323
|
+
component.emit?.("item:removed", {
|
|
324
|
+
item,
|
|
325
|
+
index,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// After item removal, clear ALL loadedRanges and collectionItems cache
|
|
329
|
+
// This forces a complete reload which is more reliable than trying to
|
|
330
|
+
// track shifted indices. The items array has been spliced and all indices
|
|
331
|
+
// are now different from what loadedRanges thinks they are.
|
|
332
|
+
const collection = (component.viewport as any)?.collection;
|
|
333
|
+
if (collection?.getLoadedRanges) {
|
|
334
|
+
const loadedRanges = collection.getLoadedRanges();
|
|
335
|
+
loadedRanges.clear();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Clear the entire collectionItems cache - it's all stale after removal
|
|
339
|
+
// Keep only the items we actually have in the source array
|
|
340
|
+
const loadedItemsCount = collectionItemsSource.length;
|
|
341
|
+
const cacheKeys = Object.keys(collectionItems)
|
|
342
|
+
.map(Number)
|
|
343
|
+
.filter((k) => !isNaN(k));
|
|
344
|
+
cacheKeys.forEach((key) => {
|
|
345
|
+
if (key >= loadedItemsCount) {
|
|
346
|
+
delete collectionItems[key];
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Trigger reload of visible range
|
|
351
|
+
const visibleRange = component.viewport?.getVisibleRange?.();
|
|
352
|
+
if (visibleRange && collection?.loadMissingRanges) {
|
|
353
|
+
collection.loadMissingRanges(
|
|
354
|
+
{ start: 0, end: Math.max(visibleRange.end, loadedItemsCount) },
|
|
355
|
+
"item-removal",
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Trigger re-render
|
|
360
|
+
renderItems();
|
|
361
|
+
isRemovingItem = false;
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Listen for collection items evicted - clean up our cache too
|
|
365
|
+
component.on?.("collection:items-evicted", (data: any) => {
|
|
366
|
+
const { keepStart, keepEnd, evictedCount } = data;
|
|
367
|
+
let cleanedCount = 0;
|
|
368
|
+
|
|
369
|
+
// Remove evicted items from our cache
|
|
370
|
+
for (const key in collectionItems) {
|
|
371
|
+
const index = parseInt(key, 10);
|
|
372
|
+
if (index < keepStart || index > keepEnd) {
|
|
373
|
+
delete collectionItems[index];
|
|
374
|
+
cleanedCount++;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
96
379
|
// Listen for collection data loaded
|
|
97
380
|
component.on?.("collection:range-loaded", (data: any) => {
|
|
381
|
+
// console.log(
|
|
382
|
+
// `[RENDER-FIX] collection:range-loaded - offset: ${data.offset}, items: ${data.items?.length}`,
|
|
383
|
+
// );
|
|
98
384
|
if (!data.items?.length) return;
|
|
99
385
|
|
|
100
386
|
// Analyze data structure on first load
|
|
@@ -104,23 +390,34 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
104
390
|
}
|
|
105
391
|
|
|
106
392
|
// Update collection items and replace placeholders
|
|
393
|
+
// console.log(
|
|
394
|
+
// `[RENDER-FIX] Updating collectionItems for indices ${data.offset} to ${data.offset + data.items.length - 1}`,
|
|
395
|
+
// );
|
|
396
|
+
let replacedCount = 0;
|
|
397
|
+
let skippedCount = 0;
|
|
107
398
|
data.items.forEach((item: any, i: number) => {
|
|
108
399
|
const index = data.offset + i;
|
|
109
400
|
const oldItem = collectionItems[index];
|
|
110
401
|
collectionItems[index] = item;
|
|
111
402
|
|
|
112
403
|
// Replace placeholder in DOM if needed
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
) {
|
|
404
|
+
const wasPlaceholder = oldItem && isPlaceholder(oldItem);
|
|
405
|
+
const hasElement = renderedElements.has(index);
|
|
406
|
+
if (wasPlaceholder && hasElement) {
|
|
407
|
+
replacedCount++;
|
|
118
408
|
const element = renderedElements.get(index);
|
|
119
409
|
if (element) {
|
|
120
410
|
const newElement = renderItem(item, index);
|
|
121
411
|
if (newElement) {
|
|
122
|
-
// Remove placeholder classes
|
|
412
|
+
// Remove placeholder classes from wrapper
|
|
123
413
|
removeClass(newElement, VIEWPORT_CONSTANTS.PLACEHOLDER.CLASS);
|
|
414
|
+
// Also remove from inner element (for string templates)
|
|
415
|
+
if (newElement.firstElementChild) {
|
|
416
|
+
removeClass(
|
|
417
|
+
newElement.firstElementChild as HTMLElement,
|
|
418
|
+
VIEWPORT_CONSTANTS.PLACEHOLDER.CLASS,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
124
421
|
|
|
125
422
|
// Add replaced class for fade-in animation
|
|
126
423
|
addClass(newElement, "viewport-item--replaced");
|
|
@@ -139,10 +436,21 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
139
436
|
setTimeout(() => {
|
|
140
437
|
removeClass(newElement, "viewport-item--replaced");
|
|
141
438
|
}, 300);
|
|
439
|
+
} else {
|
|
440
|
+
// renderItem returned null - still release the old element
|
|
441
|
+
releaseElement(element);
|
|
442
|
+
renderedElements.delete(index);
|
|
142
443
|
}
|
|
143
444
|
}
|
|
445
|
+
} else if (wasPlaceholder && !hasElement) {
|
|
446
|
+
skippedCount++;
|
|
144
447
|
}
|
|
145
448
|
});
|
|
449
|
+
// if (replacedCount > 0 || skippedCount > 0) {
|
|
450
|
+
// console.log(
|
|
451
|
+
// `[RENDER-FIX] Placeholder replacement: ${replacedCount} replaced, ${skippedCount} skipped (not rendered)`,
|
|
452
|
+
// );
|
|
453
|
+
// }
|
|
146
454
|
|
|
147
455
|
// Check if we need to render
|
|
148
456
|
const { visibleRange } = viewportState || {};
|
|
@@ -150,7 +458,7 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
150
458
|
const renderStart = Math.max(0, visibleRange.start - overscan);
|
|
151
459
|
const renderEnd = Math.min(
|
|
152
460
|
viewportState?.totalItems ?? 0 - 1,
|
|
153
|
-
visibleRange.end + overscan
|
|
461
|
+
visibleRange.end + overscan,
|
|
154
462
|
);
|
|
155
463
|
const loadedStart = data.offset;
|
|
156
464
|
const loadedEnd = data.offset + data.items.length - 1;
|
|
@@ -158,7 +466,7 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
158
466
|
// Check for placeholders in range
|
|
159
467
|
const hasPlaceholdersInRange = Array.from(
|
|
160
468
|
{ length: Math.min(loadedEnd, renderEnd) - loadedStart + 1 },
|
|
161
|
-
(_, i) => collectionItems[loadedStart + i]
|
|
469
|
+
(_, i) => collectionItems[loadedStart + i],
|
|
162
470
|
).some((item) => item && isPlaceholder(item));
|
|
163
471
|
|
|
164
472
|
const needsRender =
|
|
@@ -174,6 +482,16 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
174
482
|
// Listen for events
|
|
175
483
|
component.on?.("viewport:range-changed", renderItems);
|
|
176
484
|
component.on?.("viewport:scroll", updateItemPositions);
|
|
485
|
+
|
|
486
|
+
// Update items container height when virtual size changes
|
|
487
|
+
component.on?.("viewport:virtual-size-changed", (data: any) => {
|
|
488
|
+
if (
|
|
489
|
+
viewportState?.itemsContainer &&
|
|
490
|
+
data.totalVirtualSize !== undefined
|
|
491
|
+
) {
|
|
492
|
+
viewportState.itemsContainer.style.height = `${data.totalVirtualSize}px`;
|
|
493
|
+
}
|
|
494
|
+
});
|
|
177
495
|
});
|
|
178
496
|
|
|
179
497
|
// Template helpers
|
|
@@ -195,7 +513,7 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
195
513
|
const processLayoutSchema = (
|
|
196
514
|
schema: any,
|
|
197
515
|
item: any,
|
|
198
|
-
index: number
|
|
516
|
+
index: number,
|
|
199
517
|
): any => {
|
|
200
518
|
if (typeof schema === "string") {
|
|
201
519
|
// Handle variable substitution like {{name}}, {{index}}
|
|
@@ -234,12 +552,21 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
234
552
|
totalItems: number,
|
|
235
553
|
itemSize: number,
|
|
236
554
|
virtualTotalSize: number,
|
|
237
|
-
containerSize: number
|
|
555
|
+
containerSize: number,
|
|
556
|
+
targetScrollIndex?: number,
|
|
238
557
|
): number => {
|
|
239
558
|
const actualTotalSize = totalItems * itemSize;
|
|
240
559
|
const isCompressed =
|
|
241
560
|
actualTotalSize > virtualTotalSize && virtualTotalSize > 0;
|
|
242
561
|
|
|
562
|
+
// If we have a targetScrollIndex (from initialScrollIndex), use it directly
|
|
563
|
+
// This ensures items are positioned correctly even with compression
|
|
564
|
+
if (targetScrollIndex !== undefined && targetScrollIndex > 0) {
|
|
565
|
+
// Position items relative to the target index
|
|
566
|
+
// The target index should appear at the top of the viewport
|
|
567
|
+
return (index - targetScrollIndex) * itemSize;
|
|
568
|
+
}
|
|
569
|
+
|
|
243
570
|
if (!isCompressed || totalItems === 0) {
|
|
244
571
|
return index * itemSize - scrollPosition;
|
|
245
572
|
}
|
|
@@ -260,7 +587,7 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
260
587
|
const exactScrollIndex = scrollRatio * totalItems;
|
|
261
588
|
const interpolation = Math.max(
|
|
262
589
|
0,
|
|
263
|
-
Math.min(1, 1 - distanceFromBottom / nearBottomThreshold)
|
|
590
|
+
Math.min(1, 1 - distanceFromBottom / nearBottomThreshold),
|
|
264
591
|
);
|
|
265
592
|
|
|
266
593
|
const bottomPosition = (index - firstVisibleAtBottom) * itemSize;
|
|
@@ -300,18 +627,37 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
300
627
|
// If the layout created a wrapper, use it directly
|
|
301
628
|
if (element && element.nodeType === 1) {
|
|
302
629
|
// Element is already created by layout system
|
|
630
|
+
// Store layoutResult for cleanup when element is released (prevents memory leak)
|
|
631
|
+
layoutResults.set(element, layoutResult);
|
|
303
632
|
} else {
|
|
304
633
|
// Fallback if layout didn't create a proper element
|
|
305
634
|
element = getPooledElement();
|
|
306
635
|
if (layoutResult.element) {
|
|
307
636
|
element.appendChild(layoutResult.element);
|
|
637
|
+
// Store layoutResult on wrapper element for cleanup
|
|
638
|
+
layoutResults.set(element, layoutResult);
|
|
308
639
|
}
|
|
309
640
|
}
|
|
310
641
|
} else if (typeof result === "string") {
|
|
311
|
-
element
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
642
|
+
// Parse HTML using template element - optimized path
|
|
643
|
+
// Move nodes directly instead of cloning - much faster, no memory overhead
|
|
644
|
+
templateParser.innerHTML = result;
|
|
645
|
+
const content = templateParser.content;
|
|
646
|
+
|
|
647
|
+
if (content.children.length === 1) {
|
|
648
|
+
// Single root element - move directly (faster than cloneNode)
|
|
649
|
+
element = content.firstElementChild as HTMLElement;
|
|
650
|
+
content.removeChild(element);
|
|
651
|
+
addClass(element, "mtrl-viewport-item");
|
|
652
|
+
if (isPlaceholder(item)) {
|
|
653
|
+
addClass(element, VIEWPORT_CONSTANTS.PLACEHOLDER.CLASS);
|
|
654
|
+
}
|
|
655
|
+
} else {
|
|
656
|
+
// Multiple children - move all into pooled element
|
|
657
|
+
element = getPooledElement();
|
|
658
|
+
while (content.firstChild) {
|
|
659
|
+
element.appendChild(content.firstChild);
|
|
660
|
+
}
|
|
315
661
|
}
|
|
316
662
|
} else if (result instanceof HTMLElement) {
|
|
317
663
|
element = getPooledElement();
|
|
@@ -352,7 +698,7 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
352
698
|
actualTotalSize > virtualTotalSize && virtualTotalSize > 0;
|
|
353
699
|
|
|
354
700
|
const sortedIndices = Array.from(renderedElements.keys()).sort(
|
|
355
|
-
(a, b) => a - b
|
|
701
|
+
(a, b) => a - b,
|
|
356
702
|
);
|
|
357
703
|
if (!sortedIndices.length) return;
|
|
358
704
|
|
|
@@ -371,7 +717,7 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
371
717
|
const exactScrollIndex = scrollRatio * totalItems;
|
|
372
718
|
const interpolation = Math.max(
|
|
373
719
|
0,
|
|
374
|
-
Math.min(1, 1 - distanceFromBottom / containerSize)
|
|
720
|
+
Math.min(1, 1 - distanceFromBottom / containerSize),
|
|
375
721
|
);
|
|
376
722
|
|
|
377
723
|
const bottomPos = (firstIndex - firstVisibleAtBottom) * itemSize;
|
|
@@ -390,7 +736,7 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
390
736
|
const element = renderedElements.get(index);
|
|
391
737
|
if (element) {
|
|
392
738
|
element.style.transform = `translateY(${Math.round(
|
|
393
|
-
currentPosition
|
|
739
|
+
currentPosition,
|
|
394
740
|
)}px)`;
|
|
395
741
|
|
|
396
742
|
// Strategic log for last items
|
|
@@ -409,7 +755,9 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
409
755
|
|
|
410
756
|
// Main render function
|
|
411
757
|
const renderItems = () => {
|
|
412
|
-
if (!viewportState?.itemsContainer)
|
|
758
|
+
if (!viewportState?.itemsContainer) {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
413
761
|
|
|
414
762
|
const {
|
|
415
763
|
visibleRange,
|
|
@@ -421,15 +769,24 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
421
769
|
virtualTotalSize,
|
|
422
770
|
} = viewportState;
|
|
423
771
|
|
|
424
|
-
// Validate range
|
|
772
|
+
// Validate range - skip rendering if no items or range is invalid
|
|
425
773
|
if (
|
|
426
774
|
!visibleRange ||
|
|
775
|
+
totalItems <= 0 ||
|
|
427
776
|
visibleRange.start < 0 ||
|
|
428
777
|
visibleRange.start >= totalItems ||
|
|
429
778
|
visibleRange.end < visibleRange.start ||
|
|
430
779
|
isNaN(visibleRange.start) ||
|
|
431
780
|
isNaN(visibleRange.end)
|
|
432
781
|
) {
|
|
782
|
+
// If totalItems is 0, clear all rendered elements to remove stale placeholders
|
|
783
|
+
if (totalItems <= 0 && renderedElements.size > 0) {
|
|
784
|
+
Array.from(renderedElements.entries()).forEach(([index, element]) => {
|
|
785
|
+
if (element.parentNode) releaseElement(element);
|
|
786
|
+
renderedElements.delete(index);
|
|
787
|
+
});
|
|
788
|
+
currentVisibleRange = { start: -1, end: -1 };
|
|
789
|
+
}
|
|
433
790
|
return;
|
|
434
791
|
}
|
|
435
792
|
|
|
@@ -447,6 +804,15 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
447
804
|
const renderStart = Math.max(0, visibleRange.start - overscan);
|
|
448
805
|
const renderEnd = Math.min(totalItems - 1, visibleRange.end + overscan);
|
|
449
806
|
|
|
807
|
+
// if (isRemovingItem) {
|
|
808
|
+
// console.log(
|
|
809
|
+
// `[RENDER-FIX] renderItems calc: visibleRange=${JSON.stringify(visibleRange)}, overscan=${overscan}, totalItems=${totalItems}`,
|
|
810
|
+
// );
|
|
811
|
+
// console.log(
|
|
812
|
+
// `[RENDER-FIX] renderItems calc: renderStart=${renderStart}, renderEnd=${renderEnd}`,
|
|
813
|
+
// );
|
|
814
|
+
// }
|
|
815
|
+
|
|
450
816
|
// Remove items outside range
|
|
451
817
|
Array.from(renderedElements.entries())
|
|
452
818
|
.filter(([index]) => index < renderStart || index > renderEnd)
|
|
@@ -468,6 +834,8 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
468
834
|
|
|
469
835
|
let item = items[i];
|
|
470
836
|
if (!item) {
|
|
837
|
+
// Only log during removal to avoid spam during scroll
|
|
838
|
+
|
|
471
839
|
missingItems.push(i);
|
|
472
840
|
// Generate placeholder
|
|
473
841
|
const placeholders = (component as any).placeholders;
|
|
@@ -485,13 +853,16 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
485
853
|
|
|
486
854
|
const element = renderItem(item, i);
|
|
487
855
|
if (element) {
|
|
856
|
+
// Get targetScrollIndex from viewportState if available
|
|
857
|
+
const targetScrollIndex = (viewportState as any)?.targetScrollIndex;
|
|
488
858
|
const position = calculateItemPosition(
|
|
489
859
|
i,
|
|
490
860
|
scrollPosition,
|
|
491
861
|
totalItems,
|
|
492
862
|
itemSize,
|
|
493
863
|
virtualTotalSize,
|
|
494
|
-
containerSize
|
|
864
|
+
containerSize,
|
|
865
|
+
targetScrollIndex,
|
|
495
866
|
);
|
|
496
867
|
|
|
497
868
|
Object.assign(element.style, {
|
|
@@ -515,12 +886,17 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
515
886
|
start: Math.min(...missingItems),
|
|
516
887
|
end: Math.max(...missingItems),
|
|
517
888
|
},
|
|
518
|
-
"rendering:missing-items"
|
|
889
|
+
"rendering:missing-items",
|
|
519
890
|
);
|
|
520
891
|
}
|
|
521
892
|
|
|
522
893
|
currentVisibleRange = visibleRange;
|
|
523
894
|
|
|
895
|
+
// Log DOM stats periodically (every 10 renders)
|
|
896
|
+
if (poolStats.created % 50 === 0 && poolStats.created > 0) {
|
|
897
|
+
logDOMStats(`render:${poolStats.created}`);
|
|
898
|
+
}
|
|
899
|
+
|
|
524
900
|
// Emit items rendered event with elements for size calculation
|
|
525
901
|
const renderedElementsArray = Array.from(renderedElements.values());
|
|
526
902
|
component.emit?.("viewport:items-rendered", {
|
|
@@ -542,7 +918,7 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
542
918
|
) {
|
|
543
919
|
component.viewport.collection.loadMissingRanges(
|
|
544
920
|
visibleRange,
|
|
545
|
-
"rendering:no-items"
|
|
921
|
+
"rendering:no-items",
|
|
546
922
|
);
|
|
547
923
|
}
|
|
548
924
|
};
|
|
@@ -556,11 +932,29 @@ export const withRendering = (config: RenderingConfig = {}) => {
|
|
|
556
932
|
|
|
557
933
|
// Cleanup
|
|
558
934
|
wrapDestroy(component, () => {
|
|
935
|
+
// Release all rendered elements - use releaseElement to properly destroy layoutResults
|
|
559
936
|
renderedElements.forEach((element) => {
|
|
560
|
-
|
|
937
|
+
releaseElement(element);
|
|
561
938
|
});
|
|
562
939
|
renderedElements.clear();
|
|
940
|
+
|
|
941
|
+
// Clear element pool
|
|
563
942
|
elementPool.length = 0;
|
|
943
|
+
|
|
944
|
+
// Clear collection items cache - this is critical for memory!
|
|
945
|
+
for (const key in collectionItems) {
|
|
946
|
+
delete collectionItems[key];
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Reset state
|
|
950
|
+
currentVisibleRange = { start: -1, end: -1 };
|
|
951
|
+
viewportState = null;
|
|
952
|
+
lastRenderTime = 0;
|
|
953
|
+
|
|
954
|
+
// Reset pool stats
|
|
955
|
+
poolStats.created = 0;
|
|
956
|
+
poolStats.recycled = 0;
|
|
957
|
+
poolStats.poolSize = 0;
|
|
564
958
|
});
|
|
565
959
|
|
|
566
960
|
return component;
|