@v1nt1248/3nclient-lib 0.3.35 → 0.3.36

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@v1nt1248/3nclient-lib",
3
3
  "license": "AGPL-3.0-or-later",
4
4
  "author": "v1nt1248",
5
- "version": "0.3.35",
5
+ "version": "0.3.36",
6
6
  "description": "Library for 3NWeb clients",
7
7
  "type": "module",
8
8
  "files": [
@@ -104,17 +104,5 @@
104
104
  "vue-eslint-parser": "10.4.0",
105
105
  "vue-tsc": "3.3.2"
106
106
  },
107
- "packageManager": "pnpm@10.33.0",
108
- "pnpm": {
109
- "peerDependencyRules": {
110
- "ignoreMissing": [
111
- "@algolia/client-search",
112
- "search-insights"
113
- ]
114
- },
115
- "onlyBuiltDependencies": [
116
- "esbuild",
117
- "vue-demi"
118
- ]
119
- }
107
+ "packageManager": "pnpm@11.18.0"
120
108
  }
@@ -67,7 +67,12 @@ import type {
67
67
  import Ui3nList from './ui3n-list/ui3n-list.vue';
68
68
  import type { Ui3nListProps, Ui3nListEmits, Ui3nListSlots } from './ui3n-list/types';
69
69
  import Ui3nVirtualScroll from './ui3n-virtual-scroll/ui3n-virtual-scroll.vue';
70
- import type { Ui3nVirtualScrollProps, Ui3nVirtualScrollSlots } from './ui3n-virtual-scroll/types';
70
+ import type {
71
+ Ui3nVirtualScrollProps,
72
+ Ui3nVirtualScrollEmits,
73
+ Ui3nVirtualScrollSlots,
74
+ Ui3nVirtualScrollExpose,
75
+ } from './ui3n-virtual-scroll/types';
71
76
  import Ui3nTabs from './ui3n-tabs/ui3n-tabs.vue';
72
77
  import type { Ui3nTabsProps, Ui3nTabsEmits, Ui3nTabsSlots } from './ui3n-tabs/types';
73
78
  import Ui3nBadge from './ui3n-badge/ui3n-badge.vue';
@@ -225,7 +230,9 @@ export {
225
230
  Ui3nListSlots,
226
231
  Ui3nVirtualScroll,
227
232
  Ui3nVirtualScrollProps,
233
+ Ui3nVirtualScrollEmits,
228
234
  Ui3nVirtualScrollSlots,
235
+ Ui3nVirtualScrollExpose,
229
236
  Ui3nTabs,
230
237
  Ui3nTabsProps,
231
238
  Ui3nTabsEmits,
@@ -16,6 +16,39 @@ export interface Ui3nVirtualScrollProps<T> {
16
16
  * Number of items to render ahead
17
17
  */
18
18
  renderAhead?: number;
19
+ /**
20
+ * When true, if the user is near the bottom of the list,
21
+ * keep the viewport stuck to the bottom after items update
22
+ * (typical chat behavior). Default: false.
23
+ */
24
+ stickToBottom?: boolean;
25
+ /**
26
+ * Distance from the bottom (px) treated as "near bottom" for stickToBottom.
27
+ * Default: 32.
28
+ */
29
+ stickToBottomThreshold?: number;
30
+ /**
31
+ * Distance from an edge (px) at which 'reached-top' / 'reached-bottom' fire.
32
+ * Default: 64.
33
+ */
34
+ reachEdgeThreshold?: number;
35
+ }
36
+
37
+ /**
38
+ * Virtual scroll component emits
39
+ */
40
+ export interface Ui3nVirtualScrollEmits {
41
+ (ev: 'scroll', value: Event): void;
42
+ /**
43
+ * The list has been scrolled close to its top - the moment to load
44
+ * a previous page of items. Fires once per approach: it will not repeat
45
+ * until the list leaves the edge or 'items' change.
46
+ */
47
+ (ev: 'reached-top'): void;
48
+ /**
49
+ * Same as 'reached-top', for the bottom edge.
50
+ */
51
+ (ev: 'reached-bottom'): void;
19
52
  }
20
53
 
21
54
  /**
@@ -27,3 +60,10 @@ export interface Ui3nVirtualScrollSlots<T> {
27
60
  */
28
61
  item: ({ value, index }: { value: T; index: number }) => VNode;
29
62
  }
63
+
64
+ export interface Ui3nVirtualScrollExpose {
65
+ scrollToBottom(): void;
66
+ scrollToItem(id: string, align?: 'start' | 'end' | 'center'): void;
67
+ getScrollTop(): number;
68
+ isStuckToBottom(): boolean;
69
+ }
@@ -1,18 +1,33 @@
1
- <script lang="ts" setup generic="T extends { id?: string }">
1
+ <script lang="ts" setup generic="T extends { id: string }">
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
- import { computed, ref, shallowRef, onMounted, onBeforeUnmount, watch } from 'vue';
3
+ import { computed, ref, shallowRef, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
4
4
  import Ui3nScrollbarVertical from '../ui3n-scrollbar-vertical/ui3n-scrollbar-vertical.vue';
5
- import type { Ui3nVirtualScrollProps, Ui3nVirtualScrollSlots } from './types';
5
+ import type {
6
+ Ui3nVirtualScrollEmits,
7
+ Ui3nVirtualScrollExpose,
8
+ Ui3nVirtualScrollProps,
9
+ Ui3nVirtualScrollSlots,
10
+ } from './types';
6
11
 
7
12
  interface MeasuredSize {
8
13
  height: number;
9
14
  top: number;
10
15
  }
11
16
 
17
+ interface ScrollAnchor {
18
+ id: string;
19
+ /** How far scrollTop is below the anchor item top */
20
+ offsetWithinItem: number;
21
+ }
22
+
12
23
  const props = withDefaults(defineProps<Ui3nVirtualScrollProps<T>>(), {
13
24
  renderAhead: 20,
14
25
  minChildHeight: 16,
26
+ stickToBottom: false,
27
+ stickToBottomThreshold: 32,
28
+ reachEdgeThreshold: 64,
15
29
  });
30
+ const emits = defineEmits<Ui3nVirtualScrollEmits>();
16
31
  defineSlots<Ui3nVirtualScrollSlots<T>>();
17
32
 
18
33
  const scrollTop = ref(0);
@@ -22,14 +37,28 @@
22
37
  const sizeCache = shallowRef<MeasuredSize[]>([]);
23
38
  const cacheVersion = ref(0);
24
39
 
40
+ /** Long-lived measured heights keyed by item id */
41
+ const heightById = new Map<string, number>();
42
+ /** id -> index in the current props.items */
43
+ const idToIndex = shallowRef(new Map<string, number>());
44
+
25
45
  let isAdjustingScroll = false;
26
46
  let listResizeObserver: ResizeObserver | null = null;
27
47
  let containerResizeObserver: ResizeObserver | null = null;
48
+ /** Previous items snapshot for anchor capture */
49
+ let prevItemsRef: T[] = [];
50
+ /**
51
+ * Which edge has already been announced. Without it a consumer that loads
52
+ * a page on 'reached-top' would get an event on every scroll tick while
53
+ * sitting at the edge, and fire a request per tick.
54
+ */
55
+ let announcedEdge: 'top' | 'bottom' | null = null;
28
56
 
29
- const itemRefs = new Map<string | number, HTMLElement>();
57
+ const itemRefs = new Map<string, HTMLElement>();
30
58
 
31
59
  const totalHeight = computed(() => {
32
- cacheVersion.value;
60
+ // Touched to make this computed depend on measurements of the size cache
61
+ void cacheVersion.value;
33
62
  if (!sizeCache.value.length) {
34
63
  return 0;
35
64
  }
@@ -47,21 +76,18 @@
47
76
  return el ? el.clientHeight : 0;
48
77
  });
49
78
 
50
- const startNode = computed(() => {
51
- cacheVersion.value;
52
- const targetTop = scrollTop.value;
53
-
54
- if (!sizeCache.value.length) {
79
+ function findIndexAtOffset(cache: MeasuredSize[], targetTop: number): number {
80
+ if (!cache.length) {
55
81
  return 0;
56
82
  }
57
83
 
58
84
  let low = 0;
59
- let high = sizeCache.value.length - 1;
85
+ let high = cache.length - 1;
60
86
  let foundIndex = -1;
61
87
 
62
88
  while (low <= high) {
63
89
  const mid = Math.floor((low + high) / 2);
64
- const item = sizeCache.value[mid];
90
+ const item = cache[mid];
65
91
 
66
92
  if (item.top <= targetTop && item.top + item.height > targetTop) {
67
93
  foundIndex = mid;
@@ -77,21 +103,45 @@
77
103
  foundIndex = Math.max(0, high);
78
104
  }
79
105
 
106
+ return foundIndex;
107
+ }
108
+
109
+ const startNode = computed(() => {
110
+ // Touched to make this computed depend on measurements of the size cache
111
+ void cacheVersion.value;
112
+ if (!sizeCache.value.length) {
113
+ return 0;
114
+ }
115
+
116
+ const foundIndex = findIndexAtOffset(sizeCache.value, scrollTop.value);
80
117
  return Math.max(0, foundIndex - props.renderAhead);
81
118
  });
82
119
 
120
+ /**
121
+ * How many items to keep mounted. Measured heights are used when they are
122
+ * available: deriving the count from minChildHeight alone inflates the window
123
+ * several times over whenever items are taller than that minimum.
124
+ */
83
125
  const visibleNodeCount = computed(() => {
126
+ // Touched to make this computed depend on measurements of the size cache
127
+ void cacheVersion.value;
84
128
  const height = wrapElementHeight.value;
85
129
  if (!height) {
86
130
  return props.renderAhead * 2;
87
131
  }
88
132
 
89
- const estimatedVisible = Math.ceil(height / props.minChildHeight);
90
- return estimatedVisible + 2 * props.renderAhead;
133
+ if (!sizeCache.value.length) {
134
+ return Math.ceil(height / props.minChildHeight) + 2 * props.renderAhead;
135
+ }
136
+
137
+ const firstVisible = findIndexAtOffset(sizeCache.value, scrollTop.value);
138
+ const lastVisible = findIndexAtOffset(sizeCache.value, scrollTop.value + height);
139
+ return lastVisible - firstVisible + 1 + 2 * props.renderAhead;
91
140
  });
92
141
 
93
142
  const offsetY = computed(() => {
94
- cacheVersion.value;
143
+ // Touched to make this computed depend on measurements of the size cache
144
+ void cacheVersion.value;
95
145
  if (!sizeCache.value.length) {
96
146
  return 0;
97
147
  }
@@ -110,42 +160,283 @@
110
160
  return props.items.slice(start, start + count);
111
161
  });
112
162
 
163
+ function getContainerEl(): HTMLElement | null {
164
+ return scrollbarComponentRef.value?.getContainer() ?? null;
165
+ }
166
+
167
+ function getTotalHeightFromCache(cache: MeasuredSize[]): number {
168
+ if (!cache.length) {
169
+ return 0;
170
+ }
171
+
172
+ const last = cache[cache.length - 1];
173
+ return last.top + last.height;
174
+ }
175
+
176
+ function isNearBottom(
177
+ containerEl: HTMLElement | null,
178
+ cache: MeasuredSize[],
179
+ threshold = props.stickToBottomThreshold,
180
+ ): boolean {
181
+ if (!containerEl || !cache.length) {
182
+ return false;
183
+ }
184
+
185
+ const total = getTotalHeightFromCache(cache);
186
+ const distance = total - (containerEl.scrollTop + containerEl.clientHeight);
187
+ return distance <= threshold;
188
+ }
189
+
190
+ function applyScrollTop(containerEl: HTMLElement | null, nextScrollTop: number) {
191
+ if (!containerEl) {
192
+ scrollTop.value = Math.max(0, nextScrollTop);
193
+ return;
194
+ }
195
+
196
+ isAdjustingScroll = true;
197
+ containerEl.scrollTop = Math.max(0, nextScrollTop);
198
+ scrollTop.value = containerEl.scrollTop;
199
+ isAdjustingScroll = false;
200
+ }
201
+
202
+ function scrollToBottomInternal(containerEl: HTMLElement | null = getContainerEl(), cache: MeasuredSize[] = sizeCache.value) {
203
+ if (!cache.length) {
204
+ return;
205
+ }
206
+
207
+ const total = getTotalHeightFromCache(cache);
208
+ const viewport = containerEl?.clientHeight ?? wrapElementHeight.value;
209
+ applyScrollTop(containerEl, total - viewport);
210
+ }
211
+
212
+ function captureScrollAnchor(
213
+ cache: MeasuredSize[],
214
+ items: T[],
215
+ scrollTopValue: number,
216
+ ): ScrollAnchor | null {
217
+ if (!cache.length || !items.length) {
218
+ return null;
219
+ }
220
+
221
+ const index = findIndexAtOffset(cache, scrollTopValue);
222
+ const row = cache[index];
223
+ const id = items[index]?.id;
224
+
225
+ if (!row || !id) {
226
+ return null;
227
+ }
228
+
229
+ return {
230
+ id,
231
+ offsetWithinItem: scrollTopValue - row.top,
232
+ };
233
+ }
234
+
235
+ function restoreScrollAnchor(
236
+ anchor: ScrollAnchor,
237
+ cache: MeasuredSize[],
238
+ indexMap: Map<string, number>,
239
+ containerEl: HTMLElement | null,
240
+ ): boolean {
241
+ const index = indexMap.get(anchor.id);
242
+ if (index === undefined || !cache[index]) {
243
+ return false;
244
+ }
245
+
246
+ applyScrollTop(containerEl, cache[index].top + anchor.offsetWithinItem);
247
+ return true;
248
+ }
249
+
250
+ function rebuildSizeCacheFromItems(): MeasuredSize[] {
251
+ const nextIdToIndex = new Map<string, number>();
252
+ const nextCache: MeasuredSize[] = new Array(props.items.length);
253
+ const retainedHeights = new Map<string, number>();
254
+
255
+ let currentTop = 0;
256
+
257
+ for (let i = 0; i < props.items.length; i++) {
258
+ const id = props.items[i].id;
259
+ nextIdToIndex.set(id, i);
260
+
261
+ const height = heightById.get(id) ?? props.minChildHeight;
262
+ retainedHeights.set(id, height);
263
+
264
+ nextCache[i] = { height, top: currentTop };
265
+ currentTop += height;
266
+ }
267
+
268
+ // Drop heights for removed ids so the map does not grow forever
269
+ heightById.clear();
270
+ for (const [id, height] of retainedHeights) {
271
+ heightById.set(id, height);
272
+ }
273
+
274
+ if (import.meta.env.DEV && nextIdToIndex.size !== props.items.length) {
275
+ // Duplicates would otherwise fail silently: the map keeps one index per
276
+ // id, so measurements of one element land on another element's row
277
+ console.warn('[Ui3nVirtualScroll] items contain duplicate ids, item heights and scroll anchoring will be wrong');
278
+ }
279
+
280
+ idToIndex.value = nextIdToIndex;
281
+ return nextCache;
282
+ }
283
+
284
+ function syncItemsCache() {
285
+ const containerEl = getContainerEl();
286
+ const prevCache = sizeCache.value;
287
+ const prevItems = prevItemsRef;
288
+
289
+ // An empty previous cache means this is the first fill of the list, and
290
+ // a chat has to open at its newest item rather than at the oldest one
291
+ const shouldStick = props.stickToBottom && (!prevCache.length || isNearBottom(containerEl, prevCache));
292
+ const anchor = shouldStick ? null : captureScrollAnchor(prevCache, prevItems, scrollTop.value);
293
+
294
+ const nextCache = rebuildSizeCacheFromItems();
295
+ sizeCache.value = nextCache;
296
+ cacheVersion.value++;
297
+
298
+ prevItemsRef = props.items.slice();
299
+ // The consumer has responded with new items, so the edge may be announced again
300
+ announcedEdge = null;
301
+
302
+ const finalizeScroll = () => {
303
+ const el = getContainerEl();
304
+
305
+ if (shouldStick) {
306
+ scrollToBottomInternal(el, sizeCache.value);
307
+ } else if (anchor) {
308
+ const restored = restoreScrollAnchor(anchor, sizeCache.value, idToIndex.value, el);
309
+ if (!restored && el) {
310
+ // Anchor item removed — keep scroll within bounds
311
+ const maxScroll = Math.max(0, getTotalHeightFromCache(sizeCache.value) - el.clientHeight);
312
+ if (scrollTop.value > maxScroll) {
313
+ applyScrollTop(el, maxScroll);
314
+ }
315
+ }
316
+ }
317
+
318
+ scrollbarComponentRef.value?.updateMetrics();
319
+ };
320
+
321
+ // Wait for DOM/layout so container metrics and totalHeight are up to date
322
+ nextTick(() => {
323
+ requestAnimationFrame(finalizeScroll);
324
+ });
325
+ }
326
+
113
327
  function onScroll(event: Event) {
114
328
  if (isAdjustingScroll) {
115
329
  return;
116
330
  }
117
331
 
118
- scrollTop.value = (event.target as HTMLDivElement).scrollTop;
332
+ const containerEl = event.target as HTMLDivElement;
333
+ scrollTop.value = containerEl.scrollTop;
334
+ emits('scroll', event);
335
+
336
+ const atTop = scrollTop.value <= props.reachEdgeThreshold;
337
+ const atBottom = isNearBottom(containerEl, sizeCache.value, props.reachEdgeThreshold);
338
+
339
+ if (atTop) {
340
+ if (announcedEdge !== 'top') {
341
+ announcedEdge = 'top';
342
+ emits('reached-top');
343
+ }
344
+ } else if (atBottom) {
345
+ if (announcedEdge !== 'bottom') {
346
+ announcedEdge = 'bottom';
347
+ emits('reached-bottom');
348
+ }
349
+ } else {
350
+ announcedEdge = null;
351
+ }
119
352
  }
120
353
 
121
- function setItemRef(el: any, id: string | number) {
354
+ function setItemRef(el: any, id: string) {
122
355
  if (el) {
123
- const element = el.$el || el;
356
+ const element = (el.$el || el) as HTMLElement;
357
+ const prev = itemRefs.get(id);
358
+ if (prev && prev !== element) {
359
+ listResizeObserver?.unobserve(prev);
360
+ }
124
361
  itemRefs.set(id, element);
125
362
  listResizeObserver?.observe(element);
126
363
  } else {
364
+ const prev = itemRefs.get(id);
365
+ if (prev) {
366
+ listResizeObserver?.unobserve(prev);
367
+ }
127
368
  itemRefs.delete(id);
128
369
  }
129
370
  }
130
371
 
131
- function initCache() {
132
- let currentTop = 0;
133
- sizeCache.value = props.items.map(() => {
134
- const size = { height: props.minChildHeight, top: currentTop };
135
- currentTop += props.minChildHeight;
136
- return size;
372
+ function scrollToBottom() {
373
+ scrollToBottomInternal();
374
+ scrollbarComponentRef.value?.updateMetrics();
375
+ }
376
+
377
+ function topForAlign(row: MeasuredSize, align: 'start' | 'end' | 'center', viewport: number): number {
378
+ if (align === 'end') {
379
+ return row.top + row.height - viewport;
380
+ }
381
+ if (align === 'center') {
382
+ return row.top + row.height / 2 - viewport / 2;
383
+ }
384
+ return row.top;
385
+ }
386
+
387
+ /**
388
+ * Items that have never been rendered are only estimated at minChildHeight,
389
+ * so the target's top is off by however much its predecessors are mistaken -
390
+ * the further the target, the bigger the miss. Scrolling there brings those
391
+ * items into view, they get measured, and the target moves. So aim again
392
+ * until it stops moving.
393
+ */
394
+ function scrollToItem(id: string, align: 'start' | 'end' | 'center' = 'start', attemptsLeft = 5) {
395
+ const index = idToIndex.value.get(id);
396
+ if (index === undefined) {
397
+ return;
398
+ }
399
+
400
+ const row = sizeCache.value[index];
401
+ if (!row) {
402
+ return;
403
+ }
404
+
405
+ const containerEl = getContainerEl();
406
+ const viewport = containerEl?.clientHeight ?? wrapElementHeight.value;
407
+
408
+ applyScrollTop(containerEl, topForAlign(row, align, viewport));
409
+ scrollbarComponentRef.value?.updateMetrics();
410
+
411
+ if (attemptsLeft <= 0) {
412
+ return;
413
+ }
414
+
415
+ const topBefore = row.top;
416
+ requestAnimationFrame(() => {
417
+ const nextIndex = idToIndex.value.get(id);
418
+ const nextRow = nextIndex === undefined ? undefined : sizeCache.value[nextIndex];
419
+ if (nextRow && nextRow.top !== topBefore) {
420
+ scrollToItem(id, align, attemptsLeft - 1);
421
+ }
137
422
  });
138
- cacheVersion.value++;
139
423
  }
140
424
 
425
+ defineExpose<Ui3nVirtualScrollExpose>({
426
+ scrollToBottom,
427
+ scrollToItem: (id, align) => scrollToItem(id, align),
428
+ getScrollTop: () => scrollTop.value,
429
+ isStuckToBottom: () => isNearBottom(getContainerEl(), sizeCache.value),
430
+ });
431
+
141
432
  onMounted(() => {
142
- const containerEl = scrollbarComponentRef.value?.getContainer();
433
+ const containerEl = getContainerEl();
143
434
 
144
435
  if (containerEl) {
145
436
  viewportHeight.value = containerEl.clientHeight;
146
437
  containerResizeObserver = new ResizeObserver(entries => {
147
438
  for (const entry of entries) {
148
- viewportHeight.value = entry.contentRect.height || entry.target.clientHeight;
439
+ viewportHeight.value = entry.contentRect.height || (entry.target as HTMLElement).clientHeight;
149
440
  }
150
441
  });
151
442
  containerResizeObserver.observe(containerEl);
@@ -156,8 +447,11 @@
156
447
  let lowestChangedIndex = Infinity;
157
448
  let totalDeltaAboveKey = 0;
158
449
 
159
- const updatedCache = [...sizeCache.value];
450
+ const updatedCache = sizeCache.value.slice();
451
+ const indexMap = idToIndex.value;
160
452
  const currentScrollTop = scrollTop.value;
453
+ const el = getContainerEl();
454
+ const stickAfterMeasure = props.stickToBottom && isNearBottom(el, sizeCache.value);
161
455
 
162
456
  for (const entry of entries) {
163
457
  const sid = entry.target.getAttribute('data-sid');
@@ -165,49 +459,64 @@
165
459
  continue;
166
460
  }
167
461
 
168
- const index = props.items.findIndex((item, idx) => (item.id ?? idx).toString() === sid);
169
- if (index === -1) {
462
+ const index = indexMap.get(sid);
463
+ if (index === undefined) {
170
464
  continue;
171
465
  }
172
466
 
173
467
  const realHeight = entry.contentRect.height || entry.target.getBoundingClientRect().height;
174
468
 
175
- if (updatedCache[index] && updatedCache[index].height !== realHeight) {
176
- const oldHeight = updatedCache[index].height;
177
- updatedCache[index] = { ...updatedCache[index], height: realHeight };
469
+ const prev = updatedCache[index];
470
+ if (!prev || prev.height === realHeight) {
471
+ continue;
472
+ }
178
473
 
179
- if (updatedCache[index].top < currentScrollTop) {
180
- totalDeltaAboveKey += realHeight - oldHeight;
181
- }
474
+ const oldHeight = prev.height;
475
+ updatedCache[index] = { ...prev, height: realHeight };
476
+ heightById.set(sid, realHeight);
477
+
478
+ // Only items lying entirely above the viewport push the content down
479
+ // by their full delta; one straddling the top edge contributes just
480
+ // the part of it that is out of sight, so compensating in full there
481
+ // would shove the visible content instead of holding it still
482
+ if (prev.top + prev.height <= currentScrollTop) {
483
+ totalDeltaAboveKey += realHeight - oldHeight;
484
+ } else if (prev.top < currentScrollTop) {
485
+ totalDeltaAboveKey += Math.min(realHeight, currentScrollTop - prev.top) - Math.min(oldHeight, currentScrollTop - prev.top);
486
+ }
182
487
 
183
- if (index < lowestChangedIndex) {
184
- lowestChangedIndex = index;
185
- }
488
+ if (index < lowestChangedIndex) {
489
+ lowestChangedIndex = index;
186
490
  }
187
491
  }
188
492
 
189
- if (lowestChangedIndex !== Infinity) {
190
- let currentTop = updatedCache[lowestChangedIndex].top;
191
-
192
- for (let i = lowestChangedIndex; i < updatedCache.length; i++) {
193
- updatedCache[i].top = currentTop;
194
- currentTop += updatedCache[i].height;
195
- }
493
+ if (lowestChangedIndex === Infinity) {
494
+ return;
495
+ }
196
496
 
197
- sizeCache.value = updatedCache;
198
- cacheVersion.value++;
497
+ let currentTop = updatedCache[lowestChangedIndex].top;
498
+ for (let i = lowestChangedIndex; i < updatedCache.length; i++) {
499
+ updatedCache[i] = { ...updatedCache[i], top: currentTop };
500
+ currentTop += updatedCache[i].height;
501
+ }
199
502
 
200
- if (containerEl && totalDeltaAboveKey !== 0) {
201
- isAdjustingScroll = true;
202
- containerEl.scrollTop = currentScrollTop + totalDeltaAboveKey;
203
- scrollTop.value = containerEl.scrollTop;
204
- isAdjustingScroll = false;
205
- }
503
+ sizeCache.value = updatedCache;
504
+ cacheVersion.value++;
206
505
 
207
- scrollbarComponentRef.value?.updateMetrics();
506
+ if (stickAfterMeasure) {
507
+ scrollToBottomInternal(el, updatedCache);
508
+ } else if (el && totalDeltaAboveKey !== 0) {
509
+ applyScrollTop(el, currentScrollTop + totalDeltaAboveKey);
208
510
  }
511
+
512
+ scrollbarComponentRef.value?.updateMetrics();
209
513
  });
210
514
  });
515
+
516
+ // Observe items that may already be mounted before observer creation
517
+ for (const element of itemRefs.values()) {
518
+ listResizeObserver.observe(element);
519
+ }
211
520
  });
212
521
 
213
522
  onBeforeUnmount(() => {
@@ -215,7 +524,7 @@
215
524
  containerResizeObserver?.disconnect();
216
525
  });
217
526
 
218
- watch(() => props.items, initCache, { immediate: true });
527
+ watch(() => props.items, syncItemsCache, { immediate: true });
219
528
  </script>
220
529
 
221
530
  <template>
@@ -235,9 +544,9 @@
235
544
  >
236
545
  <div
237
546
  v-for="(item, index) in visibleChildren"
238
- :key="item.id ?? `v-node-${startNode + index}`"
239
- :ref="el => setItemRef(el, item.id ?? startNode + index)"
240
- v-bind="{ 'data-sid': item.id ?? startNode + index }"
547
+ :key="item.id"
548
+ :ref="el => setItemRef(el, item.id)"
549
+ v-bind="{ 'data-sid': item.id }"
241
550
  :class="$style.item"
242
551
  :style="{ minHeight: `${props.minChildHeight}px` }"
243
552
  >