Skip to content

fix: scroll anchoring improvements for chat - #10600

Open
yihuiliao wants to merge 22 commits into
mainfrom
reverse-virtualizer-follow-up
Open

yihuiliao wants to merge 22 commits into
mainfrom
reverse-virtualizer-follow-up

Conversation

@yihuiliao

@yihuiliao yihuiliao commented Sep 11, 2026 •

Copy link
Copy Markdown
Member

This PR fixes a couple of things. Some of which were from testing in July and some that were noticed while making the ai component docs.

  1. Updates the width of the Chat in storybook so it fits on mobile screens
  2. Removes unneeded state to calculate scroll anchoring (see devon's comment)
  3. Fixes the scroll positioning from jumping on mobile
  4. Expanding and collapsing response status in virtualized chat thread has weird scroll behavior (like it is opening upward).

For 2, I removed the hadEstimatedVisibleItems / wasNearAnchorEdge fields that tried to remember "are we near the edge?" across a resize. They were redundant since resolveScrollAdjustment already gets itemSizeChanged and contentSizeDelta per pass, so the same decision (follow edge vs. keep anchor) can be made directly from those without tracked history.

For 3, scroll position jumped on mobile, an item can only be anchored if it fits fully inside the viewport. On mobile, one tall item can fill the whole screen, so nothing ever fully fit and no anchor was picked. Now we anchor to the item closest to the edge even if it's partly cut off, falling back to the least-cut-off item if none fit.

For 4, expanding/collapsing response status scrolled upward instead of staying put, a resize anywhere in the list was treated as if it happened at the anchored edge when the chat was short, so the virtualizer would "follow the edge" and snap the scroll position, even when the resized item was in the middle of the list. Added tracking for whether a resize batch actually touched the newest item; if it didn't, we preserve the user's reading position instead of following the edge.

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices
  • I understand every change in this PR and can explain why it's there.
  • If AI-assisted, I followed our AI contribution guidance and pointed my assistant at CLAUDE.md.

📝 Test Instructions:

🧢 Your Project:

@rspbot

rspbot commented Sep 11, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Sep 30, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Oct 3, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Oct 5, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Oct 5, 2026

Copy link
Copy Markdown

@yihuiliao
yihuiliao marked this pull request as ready for review October 5, 2026 23:36
@yihuiliao yihuiliao changed the title wip: scroll anchoring improvements for chat fix: scroll anchoring improvements for chat Oct 5, 2026
// 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._batchIncludedEdgeContent;

@yihuiliao yihuiliao Oct 8, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If only older messages resized, preserve the reading position instead of following the bottom. If the resize batch includes newest content, bottom-following remains eligible (but not a guarantee. there are other conditions that need to be met for the bottom-following to happen).

This is for the case where the chat is quite short, meaning that an older message is still within the threshold of being considered "near bottom".

// (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 at the edge" values and make the wrong call.
this._hadItemResize = false;
this._batchIncludedEdgeContent = false;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear the resize flags after each relayout so the next pass doesn’t reuse this batch’s classification.

if (changed) {
this._hadItemResize = true;
// "Batch" refers to the set of updateItemSize calls that happen between one relayout and the next
this._batchIncludedEdgeContent ||= this.isEdgeContent(key);

@yihuiliao yihuiliao Oct 8, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several items can measure before one relayout. Remember whether any changed item was edge content which is decided in ListLayout. In this case, if it's the last item (and not a loader), it counts as edge content.

: 'topRight'
: edge === 'end'
? 'topLeft'
: 'bottomLeft';

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For bottom anchoring, track the item’s top so resizing the item itself doesn’t move our reference point. Top anchoring uses the item’s bottom; horizontal layouts use the equivalent left/right edges.

}
}
return best;
return best ?? fallback;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer an item whose top is visible. Only fall back to a clipped item when no visible-top candidate exists.

let domProps = filterDOMProps(otherProps);
let {isFocusVisible, focusProps} = useFocusRing();
let hasDetail = detail != null;
// Play the entrance (fade + slide) once, then remove the animating styles.

@yihuiliao yihuiliao Oct 8, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can reproduce this on our current docs. Go to the AI components page, go to the Chat Threads example, open the ResponseStatus and the ExecutionTrace items, and then scroll up and down. You should see a brief flash and then the contents settle in again.

If you open DevTools > More Tools > Rendering > Layer borders, you should see the layer appear and disappear.

This is what I (and claude) ended up coming up with but if anyone has better ideas, let me know. Can't say this whole browser layer stuff is my strong suite.

@yihuiliao yihuiliao Oct 8, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the framework of what the scroll anchoring captures in a couple of different use cases. This is what I have for now, I think they're subject to change as we test this more.

Visible items Selected anchor
One or more items with visible tops The one whose top is nearest the viewport top.
Top-clipped item plus another with a visible top Prefer the visible-top item
Only top-clipped candidates Use the least-clipped candidate’s actual top, with a negative offset.
Item’s top visible, bottom clipped Still a preferred candidate; full visibility isn’t required.

// and following it would fall short of the edge.
let effectiveAnchor =
isFirstAnchoredLayout || (wasSettlingLastPass && wasNearAnchorEdgeLastPass) ? null : anchor;
let effectiveAnchor = isFirstAnchoredLayout ? null : anchor;

@yihuiliao yihuiliao Oct 8, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So previously, wasSettlingLastPass and wasNearAnchorEdgeLastPass remembered whether we were near the bottom while estimated items were measuring, and used that to skip item-anchor preservation on later passes. But being near the bottom when items are measuring doesn't mean we should be pulled to the bottom. For example, opening older content should preserve the reading position.

Now, bottom-anchoring is decided separately using proximity and whether the resize batch includes edge content (along with other conditions). Older-item-only resizes no longer trigger a bottom snap just because we’re within the threshold.

@rspbot

rspbot commented Oct 8, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Oct 8, 2026

Copy link
Copy Markdown
## API Changes

@react-aria/utils

/@react-aria/utils:isKeyboardOpen

+isKeyboardOpen {
+  returnVal: undefined
+}

/@react-aria/utils:supportsKeyboard

+supportsKeyboard {
+  returnVal: undefined
+}

@react-spectrum/s2

/@react-spectrum/s2:SideNavItem

 SideNavItem {
   aria-label?: string
   children: ReactNode
   download?: boolean | string
-  focusMode?: 'child' | 'row' = 'row'
   hasChildItems?: boolean
   href?: Href
   hrefLang?: string
   id?: Key
   onHoverChange?: (boolean) => void
   onHoverEnd?: (HoverEvent) => void
   onHoverStart?: (HoverEvent) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   onPressUp?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   target?: HTMLAttributeAnchorTarget
   textValue: string
 }

/@react-spectrum/s2:SideNavPanel

-SideNavPanel {
-  UNSAFE_className?: UnsafeClassName
-  UNSAFE_style?: CSSProperties
-  aria-describedby?: string
-  aria-details?: string
-  aria-label?: string
-  aria-labelledby?: string
-  children?: ReactNode
-  defaultCollapsed?: boolean
-  isCollapsed?: boolean
-  onCollapsedChange?: (boolean) => void
-  styles?: StylesPropWithHeight
-}

/@react-spectrum/s2:SideNavPanelContext

-SideNavPanelContext {
-  UNTYPED
-}

/@react-spectrum/s2:SideNavItemProps

 SideNavItemProps {
   aria-label?: string
   children: ReactNode
   download?: boolean | string
-  focusMode?: 'child' | 'row' = 'row'
   hasChildItems?: boolean
   href?: Href
   hrefLang?: string
   id?: Key
   onHoverChange?: (boolean) => void
   onHoverEnd?: (HoverEvent) => void
   onHoverStart?: (HoverEvent) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   onPressUp?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   target?: HTMLAttributeAnchorTarget
   textValue: string
 }

/@react-spectrum/s2:SideNavPanelProps

-SideNavPanelProps {
-  UNSAFE_className?: UnsafeClassName
-  UNSAFE_style?: CSSProperties
-  aria-describedby?: string
-  aria-details?: string
-  aria-label?: string
-  aria-labelledby?: string
-  children?: ReactNode
-  defaultCollapsed?: boolean
-  isCollapsed?: boolean
-  onCollapsedChange?: (boolean) => void
-  styles?: StylesPropWithHeight
-}

/@react-spectrum/s2:SidePanel

+SidePanel {
+  UNSAFE_className?: UnsafeClassName
+  UNSAFE_style?: CSSProperties
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ReactNode
+  defaultCollapsed?: boolean
+  isCollapsed?: boolean
+  onCollapsedChange?: (boolean) => void
+  styles?: StylesPropWithHeight
+}

/@react-spectrum/s2:SidePanelContext

+SidePanelContext {
+  UNTYPED
+}

/@react-spectrum/s2:SidePanelProps

+SidePanelProps {
+  UNSAFE_className?: UnsafeClassName
+  UNSAFE_style?: CSSProperties
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ReactNode
+  defaultCollapsed?: boolean
+  isCollapsed?: boolean
+  onCollapsedChange?: (boolean) => void
+  styles?: StylesPropWithHeight
+}

@rspbot

rspbot commented Oct 8, 2026

Copy link
Copy Markdown

Agent Skills Changes

Added (1)
Removed (1)
  • s2/agent-skills/react-spectrum-s2/references/components/SideNavPanel.md
Modified (16)
Install

React Spectrum S2:

npx skills add https://d1pzu54gtk2aed.cloudfront.net/pr/6c3c9d109d0d4f29f0805a4faf6f0c00a5dce2e5/

React Aria:

npx skills add https://d5iwopk28bdhl.cloudfront.net/pr/6c3c9d109d0d4f29f0805a4faf6f0c00a5dce2e5/

}

if (wasNearAnchorEdge && !isScrolling && (!itemSizeChanged || contentSizeDelta > 0)) {
if (wasNearAnchorEdge && (!itemSizeChanged || contentSizeDelta !== 0) && changeIsAtEdge) {

@yihuiliao yihuiliao Oct 9, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the isScrolling guard because it currently treats programmatic scroll adjustments the same as user scrolling. When ResponseStatus expands as the last item, a scroll adjustment can mark scrolling as active and block subsequent bottom-following, leaving execution items below the viewport.

However, removing this guard may pull users back towards the bottom during active scrolling but I've tested it myself and I don't think it's the worst...but it is a tradeoff.

As a follow-up, I think we may need to differentiate between programmatic scrolling vs. user scrolling.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants