Skip to content

feat: reverse virtualizer#10258

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

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

Conversation

@yihuiliao

@yihuiliao yihuiliao commented Jun 24, 2026

Copy link
Copy Markdown
Member

Thread now sets an anchorTo="end" prop that enables the reverse collection. At this time, we are going to only support a virtualized Chat experience, but we might add a non-virtualized experience later.

It is possible to have a virtualized non-virtualized Chat experience.

Three scenarios that we handle with scroll anchoring/virtualizer:

  1. Initial render — snap to the bottom
  2. Streaming / new message — stay pinned to the bottom if the user is near the end
  3. History load at top — restore the scroll anchor so existing content doesn't jump.

Changes:

  • ScrollAnchor.ts — Layout-agnostic scroll-anchoring math, used by Virtualizer:
    • captureScrollAnchor — picks the item nearest the "anchored" edge of the viewport to use as a reference point.
    • computeScrollAnchorTarget — converts a captured anchor back into a viewport offset once layout changes.
    • getEdgeSnapTarget / isNearEdge — edge-snapping and "am I near the end" checks.
    • resolveScrollAdjustment — combines the above: prefer holding the anchor item in place, fall back to snapping to the edge if the user was already near it and isn't mid-scroll.
  • Layout.ts — Adds an optional getScrollAnchorInfo() method to the base Layout class. Returning null (the default) disables anchoring entirely; a layout that wants edge-anchoring returns {edge, axis, threshold, isAnchorable}.
  • Virtualizer.ts — relayout() calls layout.getScrollAnchorInfo() and, if present, uses a
    ScrollAnchorTracker to capture an anchor before re-layout (captureBeforeLayout) and resolve
    a scroll adjustment after (resolveAfterLayout), adjusting the visibleRect when needed. The
    tracker owns the cross-pass state (whether the
    viewport has ever snapped to the anchored edge, whether visible items were still using
    estimated sizes as of the last pass, and whether the viewport was near the anchored edge)
  • ListLayout.ts — The concrete layout implementing this for lists:
    • New anchorTo: 'end' and scrollEndThreshold options.
    • getScrollAnchorInfo() — implements the Layout hook; only enabled for vertical orientation, and excludes loaders from being anchor candidates.
    • buildReversedCollection() — new bottom-up layout pass: computes each item's height in collection order, then places items from last to first so the newest item sits at the visual bottom while remaining first in DOM order (for correct screen-reader reading order).

To-Dos:

  1. Add tests for Thread (I know, very broad but it's seriously lacking right now)
  2. 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.

✅ 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

@rspbot

rspbot commented Jul 21, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jul 21, 2026

Copy link
Copy Markdown

// bottom and written to nodes[0] (first in DOM) for screen-reader accessibility.
let width = this.virtualizer!.size.width - this.padding * 2;
let nodes: LayoutNode[] = [];
let currentBottom = contentLength - this.padding;

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.

Right now, if the content does not fill the viewport, we do not anchor the content to the bottom. You can test this in the isEmpty story. Do we want this to be the default behavior or should it be opt-in?

}

// TODO: promote to protected once the reversed layout API is more stable and tested
private buildReversedCollection(): LayoutNode[] {

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.

I know there has been some discussion regarding the buildReverseCollection and updating it. Just given the timeline/release schedule, I think I'm probably going to defer that work to a follow-up because there's a couple of ideas I'd like to try out. buildReverseCollection is a private function and shouldn't have any public surface area so it should be fine to update after release if that so happens to be the case. But I definitely agree that it can be improved!

@rspbot

rspbot commented Jul 22, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jul 22, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jul 22, 2026

Copy link
Copy Markdown

protected shouldInvalidateEverything(invalidationContext: InvalidationContext<O>): boolean {
// Invalidate cache if the size of the collection changed.
// In this case, we need to recalculate the entire layout.
// Invalidate cache if the cross-axis size of the collection changed (e.g. width, for a

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.

Encountered a bug while scrolling up in the Popover Chat story and then typing in the prompt field snapped the view back to the bottom.

This was because the prompt field is a flex sibling of Thread, so it resizing changes Thread's viewport height. ListLayout treated any viewport resize as a reason to clear its cached row heights, falling back to rough estimates. That deflated total could shrink to match the viewport, making the scroll-anchor logic think the user was at the bottom.

To fix this, we only clear cached row heights on a cross-axis resize (width, for a vertical list) because only that kind of resize can actually change how rows wrap. A pure height resize now just re-lays-out with existing cached heights.

Also fixed TableLayout, which relied on the old blanket signal to know when to recompute column widths.

@rspbot

rspbot commented Jul 22, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jul 23, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jul 24, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jul 24, 2026

Copy link
Copy Markdown
## API Changes

react-aria-components

/react-aria-components:TableLoadMoreItem

 TableLoadMoreItem {
   children?: ReactNode
   className?: string = 'react-aria-TableLoadMoreItem'
+  direction?: 'start' | 'end' = 'end'
   isLoading?: boolean
   onLoadMore?: () => any
   render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, undefined>
   scrollOffset?: number = 1
 }

/react-aria-components:TableLayout

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

/react-aria-components:Virtualizer

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

/react-aria-components:ListLayout

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

/react-aria-components:WaterfallLayout

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

/react-aria-components:Layout

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

/react-aria-components:GridLayout

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

/react-aria-components:TableLoadMoreItemProps

 TableLoadMoreItemProps {
   children?: ReactNode
   className?: string = 'react-aria-TableLoadMoreItem'
+  direction?: 'start' | 'end' = 'end'
   isLoading?: boolean
   onLoadMore?: () => any
   render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, undefined>
   scrollOffset?: number = 1
 }

/react-aria-components:VirtualizerProps

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

@react-aria/utils

/@react-aria/utils:LoadMoreSentinelProps

 LoadMoreSentinelProps {
   collection: Collection<any>
+  direction?: 'start' | 'end' = 'end'
   onLoadMore?: () => any
   scrollOffset?: number = 1
 }

@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:PromptField

 PromptField {
   acceptedAttachmentTypes?: Array<string>
   attachments?: Array<PromptFieldAttachment>
   children: React.ReactNode
   defaultAttachments?: Array<PromptFieldAttachment>
   defaultValue?: TokenFieldValue
-  hideDisclaimer?: boolean
   isGenerating?: boolean
   onAddAttachments?: (Array<PromptFieldAttachment>) => void
   onAttachmentsChange?: (Array<PromptFieldAttachment>) => void
   onChange?: (TokenFieldValue) => void
   onStop?: () => void
   onSubmit?: (TokenFieldValue, Array<PromptFieldAttachment>) => void
   styles?: StyleString
   value?: TokenFieldValue
   variant?: 'balanced' | 'prominent' | 'subtle'
 }

/@react-spectrum/ai:PromptFieldVoiceButton

-PromptFieldVoiceButton {
-  isDisabled?: boolean
-  lang?: string
-  onError?: (VoiceInputErrorCode) => void
-}

/@react-spectrum/ai:Thread

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

/@react-spectrum/ai:PromptFieldProps

 PromptFieldProps {
   acceptedAttachmentTypes?: Array<string>
   attachments?: Array<PromptFieldAttachment>
   children: React.ReactNode
   defaultAttachments?: Array<PromptFieldAttachment>
   defaultValue?: TokenFieldValue
-  hideDisclaimer?: boolean
   isGenerating?: boolean
   onAddAttachments?: (Array<PromptFieldAttachment>) => void
   onAttachmentsChange?: (Array<PromptFieldAttachment>) => void
   onChange?: (TokenFieldValue) => void
   onStop?: () => void
   onSubmit?: (TokenFieldValue, Array<PromptFieldAttachment>) => void
   styles?: StyleString
   value?: TokenFieldValue
   variant?: 'balanced' | 'prominent' | 'subtle'
 }

/@react-spectrum/ai:PromptFieldVoiceButtonProps

-PromptFieldVoiceButtonProps {
-  isDisabled?: boolean
-  lang?: string
-  onError?: (VoiceInputErrorCode) => void
-}

/@react-spectrum/ai:ThreadProps

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

/@react-spectrum/ai:ThreadLoadMoreItem

+ThreadLoadMoreItem {
+  children?: ReactNode
+  className?: string = 'react-aria-GridListLoadMoreItem'
+  isLoading?: boolean
+  onLoadMore?: () => any
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, undefined>
+  scrollOffset?: number = 1
+  style?: CSSProperties
+}

/@react-spectrum/ai:ThreadLoadMoreItemProps

+ThreadLoadMoreItemProps {
+  children?: ReactNode
+  className?: string = 'react-aria-GridListLoadMoreItem'
+  isLoading?: boolean
+  onLoadMore?: () => any
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, undefined>
+  scrollOffset?: number = 1
+  style?: CSSProperties
+}

@react-spectrum/card

/@react-spectrum/card:GalleryLayout

 GalleryLayout <T> {
+  UNSTABLE_getScrollAnchorInfo: (CardViewLayoutOptions) => ScrollAnchorInfo | null
   _distributeWidths: (Array<number>) => boolean
   _findClosest: (Rect, Rect) => LayoutInfo | null
   _findClosestLayoutInfo: (Rect, Rect) => LayoutInfo | null
   buildCollection: () => void
   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
   isVisible: (LayoutInfo, Rect, boolean) => boolean
   itemPadding: number
   layoutType: string
   margin: number
   scale: Scale
   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> {
+  UNSTABLE_getScrollAnchorInfo: (CardViewLayoutOptions) => ScrollAnchorInfo | null
   _findClosest: (Rect, Rect) => LayoutInfo | null
   _findClosestLayoutInfo: (Rect, Rect) => LayoutInfo | null
   buildChild: (Node<T>, number, number) => LayoutInfo
   buildCollection: () => void
   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
   isVisible: (LayoutInfo, Rect, boolean) => boolean
   itemPadding: number
   layoutType: string
   margin: number
   scale: Scale
   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> {
+  UNSTABLE_getScrollAnchorInfo: (CardViewLayoutOptions) => ScrollAnchorInfo | null
   _findClosest: (Rect, Rect) => LayoutInfo | null
   _findClosestLayoutInfo: (Rect, Rect) => LayoutInfo | null
   buildCollection: (InvalidationContext) => void
   collection: GridCollection<T>
   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
   isVisible: (LayoutInfo, Rect, boolean) => boolean
   layoutType: string
   margin: number
   scale: Scale
   shouldInvalidate: (Rect, Rect) => boolean
   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> {
+  UNSTABLE_getScrollAnchorInfo: (GridLayoutOptions) => ScrollAnchorInfo | null
   getContentSize: () => Size
   getDropTargetFromPoint: (number, number, (DropTarget) => boolean) => DropTarget
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getLayoutInfo: (Key) => LayoutInfo | null
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (GridLayoutOptions, GridLayoutOptions) => boolean
   update: (InvalidationContext<GridLayoutOptions>) => void
   updateItemSize: (Key, Size) => boolean
   virtualizer: Virtualizer<{}, any> | null
 }

/@react-stately/layout:ListLayout

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

/@react-stately/layout:TableLayout

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

/@react-stately/layout:WaterfallLayout

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

@react-stately/virtualizer

/@react-stately/virtualizer:Layout

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

/@react-stately/virtualizer:InvalidationContext

 InvalidationContext <O = any> {
   contentChanged?: boolean
+  heightChanged?: boolean
   itemSizeChanged?: boolean
   layoutOptions?: O
   layoutOptionsChanged?: boolean
   offsetChanged?: boolean
   sizeChanged?: boolean
+  widthChanged?: boolean
 }

@rspbot

rspbot commented Jul 24, 2026

Copy link
Copy Markdown

Agent Skills Changes

Modified (10)
Install

React Spectrum S2:

npx skills add https://d1pzu54gtk2aed.cloudfront.net/pr/7ffb16cf0325f69eb54ad1227e22d763daa46696/

React Aria:

npx skills add https://d5iwopk28bdhl.cloudfront.net/pr/7ffb16cf0325f69eb54ad1227e22d763daa46696/

@yihuiliao

Copy link
Copy Markdown
Member Author

moved the changes in ListLayout into the ai package and reduced the changes that reach the public surface area. when that wasn't possible, i used UNSTABLE to flag that API is subject to change. since it's hard to view the actual changes to ListLayout now that it's a new file, you can check the diff here: 042f4d5...ed9b12a

@LFDanLu LFDanLu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Behavior looked good to me in the storybook, just a couple of comments

/**
* Whether to observe each item's size with a ResizeObserver and re-measure when it changes.
*/
shouldObserveItemSize?: boolean;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this be made UNSTABLE as well?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

just remember that any of these we mark as UNSTABLE will live forever, they can only be deprecated, not removed

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.

i think this is something we're generally okay to have so maybe doesn't need unstable. we could debate name tho

this.warnIfReversedHorizontal();
}

private warnIfReversedHorizontal(): void {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

looks like this is leftover from the move to the AI components package?

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.

oh oops will update

invalidationContext.sizeChanged ||
this.columnsChanged(newCollection, this.lastCollection)
) {
} else if (this.columnsChanged(newCollection, this.lastCollection)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

what prompted this change? I assume TableLayout wasn't supposed to be modified?

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.

oh i think i messed up something when i was trying to move things over but if you're curious as to why there were table layout changes see #10258 (comment)

return null;
}
let threshold = layoutOptions?.scrollEndThreshold ?? this.scrollEndThreshold;
return {edge: 'end', axis: 'y', threshold, isAnchorable: isLoaderAnchorable};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

future looking (and def not applicable this early on in implementation), but I wonder if this will end up needing to be customizable to exclude more than just loaders from being set as anchors (aka sticky items)

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.

i imagine that it probably will

Comment on lines +885 to +896
// Store the real measured height and signal a relayout. Unlike the normal path, we don't
// adjust validRect/requestedRect here. In a bottom-up layout, each item's absolute y
// depends on contentLength, which requires summing all item heights first.
if (this.anchorTo === 'end' && this.orientation === 'vertical') {
if (layoutInfo.rect[heightProperty] !== size[heightProperty]) {
let newLayoutInfo = layoutInfo.copy();
newLayoutInfo.rect[heightProperty] = size[heightProperty];
newLayoutInfo.estimatedSize = false;
layoutNode.layoutInfo = newLayoutInfo;
this.layoutNodes.set(key, layoutNode);
return true;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

so this means if a item farther down the list resizes in the "reversed" orientation, we signal a relayout and can't do any of the optimization below for keeping some of the "valid" item heights?

* by a sliver (e.g. 1px, essentially scrolled out of view) can still "win" the anchor
* tie-break over a substantially visible item.
*/
const MIN_ANCHOR_OVERLAP = 4;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

wonder if this should be customizable? Where did this value come from?

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.

just an arbitrary value. i could def see it being customizable but seems fine for our use case for right now

Comment on lines +29 to +30
/** Which axis `edge` refers to — 'y' for vertical lists, 'x' for horizontal. */
axis: 'x' | 'y';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

correct me if I'm wrong, but from my understanding we currently don't actually use x axis just yet, just vertical in ListLayout right? Thinking whether or not this needs to be narrowed temporarily for now

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

oh I guess its internal only, should be fine then

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.

6 participants