Skip to content

feat: reverse virtualizer#10258

Open
yihuiliao wants to merge 31 commits into
mainfrom
reverse-virtualizer
Open

feat: reverse virtualizer#10258
yihuiliao wants to merge 31 commits into
mainfrom
reverse-virtualizer

Conversation

@yihuiliao

@yihuiliao yihuiliao commented Jun 24, 2026

Copy link
Copy Markdown
Member

Thread now accepts an anchorTo="end" prop that enables this mode. It is possible to have a virtualized non-virtualized Chat experience.

The virtualizer now handles three distinct scroll scenarios:

  1. Initial render — snap to the bottom
  2. Streaming / new message — stay pinned to the bottom if the user is near the end
  3. (Theoretical, haven't really tested this one to be honest) History load at top — restore the scroll anchor so existing content doesn't jump.

Changes:

  • ListLayout — new anchorTo: 'end' option; adds buildReversedCollection() which positions items bottom-up, and an isReversed() method to signal this to the virtualizer.
  • Layout — base isReversed() method (returns false) so Virtualizer has a way to ask Layout if it is reversed
  • Virtualizer — scroll anchor capture/restore logic keyed on isReversed(). Adds _getScrollAnchor, _restoreScrollAnchor, and _applyReverseAnchorScroll helpers.
  • VirtualizerItem / useVirtualizerItem — new shouldObserveItemSize prop attaches a ResizeObserver to each item's children so dynamic content (e.g. streaming text) triggers re-layout.
  • Virtualizer.tsx (RAC) — threads shouldObserveItemSize and scrollEndThreshold through to the state layer.

To-Dos:

  1. Add tests for Thread (I know, very broad but it's seriously lacking right now)
  2. (If we want to support Async Loading), create a LoadMoreItem for Thread. Otherwise it's impossible to test right now.
  3. Add stories and tests to RAC GridList and ListBox

Follow-ups:

  1. Key-based scroll anchoring (I pulled this out because it was increasing the complexity quite a bit). Some questions to consider: 1) should scroll anchoring live in the virtualizer itself, or as a separate layer on top of it? Devon mentioned maybe extending ListLayout perhaps? 2) When scroll is pinned to a key, what should happen to the space below that item as content streams in? The current approach (reserve padding that drains as content fills) is one policy, but there could be others (clip immediately, consumer-controlled). 3) The whitespace behavior and anchor lifecycle may need more consumer control than a single callback provides.
  2. Thread currently derives isNearBottom from live DOM scroll values in handleScroll, while the virtualizer derives wasNearBottom from its pre-layout visibleRect/contentSize snapshot when deciding whether to snap. That means the scroll button state can briefly lag behind a virtualizer-driven snap. I haven't seen anything noticeable with this (maybe testing can prove me wrong), but might be cleaner for the virtualizer to expose a generic “near end” signal, e.g. onNearEndChange or similar, using the same snapshot it uses for anchored scrolling. Then ThreadScrollButton could consume that instead of duplicating the threshold calculation from the DOM.

Known Bugs:

  • Opening or closing a Disclosure inside Chat causes a visible scroll jump, but only when the scroll position is within scrollEndThreshold of the bottom. Virtualizer has a branch that snaps the viewport to the bottom whenever any item's size changes while the user is "near bottom". This branch was written for streaming (latest message growing), but it fires indiscriminately for any ResizeObserver-triggered resize.

✅ 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

📝 Test Instructions:

Streaming Chat is the non-virtualized Thread story. Test to make sure that behavior has stayed all the same.

Virtualized Streaming Chat is the virtualized Thread story. You'll want to compare this behavior with the non-virtualized (keyboard focus, announcements, scrolling behavior).

Empty Chat is a virtualized chat that doesn't contain any messages. The first message should appear at the top and fill out the container until it overflows at which point you should be anchored to the bottom.

Sanity check virtualized components RAC or S2 to make sure their behavior hasn't changed.

🧢 Your Project:

@github-actions github-actions Bot added the RAC label Jun 24, 2026
@rspbot

rspbot commented Jun 24, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jun 24, 2026

Copy link
Copy Markdown

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.

Opening or closing a Disclosure inside Chat causes a visible scroll jump, but only when the scroll position is within scrollEndThreshold of the bottom. Virtualizer has a branch that snaps the viewport to the bottom whenever any item's size changes while the user is "near bottom". This branch was written for streaming (latest message growing), but it fires indiscriminately for any ResizeObserver-triggered resize.

If anyone has any ideas on how to fix this, that would be great. Claude suggested guarding the snap so it only fires when the resized item is the first key (the latest message), but not sure how I feel about that since if the disclosure were the first key, the weird jumping would still occur.

@yihuiliao yihuiliao marked this pull request as ready for review June 24, 2026 23:52
@yihuiliao yihuiliao changed the title wip: reverse virtualizer feat: reverse virtualizer Jun 24, 2026
@rspbot

rspbot commented Jun 25, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jun 25, 2026

Copy link
Copy Markdown
## API Changes

react-aria-components

/react-aria-components:TableLayout

 TableLayout <O extends TableLayoutProps = TableLayoutProps, T> {
   constructor: (TableLayoutProps) => void
   getContentSize: () => Size
   getDropTargetFromPoint: (number, number, (DropTarget) => boolean) => DropTarget | null
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getLayoutInfo: (Key) => LayoutInfo | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
+  isReversed: (ListLayoutOptions) => boolean
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (TableLayoutProps, TableLayoutProps) => boolean
   update: (InvalidationContext<TableLayoutProps>) => void
   updateItemSize: (Key, Size) => boolean
   virtualizer: Virtualizer<{}, any> | null
 }

/react-aria-components:Virtualizer

 Virtualizer <O> {
   children: ReactNode
   layout: LayoutClass<O> | ILayout<O>
   layoutOptions?: O
+  scrollEndThreshold?: number
+  shouldObserveItemSize?: boolean
 }

/react-aria-components:ListLayout

 ListLayout <O extends ListLayoutOptions = ListLayoutOptions, T> {
   constructor: (ListLayoutOptions) => void
   getContentSize: () => Size
   getDropTargetFromPoint: (number, number, (DropTarget) => boolean) => DropTarget | null
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getLayoutInfo: (Key) => LayoutInfo | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
+  isReversed: (ListLayoutOptions) => boolean
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (ListLayoutOptions, ListLayoutOptions) => boolean
   update: (InvalidationContext<ListLayoutOptions>) => void
   updateItemSize: (Key, Size) => boolean
 }

/react-aria-components:WaterfallLayout

 WaterfallLayout <O extends WaterfallLayoutOptions = WaterfallLayoutOptions, T extends {}> {
   getContentSize: () => Size
   getDropTargetFromPoint: (number, number) => DropTarget
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getKeyLeftOf: (Key) => Key | null
   getKeyRange: (Key, Key) => Array<Key>
   getKeyRightOf: (Key) => Key | null
   getLayoutInfo: (Key) => LayoutInfo
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
+  isReversed: (WaterfallLayoutOptions) => boolean
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (WaterfallLayoutOptions, WaterfallLayoutOptions) => boolean
   update: (InvalidationContext<WaterfallLayoutOptions>) => void
   updateItemSize: (Key, Size) => boolean
 }

/react-aria-components:Layout

 Layout <O = any, T extends {} = Node<any>> {
   getContentSize: () => Size
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getLayoutInfo: (Key) => LayoutInfo | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
+  isReversed: (O) => boolean
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (O, O) => boolean
   update: (InvalidationContext<O>) => void
   updateItemSize: (Key, Size) => boolean
 }

/react-aria-components:GridLayout

 GridLayout <O extends GridLayoutOptions = GridLayoutOptions, T> {
   getContentSize: () => Size
   getDropTargetFromPoint: (number, number, (DropTarget) => boolean) => DropTarget
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getLayoutInfo: (Key) => LayoutInfo | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
+  isReversed: (GridLayoutOptions) => boolean
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (GridLayoutOptions, GridLayoutOptions) => boolean
   update: (InvalidationContext<GridLayoutOptions>) => void
   updateItemSize: (Key, Size) => boolean
   virtualizer: Virtualizer<{}, any> | null
 }

/react-aria-components:VirtualizerProps

 VirtualizerProps <O> {
   children: ReactNode
   layout: LayoutClass<O> | ILayout<O>
   layoutOptions?: O
+  scrollEndThreshold?: number
+  shouldObserveItemSize?: boolean
 }

/react-aria-components:ListLayoutOptions

 ListLayoutOptions {
+  anchorTo?: 'end'
   dropIndicatorThickness?: number = 2
   estimatedHeadingSize?: number
   estimatedRowSize?: number
   gap?: number = 0
   loaderSize?: number = 48
   orientation?: Orientation = 'vertical'
   padding?: number = 0
   rowSize?: number = 48
 }

/react-aria-components:TableLayoutProps

 TableLayoutProps {
+  anchorTo?: 'end'
   columnWidths?: Map<Key, number>
   dropIndicatorThickness?: number = 2
   estimatedHeadingHeight?: number
   estimatedRowHeight?: number
   headingHeight?: number = 48
   loaderHeight?: number = 48
   padding?: number = 0
   rowHeight?: number = 48
 }

@react-aria/virtualizer

/@react-aria/virtualizer:VirtualizerItem

 VirtualizerItem {
   children: ReactNode
   className?: string
   layoutInfo: LayoutInfo
   parent?: LayoutInfo | null
+  shouldObserveItemSize?: boolean
   style?: CSSProperties
   virtualizer: IVirtualizer
 }

/@react-aria/virtualizer:VirtualizerItemOptions

 VirtualizerItemOptions {
   layoutInfo: LayoutInfo | null
   ref: RefObject<HTMLElement | null>
+  shouldObserveItemSize?: boolean
   virtualizer: IVirtualizer
 }

@react-spectrum/ai

/@react-spectrum/ai:Thread

 Thread <T extends {}> {
+  anchorTo?: 'end'
   aria-label?: string
   aria-labelledby?: string
   children?: ReactNode | (T) => ReactNode
   items?: Iterable<T>
+  scrollEndThreshold?: number = 100
   styles?: StyleString
 }

/@react-spectrum/ai:ThreadItem

 ThreadItem {
   children?: ChildrenOrFunction<GridListItemRenderProps>
+  id?: Key
   isStreaming?: boolean
   shouldAnnounceOnMount?: boolean
   styles?: StyleString
   textValue?: string

/@react-spectrum/ai:ThreadProps

 ThreadProps <T extends {}> {
+  anchorTo?: 'end'
   aria-label?: string
   aria-labelledby?: string
   children?: ReactNode | (T) => ReactNode
   items?: Iterable<T>
+  scrollEndThreshold?: number = 100
   styles?: StyleString
 }

/@react-spectrum/ai:ThreadItemProps

 ThreadItemProps {
   children?: ChildrenOrFunction<GridListItemRenderProps>
+  id?: Key
   isStreaming?: boolean
   shouldAnnounceOnMount?: boolean
   styles?: StyleString
   textValue?: string

@react-spectrum/card

/@react-spectrum/card:GalleryLayout

 GalleryLayout <T> {
   _distributeWidths: (Array<number>) => boolean
   _findClosest: (Rect, Rect) => LayoutInfo | null
   _findClosestLayoutInfo: (Rect, Rect) => LayoutInfo | null
   buildCollection: () => void
   collection: GridCollection<T>
   constructor: (GalleryLayoutOptions) => void
   direction: Direction
   disabledKeys: Set<Key>
   getContentSize: () => number
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getFirstKey: () => Node<T> | undefined
   getKeyAbove: (Key) => Node<T> | undefined
   getKeyBelow: (Key) => Node<T> | undefined
   getKeyForSearch: (string, Key) => Node<T> | undefined | null
   getKeyLeftOf: (Key) => Node<T> | undefined
   getKeyPageAbove: (Key) => Node<T> | undefined
   getKeyPageBelow: (Key) => Node<T> | undefined
   getKeyRightOf: (Key) => Node<T> | undefined
   getLastKey: () => Node<T> | undefined
   getLayoutInfo: (Key) => LayoutInfo
   getVisibleLayoutInfos: (Rect, any) => Array<LayoutInfo>
   isLoading: boolean
+  isReversed: (CardViewLayoutOptions) => boolean
   isVisible: (LayoutInfo, Rect, boolean) => boolean
   itemPadding: number
   layoutType: string
   margin: number
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (CardViewLayoutOptions, CardViewLayoutOptions) => boolean
   update: (InvalidationContext<CardViewLayoutOptions>) => void
   updateItemSize: (Key, Size) => boolean
   virtualizer: Virtualizer<{}, any> | null
 }

/@react-spectrum/card:GridLayout

 GridLayout <T> {
   _findClosest: (Rect, Rect) => LayoutInfo | null
   _findClosestLayoutInfo: (Rect, Rect) => LayoutInfo | null
   buildChild: (Node<T>, number, number) => LayoutInfo
   buildCollection: () => void
   cardOrientation: Orientation
   collection: GridCollection<T>
   constructor: (GridLayoutOptions) => void
   direction: Direction
   disabledKeys: Set<Key>
   getContentSize: () => number
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getFirstKey: () => Node<T> | undefined
   getIndexAtPoint: (number, number, any) => number
   getKeyAbove: (Key) => Node<T> | undefined | null
   getKeyBelow: (Key) => Node<T> | undefined | null
   getKeyForSearch: (string, Key) => Node<T> | undefined | null
   getKeyLeftOf: (Key) => Node<T> | undefined
   getKeyPageAbove: (Key) => Node<T> | undefined
   getKeyPageBelow: (Key) => Node<T> | undefined
   getKeyRightOf: (Key) => Node<T> | undefined
   getLastKey: () => Node<T> | undefined
   getLayoutInfo: (Key) => LayoutInfo
   getVisibleLayoutInfos: (Rect, any) => Array<LayoutInfo>
   isLoading: boolean
+  isReversed: (CardViewLayoutOptions) => boolean
   isVisible: (LayoutInfo, Rect, boolean) => boolean
   itemPadding: number
   layoutType: string
   margin: number
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (CardViewLayoutOptions, CardViewLayoutOptions) => boolean
   update: (InvalidationContext<CardViewLayoutOptions>) => void
   updateItemSize: (Key, Size) => boolean
   virtualizer: Virtualizer<{}, any> | null
 }

/@react-spectrum/card:WaterfallLayout

 WaterfallLayout <T> {
   _findClosest: (Rect, Rect) => LayoutInfo | null
   _findClosestLayoutInfo: (Rect, Rect) => LayoutInfo | null
   buildCollection: (InvalidationContext) => void
   collection: GridCollection<T>
   constructor: (WaterfallLayoutOptions) => void
   direction: Direction
   disabledKeys: Set<Key>
   getClosestLeft: (Key) => Node<T> | undefined
   getClosestRight: (Key) => Node<T> | undefined
   getContentSize: () => number
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getFirstKey: () => Node<T> | undefined
   getKeyAbove: (Key) => Node<T> | undefined
   getKeyBelow: (Key) => Node<T> | undefined
   getKeyForSearch: (string, Key) => Node<T> | undefined | null
   getKeyLeftOf: (Key) => Node<T> | undefined
   getKeyPageAbove: (Key) => Node<T> | undefined
   getKeyPageBelow: (Key) => Node<T> | undefined
   getKeyRightOf: (Key) => Node<T> | undefined
   getLastKey: () => Node<T> | undefined
   getLayoutInfo: (Key) => LayoutInfo
   getNextColumnIndex: (Array<number>) => number
   getVisibleLayoutInfos: (Rect, any) => Array<LayoutInfo>
   isLoading: boolean
+  isReversed: (CardViewLayoutOptions) => boolean
   isVisible: (LayoutInfo, Rect, boolean) => boolean
   layoutType: string
   margin: number
   scale: Scale
   shouldInvalidateLayoutOptions: (CardViewLayoutOptions, CardViewLayoutOptions) => boolean
   update: (InvalidationContext<CardViewLayoutOptions>) => void
   updateItemSize: (Key, Size) => number
   virtualizer: Virtualizer<{}, any> | null
 }

@react-stately/layout

/@react-stately/layout:GridLayout

 GridLayout <O extends GridLayoutOptions = GridLayoutOptions, T> {
   getContentSize: () => Size
   getDropTargetFromPoint: (number, number, (DropTarget) => boolean) => DropTarget
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getLayoutInfo: (Key) => LayoutInfo | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
+  isReversed: (GridLayoutOptions) => boolean
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (GridLayoutOptions, GridLayoutOptions) => boolean
   update: (InvalidationContext<GridLayoutOptions>) => void
   updateItemSize: (Key, Size) => boolean
 }

/@react-stately/layout:ListLayout

 ListLayout <O extends ListLayoutOptions = ListLayoutOptions, T> {
   constructor: (ListLayoutOptions) => void
   getContentSize: () => Size
   getDropTargetFromPoint: (number, number, (DropTarget) => boolean) => DropTarget | null
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getLayoutInfo: (Key) => LayoutInfo | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
+  isReversed: (ListLayoutOptions) => boolean
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (ListLayoutOptions, ListLayoutOptions) => boolean
   update: (InvalidationContext<ListLayoutOptions>) => void
   updateItemSize: (Key, Size) => boolean
 }

/@react-stately/layout:TableLayout

 TableLayout <O extends TableLayoutProps = TableLayoutProps, T> {
   constructor: (TableLayoutProps) => void
   getContentSize: () => Size
   getDropTargetFromPoint: (number, number, (DropTarget) => boolean) => DropTarget | null
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getLayoutInfo: (Key) => LayoutInfo | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
+  isReversed: (ListLayoutOptions) => boolean
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (TableLayoutProps, TableLayoutProps) => boolean
   update: (InvalidationContext<TableLayoutProps>) => void
   updateItemSize: (Key, Size) => boolean
 }

/@react-stately/layout:WaterfallLayout

 WaterfallLayout <O extends WaterfallLayoutOptions = WaterfallLayoutOptions, T extends {}> {
   getContentSize: () => Size
   getDropTargetFromPoint: (number, number) => DropTarget
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getKeyLeftOf: (Key) => Key | null
   getKeyRange: (Key, Key) => Array<Key>
   getKeyRightOf: (Key) => Key | null
   getLayoutInfo: (Key) => LayoutInfo
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
+  isReversed: (WaterfallLayoutOptions) => boolean
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (WaterfallLayoutOptions, WaterfallLayoutOptions) => boolean
   update: (InvalidationContext<WaterfallLayoutOptions>) => void
   updateItemSize: (Key, Size) => boolean
 }

/@react-stately/layout:ListLayoutOptions

 ListLayoutOptions {
+  anchorTo?: 'end'
   dropIndicatorThickness?: number = 2
   estimatedHeadingSize?: number
   estimatedRowSize?: number
   gap?: number = 0
   loaderSize?: number = 48
   orientation?: Orientation = 'vertical'
   padding?: number = 0
   rowSize?: number = 48
 }

/@react-stately/layout:TableLayoutProps

 TableLayoutProps {
+  anchorTo?: 'end'
   columnWidths?: Map<Key, number>
   dropIndicatorThickness?: number = 2
   estimatedHeadingHeight?: number
   estimatedRowHeight?: number
   headingHeight?: number = 48
   loaderHeight?: number = 48
   padding?: number = 0
   rowHeight?: number = 48
 }

@react-stately/virtualizer

/@react-stately/virtualizer:Layout

 Layout <O = any, T extends {} = Node<any>> {
   getContentSize: () => Size
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getLayoutInfo: (Key) => LayoutInfo | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
+  isReversed: (O) => boolean
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (O, O) => boolean
   update: (InvalidationContext<O>) => void
   updateItemSize: (Key, Size) => boolean
 }

@rspbot

rspbot commented Jun 25, 2026

Copy link
Copy Markdown

Agent Skills Changes

Modified (2)
Install

React Spectrum S2:

npx skills add https://d1pzu54gtk2aed.cloudfront.net/pr/d0cda8657a7a071103454c9aee9a802cb7a2110d/

React Aria:

npx skills add https://d5iwopk28bdhl.cloudfront.net/pr/d0cda8657a7a071103454c9aee9a802cb7a2110d/

await user.tab();
expect(document.activeElement).toBe(rows[0]);
expect(rows[0]).toHaveTextContent('Hello');
expect(rows[0]).toHaveTextContent('World');

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.

Updates the tests here because now we reverse the array inside Threads. These specific tests are pre-existing and are for the non-virtualized case.

}

interface LayoutContextValue {
interface VirtualizerOptionsContextValue {

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.

Renamed to be a bit more generic since this context now handles more than just Layouts. I would be okay changing it and splitting it out though.

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