diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs index e470032a9572b3ced764ca02238a8c6be435a9d4..93770cdc02c570ce6aaa2ce256792b940c25e4d3 100644 --- a/dist/cjs/index.cjs +++ b/dist/cjs/index.cjs @@ -289,7 +289,7 @@ class Virtualizer { let anchor = null; let followOnAppend = null; let edgeKeysChanged = false; - if (prevOptions !== void 0 && prevOptions.enabled && merged.enabled && merged.anchorTo === "end" && this.scrollElement !== null) { + if (prevOptions !== void 0 && prevOptions.enabled && merged.enabled && this.scrollElement !== null) { const prevCount = prevOptions.count; const nextCount = merged.count; const measurements = this.getMeasurements(); @@ -299,11 +299,20 @@ class Virtualizer { const didEdgeKeysChange = didCountChange || prevCount > 0 && nextCount > 0 && (merged.getItemKey(0) !== prevFirstKey || merged.getItemKey(nextCount - 1) !== prevLastKey); if (didEdgeKeysChange) { edgeKeysChanged = true; + // A data change can legitimately re-key the rows around the current offset + // (e.g. a truncated leading chat turn regrouping once a prepended page loads + // its parent). Capture fallback anchors below the primary one so the scroll + // position survives even when the nearest keys disappear. const item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null; if (item) { - anchor = [item.key, this.getScrollOffset() - item.start]; + anchor = []; + for (let i = item.index; i < prevCount && anchor.length < 100; i++) { + const candidate = measurements[i]; + if (!candidate) break; + anchor.push([candidate.key, this.getScrollOffset() - candidate.start]); + } } - const behavior = merged.followOnAppend === true ? "auto" : merged.followOnAppend || null; + const behavior = merged.anchorTo === "end" ? merged.followOnAppend === true ? "auto" : merged.followOnAppend || null : null; if (behavior && nextCount > prevCount && this.isAtEnd(prevOptions.scrollEndThreshold) && (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey)) { followOnAppend = behavior; } @@ -316,30 +325,31 @@ class Virtualizer { } let anchorResolved = false; let anchorDelta = 0; - if (anchor && this.scrollOffset !== null) { - const [anchorKey, anchorOffset] = anchor; + let resolvedAnchor = null; + if (anchor && anchor.length > 0 && this.scrollOffset !== null) { const newMeasurements = this.getMeasurements(); const { count, getItemKey } = this.options; - let idx = 0; - while (idx < count && getItemKey(idx) !== anchorKey) { - idx++; - } - if (idx < count) { + const indexByKey = new Map(); + for (let i = 0; i < count; i++) indexByKey.set(getItemKey(i), i); + for (const [anchorKey, anchorOffset] of anchor) { + const idx = indexByKey.get(anchorKey); + if (idx === void 0) continue; const anchorItem = newMeasurements[idx]; - if (anchorItem) { - const newOffset = Math.max(0, anchorItem.start + anchorOffset); - if (newOffset !== this.scrollOffset) { - anchorDelta = newOffset - this.scrollOffset; - this.scrollOffset = newOffset; - anchorResolved = true; - } + if (!anchorItem) continue; + resolvedAnchor = [anchorKey, anchorOffset]; + const newOffset = Math.max(0, anchorItem.start + anchorOffset); + if (newOffset !== this.scrollOffset) { + anchorDelta = newOffset - this.scrollOffset; + this.scrollOffset = newOffset; + anchorResolved = true; } + break; } } if (anchorResolved || followOnAppend) { this.pendingScrollAnchor = [ - anchorResolved ? anchor[0] : null, - anchorResolved ? anchor[1] : 0, + anchorResolved ? resolvedAnchor[0] : null, + anchorResolved ? resolvedAnchor[1] : 0, followOnAppend, anchorDelta ]; @@ -725,17 +735,20 @@ class Virtualizer { this.getMeasurements(), this.getSize(), this.getScrollOffset(), - this.options.lanes + this.options.lanes, + this.options.paddingEnd ], - (measurements, outerSize, scrollOffset, lanes) => { + (measurements, outerSize, scrollOffset, lanes, _paddingEnd) => { if (measurements.length === 0 || outerSize === 0) { this.range = null; return null; } + const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0); + const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset); this.range = calculateRangeImpl( measurements, outerSize, - scrollOffset, + effectiveScrollOffset, lanes, // Pass the typed array so binary search + forward-walk can read // start/end directly from Float64Array, skipping the Proxy traps. diff --git a/dist/cjs/index.d.cts b/dist/cjs/index.d.cts index cc1001b8a14d06aa73a3a9e8f59870557ee9e7d6..70b202ed436ce1f3283a8465a81faf2e6e2e7983 100644 --- a/dist/cjs/index.d.cts +++ b/dist/cjs/index.d.cts @@ -144,7 +144,7 @@ export declare class Virtualizer 0 && nextCount > 0 && (merged.getItemKey(0) !== prevFirstKey || merged.getItemKey(nextCount - 1) !== prevLastKey); if (didEdgeKeysChange) { edgeKeysChanged = true; + // A data change can legitimately re-key the rows around the current offset + // (e.g. a truncated leading chat turn regrouping once a prepended page loads + // its parent). Capture fallback anchors below the primary one so the scroll + // position survives even when the nearest keys disappear. const item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null; if (item) { - anchor = [item.key, this.getScrollOffset() - item.start]; + anchor = []; + for (let i = item.index; i < prevCount && anchor.length < 100; i++) { + const candidate = measurements[i]; + if (!candidate) break; + anchor.push([candidate.key, this.getScrollOffset() - candidate.start]); + } } - const behavior = merged.followOnAppend === true ? "auto" : merged.followOnAppend || null; + const behavior = merged.anchorTo === "end" ? merged.followOnAppend === true ? "auto" : merged.followOnAppend || null : null; if (behavior && nextCount > prevCount && this.isAtEnd(prevOptions.scrollEndThreshold) && (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey)) { followOnAppend = behavior; } @@ -314,30 +323,31 @@ class Virtualizer { } let anchorResolved = false; let anchorDelta = 0; - if (anchor && this.scrollOffset !== null) { - const [anchorKey, anchorOffset] = anchor; + let resolvedAnchor = null; + if (anchor && anchor.length > 0 && this.scrollOffset !== null) { const newMeasurements = this.getMeasurements(); const { count, getItemKey } = this.options; - let idx = 0; - while (idx < count && getItemKey(idx) !== anchorKey) { - idx++; - } - if (idx < count) { + const indexByKey = new Map(); + for (let i = 0; i < count; i++) indexByKey.set(getItemKey(i), i); + for (const [anchorKey, anchorOffset] of anchor) { + const idx = indexByKey.get(anchorKey); + if (idx === void 0) continue; const anchorItem = newMeasurements[idx]; - if (anchorItem) { - const newOffset = Math.max(0, anchorItem.start + anchorOffset); - if (newOffset !== this.scrollOffset) { - anchorDelta = newOffset - this.scrollOffset; - this.scrollOffset = newOffset; - anchorResolved = true; - } + if (!anchorItem) continue; + resolvedAnchor = [anchorKey, anchorOffset]; + const newOffset = Math.max(0, anchorItem.start + anchorOffset); + if (newOffset !== this.scrollOffset) { + anchorDelta = newOffset - this.scrollOffset; + this.scrollOffset = newOffset; + anchorResolved = true; } + break; } } if (anchorResolved || followOnAppend) { this.pendingScrollAnchor = [ - anchorResolved ? anchor[0] : null, - anchorResolved ? anchor[1] : 0, + anchorResolved ? resolvedAnchor[0] : null, + anchorResolved ? resolvedAnchor[1] : 0, followOnAppend, anchorDelta ]; @@ -723,17 +733,20 @@ class Virtualizer { this.getMeasurements(), this.getSize(), this.getScrollOffset(), - this.options.lanes + this.options.lanes, + this.options.paddingEnd ], - (measurements, outerSize, scrollOffset, lanes) => { + (measurements, outerSize, scrollOffset, lanes, _paddingEnd) => { if (measurements.length === 0 || outerSize === 0) { this.range = null; return null; } + const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0); + const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset); this.range = calculateRangeImpl( measurements, outerSize, - scrollOffset, + effectiveScrollOffset, lanes, // Pass the typed array so binary search + forward-walk can read // start/end directly from Float64Array, skipping the Proxy traps. diff --git a/src/index.ts b/src/index.ts index dc6f1010c4d4758de9c46fb8d69209e582e47171..5d8bf755e285e4d0688d5e41ed768c60e4267131 100644 --- a/src/index.ts +++ b/src/index.ts @@ -567,7 +567,7 @@ export class Virtualizer< const prevOptions = this.options as | Required> | undefined - let anchor: [Key, number] | null = null + let anchor: Array<[Key, number]> | null = null let followOnAppend: ScrollBehavior | null = null let edgeKeysChanged = false @@ -575,7 +575,6 @@ export class Virtualizer< prevOptions !== undefined && prevOptions.enabled && merged.enabled && - merged.anchorTo === 'end' && this.scrollElement !== null ) { const prevCount = prevOptions.count @@ -600,6 +599,10 @@ export class Virtualizer< if (didEdgeKeysChange) { edgeKeysChanged = true + // A data change can legitimately re-key the rows around the current offset + // (e.g. a truncated leading chat turn regrouping once a prepended page loads + // its parent). Capture fallback anchors below the primary one so the scroll + // position survives even when the nearest keys disappear. const item = prevCount > 0 ? (this.getVirtualItemForOffset(this.getScrollOffset()) ?? @@ -607,13 +610,20 @@ export class Virtualizer< : null if (item) { - anchor = [item.key, this.getScrollOffset() - item.start] + anchor = [] + for (let i = item.index; i < prevCount && anchor.length < 100; i++) { + const candidate = measurements[i] + if (!candidate) break + anchor.push([candidate.key, this.getScrollOffset() - candidate.start]) + } } const behavior = - merged.followOnAppend === true - ? 'auto' - : merged.followOnAppend || null + merged.anchorTo === 'end' + ? merged.followOnAppend === true + ? 'auto' + : merged.followOnAppend || null + : null if ( behavior && @@ -646,35 +656,36 @@ export class Virtualizer< // frame, producing a visible "jump" on prepend with dynamic sizes. let anchorResolved = false let anchorDelta = 0 - if (anchor && this.scrollOffset !== null) { - const [anchorKey, anchorOffset] = anchor + let resolvedAnchor: [Key, number] | null = null + if (anchor && anchor.length > 0 && this.scrollOffset !== null) { const newMeasurements = this.getMeasurements() const { count, getItemKey } = this.options - let idx = 0 - while (idx < count && getItemKey(idx) !== anchorKey) { - idx++ - } - if (idx < count) { + const indexByKey = new Map() + for (let i = 0; i < count; i++) indexByKey.set(getItemKey(i), i) + for (const [anchorKey, anchorOffset] of anchor) { + const idx = indexByKey.get(anchorKey) + if (idx === undefined) continue const anchorItem = newMeasurements[idx] - if (anchorItem) { - // Clamp to the reachable range's lower bound — anchorOffset may - // have been derived from a transiently negative scrollOffset - // (rubber-band), and a negative tracked offset never self-heals - // when the element cannot scroll (#1229). - const newOffset = Math.max(0, anchorItem.start + anchorOffset) - if (newOffset !== this.scrollOffset) { - anchorDelta = newOffset - this.scrollOffset - this.scrollOffset = newOffset - anchorResolved = true - } + if (!anchorItem) continue + resolvedAnchor = [anchorKey, anchorOffset] + // Clamp to the reachable range's lower bound — anchorOffset may + // have been derived from a transiently negative scrollOffset + // (rubber-band), and a negative tracked offset never self-heals + // when the element cannot scroll (#1229). + const newOffset = Math.max(0, anchorItem.start + anchorOffset) + if (newOffset !== this.scrollOffset) { + anchorDelta = newOffset - this.scrollOffset + this.scrollOffset = newOffset + anchorResolved = true } + break } } if (anchorResolved || followOnAppend) { this.pendingScrollAnchor = [ - anchorResolved ? anchor![0] : null, - anchorResolved ? anchor![1] : 0, + anchorResolved ? resolvedAnchor![0] : null, + anchorResolved ? resolvedAnchor![1] : 0, followOnAppend, anchorDelta, ] @@ -1410,16 +1421,25 @@ export class Virtualizer< this.getSize(), this.getScrollOffset(), this.options.lanes, + this.options.paddingEnd, ], - (measurements, outerSize, scrollOffset, lanes) => { + (measurements, outerSize, scrollOffset, lanes, _paddingEnd) => { if (measurements.length === 0 || outerSize === 0) { this.range = null return null } + const maxScrollOffset = Math.max( + this.options.scrollMargin + this.getTotalSize() - outerSize, + 0, + ) + const effectiveScrollOffset = Math.min( + Math.max(scrollOffset, 0), + maxScrollOffset, + ) this.range = calculateRangeImpl( measurements, outerSize, - scrollOffset, + effectiveScrollOffset, lanes, // Pass the typed array so binary search + forward-walk can read // start/end directly from Float64Array, skipping the Proxy traps.