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.
@@ -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 and clone for reuse
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(cleanElement);
112
+ elementPool.push(element);
87
113
  poolStats.poolSize = elementPool.length;
88
114
  }
89
- element.remove();
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
- if (
114
- oldItem &&
115
- isPlaceholder(oldItem) &&
116
- renderedElements.has(index)
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 = getPooledElement();
312
- element.innerHTML = result;
313
- if (element.children.length === 1) {
314
- addClass(element.firstElementChild as HTMLElement, "viewport-item");
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) return;
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
- if (element.parentNode) releaseElement(element);
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;