diff --git a/packages/@react-spectrum/ai/src/ResponseStatus.tsx b/packages/@react-spectrum/ai/src/ResponseStatus.tsx index aa7c82b066e..81b75e9bdb8 100644 --- a/packages/@react-spectrum/ai/src/ResponseStatus.tsx +++ b/packages/@react-spectrum/ai/src/ResponseStatus.tsx @@ -670,7 +670,10 @@ const executionTraceItemStyles = style({ default: 'block', ':last-child': 'none' } - }, + } +}); + +const executionTraceItemEntranceStyles = style({ transition: '[opacity, translate]', transitionDuration: `[${EXECUTION_TRACE_ITEM_TRANSITION_DURATION}ms, 310ms]`, transitionTimingFunction: `[cubic-bezier(0.45, 0, 0.4, 1), ${EXECUTION_TRACE_ITEM_TIMING_FUNCTION}]`, @@ -735,9 +738,27 @@ export const ExecutionTraceItem = forwardRef(function ExecutionTraceItem( let domProps = filterDOMProps(otherProps); let {isFocusVisible, focusProps} = useFocusRing(); let hasDetail = detail != null; + // Play the entrance (fade + slide) once, then remove the animating styles. + // This is to prevent a flash that occurs when scrolling in virtualized containers + // because the browser keeps re-creating the layer these animations force it onto + let [hasEntered, setHasEntered] = useState(false); return ( -
  • +
  • { + // Only react to this item's own opacity transition (the longer of the two, so both the + // fade and slide have finished), not transitions bubbling up from descendants. + if (e.target === e.currentTarget && e.propertyName === 'opacity') { + setHasEntered(true); + } + }} + className={mergeStyles( + executionTraceItemStyles, + hasEntered ? undefined : executionTraceItemEntranceStyles, + styles + )}>
    {status === 'failed' && ( diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index 25535f6ae81..b08d470b170 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -62,7 +62,7 @@ const meta: Meta = { title: 'AI/Chat', decorators: [ Story => ( -
    +
    ) diff --git a/packages/react-stately/src/virtualizer/ScrollAnchor.ts b/packages/react-stately/src/virtualizer/ScrollAnchor.ts index cfa3e317338..716fcec4a65 100644 --- a/packages/react-stately/src/virtualizer/ScrollAnchor.ts +++ b/packages/react-stately/src/virtualizer/ScrollAnchor.ts @@ -88,22 +88,48 @@ export function captureScrollAnchor( isAnchorable: (layoutInfo: LayoutInfo) => boolean = () => true ): ScrollAnchor | null { let dimension = dimensionForAxis(axis); + // The corner on the item's leading edge - the side where content is added/removed. For 'end' + // that's the start of the axis (top/left); for 'start' it's the end of the axis (bottom/right). + let corner: RectCorner = + axis === 'x' + ? edge === 'end' + ? 'topLeft' + : 'topRight' + : edge === 'end' + ? 'topLeft' + : 'bottomLeft'; + let viewportExtent = visibleRect[dimension]; let best: ScrollAnchor | null = null; + // Fallback used only when every visible item is clipped past the leading edge (e.g. a single item + // taller than the viewport): the least-clipped item still makes the most stable anchor available. + let fallback: ScrollAnchor | null = null; for (let [key, layoutInfo] of visibleLayoutInfos) { if (!layoutInfo || !isAnchorable(layoutInfo)) { continue; } let overlap = layoutInfo.rect.intersection(visibleRect)[dimension]; - if (layoutInfo.rect.area > 0 && overlap >= MIN_ANCHOR_OVERLAP) { - let corner = layoutInfo.rect.getCornerInRect(visibleRect) ?? 'topLeft'; - let offset = layoutInfo.rect[corner][axis] - visibleRect[axis]; + if (layoutInfo.rect.area <= 0 || overlap < MIN_ANCHOR_OVERLAP) { + continue; + } + let offset = layoutInfo.rect[corner][axis] - visibleRect[axis]; + // Is the leading edge within the viewport? + let leadingEdgeVisible = edge === 'end' ? offset >= 0 : offset <= viewportExtent; + if (leadingEdgeVisible) { + // Pick the item nearest the leading edge among those whose leading edge is visible. let isBetter = !best || (edge === 'end' ? offset < best.offset : offset > best.offset); if (isBetter) { best = {key, corner, offset}; } + } else { + // Least-clipped = closest to the leading edge from the clipped side. + let isBetter = + !fallback || (edge === 'end' ? offset > fallback.offset : offset < fallback.offset); + if (isBetter) { + fallback = {key, corner, offset}; + } } } - return best; + return best ?? fallback; } /** Returns the viewport coordinate (along `axis`) that pins the viewport to `edge` of the content. */ @@ -154,7 +180,8 @@ export function resolveScrollAdjustment( contentSizeDelta: number, getLayoutInfo: (key: Key) => LayoutInfo | null, previousVisibleRect: Rect, - contentSize: Size + contentSize: Size, + changeIsAtEdge: boolean = true ): Rect | null { let withTarget = (target: number): Rect => axis === 'x' @@ -171,7 +198,16 @@ export function resolveScrollAdjustment( previousVisibleRect.height ); - if (anchor) { + // Two possible responses when content settles: "preserve the anchor" (keep the item the user is + // looking at in place) or "follow the edge" (keep the viewport pinned to the + // content edge, e.g. the bottom of a chat) + let followEdge = + wasNearAnchorEdge && + !isScrolling && + itemSizeChanged && + contentSizeDelta !== 0 && + changeIsAtEdge; + if (anchor && !followEdge) { let target = computeScrollAnchorTarget( anchor, axis, @@ -184,7 +220,12 @@ export function resolveScrollAdjustment( } } - if (wasNearAnchorEdge && !isScrolling && (!itemSizeChanged || contentSizeDelta > 0)) { + if ( + wasNearAnchorEdge && + !isScrolling && + (!itemSizeChanged || contentSizeDelta !== 0) && + changeIsAtEdge + ) { let target = withTarget(getEdgeSnapTarget(edge, axis, contentSize, previousVisibleRect)); return target.equals(previousVisibleRect) ? null : target; } @@ -196,14 +237,18 @@ export interface ResolveAfterLayoutOptions { anchorInfo: ScrollAnchorInfo | null; /** The anchor captured by `captureBeforeLayout` before this pass's `layout.update()` ran. */ anchor: ScrollAnchor | null; - /** The full post-layout visible layout infos, i.e. `virtualizer.getVisibleLayoutInfos()`. */ - postLayoutInfos: Map; previousVisibleRect: Rect; previousContentSize: Size; contentSize: Size; itemSizeChanged: boolean; isScrolling: boolean; getLayoutInfo: (key: Key) => LayoutInfo | null; + /** + * Whether the content that changed this pass was at the anchored edge (e.g. the newest item in a + * bottom-anchored list). When false, the viewport does not follow the edge, so a mid-list resize + * while the user is scrolled away preserves their position. Defaults to true. + */ + changeIsAtEdge?: boolean; } /** @@ -212,14 +257,10 @@ export interface ResolveAfterLayoutOptions { */ export class ScrollAnchorTracker { private hasSnappedToEdge = false; - private hadEstimatedVisibleItems = false; - private wasNearAnchorEdge = false; - /** Resets all tracked state, e.g. when the virtualizer's layout instance changes. */ + /** Resets the first-layout flag, e.g. when the virtualizer's layout instance changes. */ reset(): void { this.hasSnappedToEdge = false; - this.hadEstimatedVisibleItems = false; - this.wasNearAnchorEdge = false; } /** @@ -250,43 +291,19 @@ export class ScrollAnchorTracker { let { anchorInfo, anchor, - postLayoutInfos, previousVisibleRect, previousContentSize, contentSize, itemSizeChanged, isScrolling, - getLayoutInfo + getLayoutInfo, + changeIsAtEdge = true } = options; if (!anchorInfo) { return null; } - // Read the previous pass's state into locals before any writes below overwrite it. - let wasSettlingLastPass = this.hadEstimatedVisibleItems; - let wasNearAnchorEdgeLastPass = this.wasNearAnchorEdge; - - let hasEstimated = false; - for (let layoutInfo of postLayoutInfos.values()) { - if (layoutInfo.estimatedSize) { - hasEstimated = true; - break; - } - } - this.hadEstimatedVisibleItems = hasEstimated; - // Don't recheck "near edge?" mid-resize because it could look like a scroll that never happened. - // Reuse the answer from before the resizing started. - if (!wasSettlingLastPass) { - this.wasNearAnchorEdge = isNearEdge( - previousVisibleRect, - previousContentSize, - anchorInfo.edge, - anchorInfo.axis, - anchorInfo.threshold - ); - } - if (previousVisibleRect.area === 0) { return null; } @@ -303,7 +320,6 @@ export class ScrollAnchorTracker { let wasNearAnchorEdge = isFirstAnchoredLayout || - (wasSettlingLastPass && wasNearAnchorEdgeLastPass) || isNearEdge( previousVisibleRect, previousContentSize, @@ -311,15 +327,9 @@ export class ScrollAnchorTracker { anchorInfo.axis, anchorInfo.threshold ); - // A first-ever layout always snaps to the edge, even if the raw distance check says - // otherwise. Save that real decision here so later passes in this cascade reuse it. - if (!wasSettlingLastPass) { - this.wasNearAnchorEdge = wasNearAnchorEdge; - } - // Skip restoring to the captured anchor while still resizing because items above it are also still growing, - // and following it would fall short of the edge. - let effectiveAnchor = - isFirstAnchoredLayout || (wasSettlingLastPass && wasNearAnchorEdgeLastPass) ? null : anchor; + let effectiveAnchor = isFirstAnchoredLayout ? null : anchor; + // The first anchored layout always snaps to the edge, regardless of what changed. + let effectiveChangeIsAtEdge = isFirstAnchoredLayout || changeIsAtEdge; return resolveScrollAdjustment( anchorInfo.edge, anchorInfo.axis, @@ -330,7 +340,8 @@ export class ScrollAnchorTracker { contentSizeDelta, getLayoutInfo, previousVisibleRect, - contentSize + contentSize, + effectiveChangeIsAtEdge ); } } diff --git a/packages/react-stately/src/virtualizer/Virtualizer.ts b/packages/react-stately/src/virtualizer/Virtualizer.ts index bfa3bef8538..217f3758991 100644 --- a/packages/react-stately/src/virtualizer/Virtualizer.ts +++ b/packages/react-stately/src/virtualizer/Virtualizer.ts @@ -71,7 +71,12 @@ export class Virtualizer { private _isScrolling: boolean; private _invalidationContext: InvalidationContext; private _overscanManager: OverscanManager; + private _scrollAnchor: ScrollAnchorTracker; + // Together they classify whether the changed content was at the anchored (e.g. bottom) edge to avoid + // following the edge when only a mid-list item resized in a short chat. + private _hadItemResize: boolean; + private _batchIncludedNewestContent: boolean; constructor(options: VirtualizerOptions) { this.delegate = options.delegate; @@ -88,6 +93,8 @@ export class Virtualizer { this._invalidationContext = {}; this._overscanManager = new OverscanManager(); this._scrollAnchor = new ScrollAnchorTracker(); + this._hadItemResize = false; + this._batchIncludedNewestContent = false; } /** Returns whether the given key, or an ancestor, is persisted. */ @@ -177,14 +184,11 @@ export class Virtualizer { // On first render _visibleViews is empty so no anchor will be found. let anchor: ScrollAnchor | null = null; if (anchorInfo) { - let preLayoutInfos: [Key, LayoutInfo][] = []; - for (let [key, view] of this._visibleViews) { - let layoutInfo = this.layout.getLayoutInfo(key) ?? view.layoutInfo; - if (layoutInfo) { - preLayoutInfos.push([key, layoutInfo]); - } - } - anchor = this._scrollAnchor.captureBeforeLayout(anchorInfo, preLayoutInfos, this.visibleRect); + anchor = this._scrollAnchor.captureBeforeLayout( + anchorInfo, + this.getVisibleLayoutInfos(), + this.visibleRect + ); } let previousContentSize = this.contentSize; @@ -196,18 +200,29 @@ export class Virtualizer { let rawContentSize = this.layout.getContentSize(); (this as Mutable).contentSize = new Size(rawContentSize.width, rawContentSize.height); + // Decide whether the change that triggered this relayout was at the anchored edge. If items + // resized but none of them were the newest content, we don't follow the edge and we keep the + // user's reading position instead. + let changeIsAtEdge = !this._hadItemResize || this._batchIncludedNewestContent; + let target = this._scrollAnchor.resolveAfterLayout({ anchorInfo, anchor, - postLayoutInfos: anchorInfo ? this.getVisibleLayoutInfos() : new Map(), previousVisibleRect, previousContentSize, contentSize: this.contentSize, itemSizeChanged: context.itemSizeChanged ?? false, isScrolling: this._isScrolling, - getLayoutInfo: (key: Key) => this.layout.getLayoutInfo(key) + getLayoutInfo: (key: Key) => this.layout.getLayoutInfo(key), + changeIsAtEdge }); + // Clear these flags because a relayout can also run for reasons unrelated to a resize + // (scrolling, a new message, a window resize). If we left the flags set, the next relayout + // would still see this pass's "an item resized / it was the newest" values and make the wrong call. + this._hadItemResize = false; + this._batchIncludedNewestContent = false; + if (target) { // Queues a new render cycle. Return early to skip updateSubviews — running it now // would position views against the old visibleRect, causing a flash before the @@ -447,9 +462,28 @@ export class Virtualizer { let changed = this.layout.updateItemSize(key, size); if (changed) { + this._hadItemResize = true; + // "Batch" refers to the set of updateItemSize calls that happen between one relayout and the next + this._batchIncludedNewestContent ||= this.isNewestContent(key); this.invalidate({ itemSizeChanged: true }); } } + + /** + * Whether `key` is the newest real item in the collection — i.e. the last non-loader node. + */ + private isNewestContent(key: Key): boolean { + let lastKey = this.collection.getLastKey(); + while (lastKey != null) { + let node = this.collection.getItem(lastKey) as {type?: string} | null; + if (node?.type !== 'loader') { + break; + } + lastKey = this.collection.getKeyBefore(lastKey); + } + + return lastKey != null && lastKey === key; + } } diff --git a/packages/react-stately/test/virtualizer/ScrollAnchor.test.ts b/packages/react-stately/test/virtualizer/ScrollAnchor.test.ts index 96844aab8e9..7f1052b9053 100644 --- a/packages/react-stately/test/virtualizer/ScrollAnchor.test.ts +++ b/packages/react-stately/test/virtualizer/ScrollAnchor.test.ts @@ -65,6 +65,70 @@ describe('captureScrollAnchor', () => { expect(anchor?.key).toBe('closer'); }); + + it('does not anchor to a substantially-visible item clipped at the leading edge', () => { + let visibleRect = new Rect(0, 1000, 400, 468); // viewport 1000-1468 + let clipped = new LayoutInfo('item', 'clipped', new Rect(0, 962, 400, 76)); // top clipped, 38px visible + let fullyVisible = new LayoutInfo('item', 'fully-visible', new Rect(0, 1040, 400, 60)); + + let anchor = captureScrollAnchor('end', 'y', visibleRect, [ + ['clipped', clipped], + ['fully-visible', fullyVisible] + ]); + + expect(anchor?.key).toBe('fully-visible'); + expect(anchor?.corner).toBe('topLeft'); + expect(anchor?.offset).toBe(40); + }); + + it('anchors along the x axis by the left edge when anchoring to end', () => { + // Branch coverage for the horizontal corner branch (topLeft on the x axis). + let visibleRect = new Rect(1000, 0, 468, 400); // viewport x 1000-1468 + let leftClipped = new LayoutInfo('item', 'left-clipped', new Rect(962, 0, 76, 400)); // left clipped + let fullyVisible = new LayoutInfo('item', 'fully-visible', new Rect(1040, 0, 60, 400)); + + let anchor = captureScrollAnchor('end', 'x', visibleRect, [ + ['left-clipped', leftClipped], + ['fully-visible', fullyVisible] + ]); + + expect(anchor?.key).toBe('fully-visible'); + expect(anchor?.corner).toBe('topLeft'); + expect(anchor?.offset).toBe(40); + }); + + it('falls back to a clipped item when it is the only substantial candidate', () => { + let visibleRect = new Rect(0, 1000, 400, 468); // viewport 1000-1468 + let taller = new LayoutInfo('item', 'taller', new Rect(0, 900, 400, 800)); // spans 900-1700 + + let anchor = captureScrollAnchor('end', 'y', visibleRect, [['taller', taller]]); + + expect(anchor?.key).toBe('taller'); + }); + + it('returns a substantial clipped item over a fully-visible sub-overlap sliver', () => { + let visibleRect = new Rect(0, 1000, 400, 468); // viewport 1000-1468 + // Fully visible (top at 1002) but only 2px tall -> overlap 2 < MIN_ANCHOR_OVERLAP. + let sliver = new LayoutInfo('item', 'sliver', new Rect(0, 1002, 400, 2)); + // Top-clipped but 38px visible -> substantial overlap, reaches the fallback. + let clipped = new LayoutInfo('item', 'clipped', new Rect(0, 962, 400, 76)); + + let anchor = captureScrollAnchor('end', 'y', visibleRect, [ + ['sliver', sliver], + ['clipped', clipped] + ]); + + expect(anchor?.key).toBe('clipped'); + }); + + it('excludes an item that is both clipped and sub-overlap at the overlap gate', () => { + let visibleRect = new Rect(0, 1000, 400, 468); + let clippedSliver = new LayoutInfo('item', 'clipped-sliver', new Rect(0, 960, 400, 42)); // 2px visible at top + + let anchor = captureScrollAnchor('end', 'y', visibleRect, [['clipped-sliver', clippedSliver]]); + + expect(anchor).toBeNull(); + }); }); describe('computeScrollAnchorTarget', () => { @@ -216,6 +280,119 @@ describe('resolveScrollAdjustment', () => { expect(result?.y).toBe(600); }); + it('keeps the anchor when the user has scrolled away from the edge, even as items resize', () => { + // The user is NOT near the edge (they scrolled up to read), and an item resizes and grows + // content. Their reading position must be preserved via the anchor, not yanked to the edge. + let anchor: ScrollAnchor = {key: 'item', corner: 'topLeft', offset: 10}; + let layoutInfo = new LayoutInfo('item', 'item', new Rect(0, 610, 400, 40)); + + let result = resolveScrollAdjustment( + 'end', + 'y', + anchor, + false, // wasNearAnchorEdge -- scrolled away + false, // isScrolling + true, // itemSizeChanged + 50, // contentSizeDelta > 0 + () => layoutInfo, + visibleRect, + contentSize + ); + + // Anchor target (600), never the edge-snap target (2000 - 468 = 1532). + expect(result?.y).toBe(600); + }); + + it('follows the edge over the anchor while items settle near the edge', () => { + // The user is following the edge (near it, not scrolling) and items are measuring bigger. + // Even though the anchor resolves to a different target, we must snap to the edge -- the + // anchor only compensates for growth on its side of the viewport, so restoring it would + // strand the edge off-screen (the initial-render "partly scrolled up" bug). + let anchor: ScrollAnchor = {key: 'item', corner: 'topLeft', offset: 10}; + let layoutInfo = new LayoutInfo('item', 'item', new Rect(0, 610, 400, 40)); + + let result = resolveScrollAdjustment( + 'end', + 'y', + anchor, + true, // wasNearAnchorEdge -- following the edge + false, // isScrolling + true, // itemSizeChanged -- measurement settle + 50, // contentSizeDelta > 0 + () => layoutInfo, + visibleRect, + contentSize + ); + + // Edge-snap target (2000 - 468 = 1532), not the anchor target (600). + expect(result?.y).toBe(2000 - 468); + }); + + it('follows the edge over the anchor while items settle smaller near the edge', () => { + let anchor: ScrollAnchor = {key: 'item', corner: 'topLeft', offset: 10}; + let layoutInfo = new LayoutInfo('item', 'item', new Rect(0, 610, 400, 40)); + + let result = resolveScrollAdjustment( + 'end', + 'y', + anchor, + true, // wasNearAnchorEdge -- following the edge + false, // isScrolling + true, // itemSizeChanged -- measurement settle + -50, // contentSizeDelta < 0 (content shrank) + () => layoutInfo, + visibleRect, + contentSize + ); + + // Edge-snap target (2000 - 468 = 1532), not the anchor target (600). + expect(result?.y).toBe(2000 - 468); + }); + + it('preserves the anchor over the edge when the change was not at the edge', () => { + // Near the edge and an item resized, but the change was NOT at the anchored edge (e.g. a + // mid-list item grew while the user reads up top). We must preserve the anchor, not snap. + let anchor: ScrollAnchor = {key: 'item', corner: 'topLeft', offset: 10}; + let layoutInfo = new LayoutInfo('item', 'item', new Rect(0, 610, 400, 40)); + + let result = resolveScrollAdjustment( + 'end', + 'y', + anchor, + true, // wasNearAnchorEdge + false, // isScrolling + true, // itemSizeChanged + 50, // contentSizeDelta > 0 + () => layoutInfo, + visibleRect, + contentSize, + false // changeIsAtEdge -- the change was NOT at the anchored edge + ); + + // Anchor target (600), not the edge-snap target (2000 - 468 = 1532). + expect(result?.y).toBe(600); + }); + + it('does not snap to the edge when the change was not at the edge and no anchor resolves', () => { + // Fallback path: null anchor, near the edge, an item resized, but not at the anchored edge. + // Without gating the fallback too, this would still snap. It must stay put. + let result = resolveScrollAdjustment( + 'end', + 'y', + null, + true, // wasNearAnchorEdge + false, // isScrolling + true, // itemSizeChanged + 50, // contentSizeDelta > 0 + () => null, + visibleRect, + contentSize, + false // changeIsAtEdge + ); + + expect(result).toBeNull(); + }); + it('falls back to snapping to the edge when there is no anchor and near the edge', () => { let result = resolveScrollAdjustment( 'end', @@ -352,7 +529,6 @@ describe('ScrollAnchorTracker', () => { let result = tracker.resolveAfterLayout({ anchorInfo: null, anchor: null, - postLayoutInfos: new Map(), previousVisibleRect: visibleRect, previousContentSize: contentSize, contentSize, @@ -371,7 +547,6 @@ describe('ScrollAnchorTracker', () => { let result = tracker.resolveAfterLayout({ anchorInfo, anchor: null, - postLayoutInfos: new Map(), previousVisibleRect: new Rect(0, 0, 0, 0), previousContentSize: contentSize, contentSize, @@ -392,7 +567,6 @@ describe('ScrollAnchorTracker', () => { let result = tracker.resolveAfterLayout({ anchorInfo, anchor: null, - postLayoutInfos: new Map(), previousVisibleRect: visibleRect, previousContentSize: contentSize, contentSize, @@ -413,7 +587,6 @@ describe('ScrollAnchorTracker', () => { tracker.resolveAfterLayout({ anchorInfo, anchor: null, - postLayoutInfos: new Map(), previousVisibleRect: visibleRect, previousContentSize: contentSize, contentSize, @@ -425,7 +598,6 @@ describe('ScrollAnchorTracker', () => { let result = tracker.resolveAfterLayout({ anchorInfo, anchor: null, - postLayoutInfos: new Map(), previousVisibleRect: visibleRect, previousContentSize: contentSize, contentSize, @@ -445,7 +617,6 @@ describe('ScrollAnchorTracker', () => { tracker.resolveAfterLayout({ anchorInfo, anchor: null, - postLayoutInfos: new Map(), previousVisibleRect: visibleRect, previousContentSize: contentSize, contentSize, @@ -458,7 +629,6 @@ describe('ScrollAnchorTracker', () => { let result = tracker.resolveAfterLayout({ anchorInfo, anchor: null, - postLayoutInfos: new Map(), previousVisibleRect: visibleRect, previousContentSize: contentSize, contentSize: grownContentSize, @@ -470,105 +640,164 @@ describe('ScrollAnchorTracker', () => { expect(result?.y).toBe(2200 - 468); }); - it('reset() clears tracked state so the next call behaves like a first pass again', () => { + it('holds the viewport with the anchor when content grows without an item resize (prepend near the edge)', () => { + // Older content is prepended above the viewport while the user sits near the edge: content + // grows (contentSizeDelta > 0) but no item resized (itemSizeChanged is false). This is not a + // measurement settle, so the anchor wins and the viewport stays put instead of snapping to + // the new edge -- the prepended content shouldn't yank the user's view down. let tracker = new ScrollAnchorTracker(); - let visibleRect = new Rect(0, 2000 - 468, 400, 468); - let contentSize = new Size(400, 2000); + // Pass 1 establishes hasSnappedToEdge so the next pass is a normal relayout, not the first. + let firstContentSize = new Size(400, 2000); tracker.resolveAfterLayout({ anchorInfo, anchor: null, - postLayoutInfos: new Map(), - previousVisibleRect: visibleRect, - previousContentSize: contentSize, - contentSize, + previousVisibleRect: new Rect(0, 2000 - 468, 400, 468), + previousContentSize: firstContentSize, + contentSize: firstContentSize, itemSizeChanged: false, isScrolling: false, getLayoutInfo: () => null }); - tracker.reset(); + // Pass 2: viewport near the old edge (snap-eligible) and content grew 2000 -> 2200, but an + // anchor resolves, so the anchor wins over the snap. + let nearOldEdge = new Rect(0, 1520, 400, 468); // 12px from the old bottom edge + let grownContentSize = new Size(400, 2200); + let anchor: ScrollAnchor = {key: 'item', corner: 'topLeft', offset: 10}; + let anchorLayoutInfo = new LayoutInfo('item', 'item', new Rect(0, 1510, 400, 40)); - // Far from the edge; would return null on a non-first pass, but reset() means this counts - // as the first pass again, so it should snap unconditionally. - let farRect = new Rect(0, 0, 400, 468); let result = tracker.resolveAfterLayout({ + anchorInfo, + anchor, + previousVisibleRect: nearOldEdge, + previousContentSize: firstContentSize, + contentSize: grownContentSize, + itemSizeChanged: false, + isScrolling: false, + getLayoutInfo: () => anchorLayoutInfo + }); + + // Anchor target (1510 - 10 = 1500), never the edge snap (2200 - 468 = 1732). + expect(result?.y).toBe(1500); + }); + + it('follows the edge to the real bottom as estimated items measure on initial render', () => { + // Reproduces the initial-render settle: pass 1 snaps to the estimated bottom, then items + // measure bigger and content grows. An anchor is captured and it "moves" (items above it + // grew too), so a terminal anchor restore would land the viewport short of the new bottom + // -- the "partly scrolled up on first render" bug. Because the user is following the edge and + // items are settling, we must snap to the real bottom instead. + let tracker = new ScrollAnchorTracker(); + + // Pass 1: first anchored layout snaps to the estimated bottom (1296 - 468 = 828). + let estimatedContentSize = new Size(400, 1296); + tracker.resolveAfterLayout({ anchorInfo, anchor: null, - postLayoutInfos: new Map(), - previousVisibleRect: farRect, - previousContentSize: contentSize, - contentSize, + previousVisibleRect: new Rect(0, 0, 400, 468), + previousContentSize: estimatedContentSize, + contentSize: estimatedContentSize, itemSizeChanged: false, isScrolling: false, getLayoutInfo: () => null }); - expect(result?.y).toBe(2000 - 468); + // Pass 2: items measured, content grew 1296 -> 1692. The anchor resolves to 960 (it moved + // down 132px as items above it grew), but restoring it would strand the bottom 264px off. + let atEstimatedBottom = new Rect(0, 828, 400, 468); + let measuredContentSize = new Size(400, 1692); + let anchor: ScrollAnchor = {key: 'item', corner: 'topLeft', offset: 10}; + let movedAnchorInfo = new LayoutInfo('item', 'item', new Rect(0, 970, 400, 40)); + + let result = tracker.resolveAfterLayout({ + anchorInfo, + anchor, + previousVisibleRect: atEstimatedBottom, + previousContentSize: estimatedContentSize, + contentSize: measuredContentSize, + itemSizeChanged: true, + isScrolling: false, + getLayoutInfo: () => movedAnchorInfo + }); + + // The real bottom (1692 - 468 = 1224), not the anchor target (960). + expect(result?.y).toBe(1692 - 468); }); - it('reuses the pre-resize "near edge" decision across a settling cascade instead of recomputing mid-resize', () => { + it('preserves the reading position when a change not at the edge settles after the first pass', () => { + // Mirrors the initial-settle case, but the growth was NOT at the anchored edge (a mid-thread + // item resized while the user is scrolled up). Instead of snapping to the real bottom, keep + // the anchor so the reading position is preserved. let tracker = new ScrollAnchorTracker(); - let contentSize = new Size(400, 2000); - // Near the bottom edge before resizing starts. - let nearEdgeRect = new Rect(0, 2000 - 468, 400, 468); - // Later, after items grew, the same viewport position is far from the (new, larger) edge. - let farRect = new Rect(0, 0, 400, 468); - // Pass 1 (first pass, no estimated items): establishes hasSnappedToEdge and records that - // the viewport was near the edge. + // Pass 1: establish hasSnappedToEdge so pass 2 is a normal relayout, not the first. + let firstContentSize = new Size(400, 1296); tracker.resolveAfterLayout({ anchorInfo, anchor: null, - postLayoutInfos: new Map(), - previousVisibleRect: nearEdgeRect, - previousContentSize: contentSize, - contentSize, + previousVisibleRect: new Rect(0, 0, 400, 468), + previousContentSize: firstContentSize, + contentSize: firstContentSize, itemSizeChanged: false, isScrolling: false, getLayoutInfo: () => null }); - // Pass 2 (resize begins): an estimated-size item shows up. The previous pass wasn't - // estimating, so this pass still freely recomputes "near edge" using the still-near rect, - // and records true. - let estimatedItem = new LayoutInfo('item', 'item', new Rect(0, 0, 400, 40)); - estimatedItem.estimatedSize = true; - let midResizeContentSize = new Size(400, 2100); + // Pass 2: content grew from a mid-thread resize (changeIsAtEdge false). The anchor resolves to + // 960; we return that instead of the edge snap (1692 - 468 = 1224). + let scrolledUp = new Rect(0, 828, 400, 468); + let measuredContentSize = new Size(400, 1692); + let anchor: ScrollAnchor = {key: 'item', corner: 'topLeft', offset: 10}; + let movedAnchorInfo = new LayoutInfo('item', 'item', new Rect(0, 970, 400, 40)); + + let result = tracker.resolveAfterLayout({ + anchorInfo, + anchor, + previousVisibleRect: scrolledUp, + previousContentSize: firstContentSize, + contentSize: measuredContentSize, + itemSizeChanged: true, + isScrolling: false, + getLayoutInfo: () => movedAnchorInfo, + changeIsAtEdge: false + }); + + expect(result?.y).toBe(960); + }); + + it('reset() clears tracked state so the next call behaves like a first pass again', () => { + let tracker = new ScrollAnchorTracker(); + let visibleRect = new Rect(0, 2000 - 468, 400, 468); + let contentSize = new Size(400, 2000); - let midResult = tracker.resolveAfterLayout({ + tracker.resolveAfterLayout({ anchorInfo, anchor: null, - postLayoutInfos: new Map([['item', estimatedItem]]), - previousVisibleRect: nearEdgeRect, + previousVisibleRect: visibleRect, previousContentSize: contentSize, - contentSize: midResizeContentSize, - itemSizeChanged: true, + contentSize, + itemSizeChanged: false, isScrolling: false, getLayoutInfo: () => null }); - expect(midResult?.y).toBe(midResizeContentSize.height - nearEdgeRect.height); - - // Pass 3 (settling): sizes are no longer estimated, but the rect passed in for this pass - // has drifted far from the (new) edge -- if the tracker recomputed naively it would decide - // "not near edge" and refuse to snap. Because pass 2 had estimated items, this pass reuses - // pass 2's recorded decision (true) instead, and still snaps. - let settledItem = new LayoutInfo('item', 'item', new Rect(0, 0, 400, 40)); - let finalContentSize = new Size(400, 2200); + tracker.reset(); - let settledResult = tracker.resolveAfterLayout({ + // Far from the edge; would return null on a non-first pass, but reset() means this counts + // as the first pass again, so it should snap unconditionally. + let farRect = new Rect(0, 0, 400, 468); + let result = tracker.resolveAfterLayout({ anchorInfo, anchor: null, - postLayoutInfos: new Map([['item', settledItem]]), previousVisibleRect: farRect, - previousContentSize: midResizeContentSize, - contentSize: finalContentSize, - itemSizeChanged: true, + previousContentSize: contentSize, + contentSize, + itemSizeChanged: false, isScrolling: false, getLayoutInfo: () => null }); - expect(settledResult?.y).toBe(finalContentSize.height - farRect.height); + expect(result?.y).toBe(2000 - 468); }); });