You've already forked element-web
mirror of
https://github.com/element-hq/element-web.git
synced 2026-10-24 23:19:26 +03:00
When the new timeline's scroller shrinks (a banner appears, the composer grows, the window is resized), the browser keeps `scrollTop`, so a reader at the bottom was left with the newest messages below the fold. Rows whose height follows the window, such as collapsed code blocks, made window resizes lose the bottom even with a resize handler in the view, because TanStack compensated them before the handler ran. The fix is a new hunk in our @tanstack/virtual-core patch rather than code in TimelineView: an end-anchored list now keeps an end-pinned view pinned when its scroll element shrinks, the same way it already does when a row grows. It is proposed upstream to TanStack Virtual and can be dropped once released. The rest of the patch is unchanged.
133 lines
8.2 KiB
Diff
133 lines
8.2 KiB
Diff
diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts
|
|
index 039bff8..39d31ff 100644
|
|
--- a/dist/esm/index.d.ts
|
|
+++ b/dist/esm/index.d.ts
|
|
@@ -78,6 +78,12 @@ export interface VirtualizerOptions<TScrollElement extends Element | Window, TIt
|
|
initialMeasurementsCache?: Array<VirtualItem>;
|
|
lanes?: number;
|
|
anchorTo?: ScrollAnchor;
|
|
+ /**
|
|
+ * With `anchorTo: "end"`, reject an item as the scroll anchor (e.g. a transient
|
|
+ * placeholder/sentinel row whose key does not survive the update); anchoring
|
|
+ * steps inward to the nearest accepted item. Element-web patch, pending upstream.
|
|
+ */
|
|
+ isValidAnchorItem?: (item: VirtualItem) => boolean;
|
|
followOnAppend?: FollowOnAppend;
|
|
scrollEndThreshold?: number;
|
|
isScrollingResetDelay?: number;
|
|
diff --git a/dist/esm/index.js b/dist/esm/index.js
|
|
index 7032b66..b8140c8 100644
|
|
--- a/dist/esm/index.js
|
|
+++ b/dist/esm/index.js
|
|
@@ -321,7 +321,24 @@ class Virtualizer {
|
|
const didEdgeKeysChange = didCountChange || prevCount > 0 && nextCount > 0 && (merged.getItemKey(0) !== prevFirstKey || merged.getItemKey(nextCount - 1) !== prevLastKey);
|
|
if (didEdgeKeysChange) {
|
|
edgeKeysChanged = true;
|
|
- const item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null;
|
|
+ let item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null;
|
|
+ // With anchorTo: "end" the scroll is re-pinned by the key of the item
|
|
+ // under the current offset. If the consumer rejects that item as an
|
|
+ // anchor via isValidAnchorItem — e.g. a transient placeholder/sentinel
|
|
+ // row (loading spinner, "load more", skeleton) whose key does not
|
|
+ // survive the update — re-pinning by its key would fail and the
|
|
+ // prepend would go uncompensated (the list jumps to the edge). Such
|
|
+ // rows only sit at the extreme edges, so step one index inward to the
|
|
+ // nearest accepted item, whose key persists across the update.
|
|
+ const isValidAnchorItem = merged.isValidAnchorItem;
|
|
+ if (item && isValidAnchorItem && !isValidAnchorItem(item)) {
|
|
+ const step = item.index === 0 ? 1 : -1;
|
|
+ let i = item.index;
|
|
+ while (i >= 0 && i < measurements.length && measurements[i] && !isValidAnchorItem(measurements[i])) {
|
|
+ i += step;
|
|
+ }
|
|
+ item = measurements[i] ?? item;
|
|
+ }
|
|
if (item) {
|
|
anchor = [item.key, this.getScrollOffset() - item.start];
|
|
}
|
|
@@ -443,7 +460,23 @@ class Virtualizer {
|
|
});
|
|
this.unsubs.push(
|
|
this.options.observeElementRect(this, (rect) => {
|
|
+ var _a2;
|
|
+ // With anchorTo: "end", keep an end-pinned viewport pinned when the
|
|
+ // scroll element shrinks, as resizeItem does when an item grows. The
|
|
+ // browser keeps scrollTop, so the end would otherwise drop below the
|
|
+ // fold. Judge "pinned" against the size before this change, and never
|
|
+ // scroll past the element's real end, except while a clamped write
|
|
+ // waits for the sizer (the DOM end is stale then; the retry lands it).
|
|
+ // Element-web patch, pending upstream.
|
|
+ const prevSize = this.scrollRect !== null ? this.getSize() : null;
|
|
+ const wasAtEnd = prevSize !== null && this.options.anchorTo === "end" && ((_a2 = this.scrollState) == null ? void 0 : _a2.behavior) !== "smooth" && this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold;
|
|
this.scrollRect = rect;
|
|
+ if (wasAtEnd && prevSize > this.getSize()) {
|
|
+ const shrink = prevSize - this.getSize();
|
|
+ this.applyScrollAdjustment(
|
|
+ this._clampedAdjustment !== null ? shrink : Math.min(shrink, this.getDistanceFromEnd())
|
|
+ );
|
|
+ }
|
|
this.maybeNotify();
|
|
})
|
|
);
|
|
@@ -913,9 +946,19 @@ class Virtualizer {
|
|
const itemSize = this.itemSizeCache.get(key) ?? cachedSize;
|
|
const delta = size - itemSize;
|
|
if (delta !== 0) {
|
|
- const wasAtEnd = this.options.anchorTo === "end" && ((_b = this.scrollState) == null ? void 0 : _b.behavior) !== "smooth" && this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold;
|
|
+ // `this.scrollOffset` only updates when the async scroll event fires, so
|
|
+ // within a frame it can be stale in two ways: (a) an earlier resize in the
|
|
+ // same ResizeObserver batch already queued `scrollAdjustments`, and (b) a
|
|
+ // programmatic scroll (scrollToOffset / the reconcile loop) wrote scrollTop
|
|
+ // this frame — recorded synchronously in `_intendedScrollOffset` but not yet
|
|
+ // reflected by getScrollOffset(). Evaluating "was the view at the end?" (or
|
|
+ // "is this item above the fold?") against the stale offset makes resizes
|
|
+ // during a scroll-to-end land uncompensated, stranding the view above the
|
|
+ // bottom. Use the synchronous truth when a write is pending.
|
|
+ const effectiveScrollOffset = this._intendedScrollOffset ?? (this.getScrollOffset() + this.scrollAdjustments);
|
|
+ const wasAtEnd = this.options.anchorTo === "end" && ((_b = this.scrollState) == null ? void 0 : _b.behavior) !== "smooth" && this.getTotalSize() - this.getSize() - effectiveScrollOffset <= this.options.scrollEndThreshold;
|
|
const prevTotalSize = wasAtEnd ? this.getTotalSize() : 0;
|
|
- const scrollOffsetWithAdj = this.getScrollOffset() + this.scrollAdjustments;
|
|
+ const scrollOffsetWithAdj = effectiveScrollOffset;
|
|
const isFirstMeasure = !this.itemSizeCache.has(key);
|
|
const defaultShouldAdjust = isFirstMeasure ? (
|
|
// First measurement: compensate any item whose top sits above the
|
|
@@ -928,9 +971,12 @@ class Virtualizer {
|
|
// below — e.g. a streaming chat message growing at its bottom)
|
|
// changes size *below* the anchor point, so shifting scrollTop by the
|
|
// delta would drag the viewport downward on every growth (#1218).
|
|
- // Also skip during backward scroll to avoid the "items jump while
|
|
- // scrolling up" cascade.
|
|
- itemStart + itemSize <= scrollOffsetWithAdj && this.scrollDirection !== "backward"
|
|
+ // Upstream also skips this while scrolling backwards, a guard against
|
|
+ // corrections firing from a stale scroll offset. The effective-offset
|
|
+ // fix above removes the staleness, and keeping the guard meant every
|
|
+ // image or link preview that finished loading above the viewport
|
|
+ // shoved the view while scrolling up — so the guard is dropped.
|
|
+ itemStart + itemSize <= scrollOffsetWithAdj
|
|
);
|
|
const shouldAdjustScroll = ((_c = this.scrollState) == null ? void 0 : _c.behavior) !== "smooth" && (this.shouldAdjustScrollPositionOnItemSizeChange !== void 0 ? this.shouldAdjustScrollPositionOnItemSizeChange(
|
|
// The callback expects a VirtualItem; build one lazily only
|
|
@@ -1197,16 +1243,19 @@ class Virtualizer {
|
|
this._iosDeferredAdjustment += delta;
|
|
return false;
|
|
} else {
|
|
- const target = this.getScrollOffset() + this.scrollAdjustments + delta;
|
|
+ // A programmatic scroll written this frame is not yet visible through
|
|
+ // getScrollOffset(); build the correction on the offset that was written.
|
|
+ const base = this._intendedScrollOffset ?? this.getScrollOffset();
|
|
+ const target = base + this.scrollAdjustments + delta;
|
|
const el = this.scrollElement;
|
|
const maxAtWrite = el !== null && ("scrollHeight" in el || "document" in el) ? this.getMaxScrollOffset() : null;
|
|
this._clampedAdjustment = maxAtWrite !== null && target > maxAtWrite + 0.5 ? { target, maxAtWrite } : null;
|
|
- this._scrollToOffset(this.getScrollOffset(), {
|
|
+ this._scrollToOffset(base, {
|
|
adjustments: this.scrollAdjustments += delta,
|
|
behavior
|
|
});
|
|
if (this.scrollOffset !== null) {
|
|
- this.scrollOffset += this.scrollAdjustments;
|
|
+ this.scrollOffset = base + this.scrollAdjustments;
|
|
if (this.scrollOffset < 0) this.scrollOffset = 0;
|
|
this.scrollAdjustments = 0;
|
|
}
|