Skip to content

feat: reverse virtualizer#10258

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

feat: reverse virtualizer#10258
yihuiliao wants to merge 52 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. 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. 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

@rspbot

rspbot commented Jul 15, 2026

Copy link
Copy Markdown

@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.

some initial comments, will follow up on the core changes later

);
});

// TODO: do we want UNSTABLE_focusOnEntry on ThreadProps or do we just want to set it on Thread for users so they don't have to do it themselves?

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.

I think for now we set it for them, we can open this up later

* @default 100
*/
scrollEndThreshold?: number;
anchorTo?: 'end';

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.

in a similar vein, do we want to not expose this prop for now since we only support "end" | undefined? That means it will always be virtualized as well which seems fine for now

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.

yeah that seems fine to me

Comment on lines +220 to +223
* The maximum distance in px from the bottom of the content for the
* viewport to be considered "near the end". While near the end, appended content and streaming
* size changes will keep the viewport pinned to the latest output.
*

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.

It seems like if you aren't at the absolute bottom that we keep the scroll position the same, maybe this description is out of date?

EDIT: may have been some kind of lag between when it calculated it was greater than 100 from the bottom, sometimes it feels like the scroll to bottom button appears a bit late. Not a huge deal

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.

Also, maybe it shouldn't auto scroll if the user's focus is on a message in the thread (might be weird to shift things out from under then even if said message might still be persisted if it scrolls out of view due to a new message streaming in)?

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.

are you saying it shouldn't autoscroll to the bottom if the user's focus is on a message that's within the threshold?

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.

yep

return {messages, isLoadingMore, handleLoadMore, hasMore};
}

export function AsyncLoadingChat() {

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.

I noticed that sometimes the thread "jumps downwards" when new items load via scrolling upwards, not 100% consistently reproducible nor is it specific to 1st/2nd/etc load

@yihuiliao yihuiliao Jul 15, 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.

when you say jump downwards, do you mean it's jumping to the bottom or is it just shifting slightly? there's was a separate issue where if you were loading new items but still within the scrollEndThreshold it would scroll you to the bottom so i want to make sure that's not happening.

otherwise, i think it probably something to do with how we're calculating the space when there is/isn't a spinner...

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.

sorry, I meant shifting slightly haha, almost like the snap behavior but its weird that it would happen when scrolling up then

let state = useContext(ListStateContext)!;
let {isVirtualized} = useContext(CollectionRendererContext);
let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props;
let {isLoading, onLoadMore, scrollOffset, direction, ...otherProps} = props;

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.

maybe these new props/layout options/etc should be hidden from RAC users for now? We can expose them when we move thread/chat to RAC IMO

@snowystinger snowystinger 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.

Comments from testing

Go to path=/story/ai-chat--virtualized-streaming-chat
Tab into the chat, and up arrow to the top (oldest) message
Tab + Tab to the text area
Shift + Tab back to the chat, it will lose that focus was on the old message and will instead jump to the most recent. I think it should remember where the focus was.

It's a little annoying that I have to Tab 2x to get to the text input. I kinda of wish tab would skip the chat entirely and then I'd Shift+Tab to get to it. autoFocus would work some of the time.

We definitely need more tests. I'd start by writing tests for the various requirements that have been reporting in testing sessions. That way we don't regress on those.

We'll call out the items.reverse update in the release notes?

updateSize();
}
});
}, [layoutInfo?.estimatedSize, updateSize]);

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.

Can you provide more info about this change? seems like a big change and I'd like to know why it's safe

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 the old effect had no dep array so it fired on every render but it only actually did anything when the estimatedSize was still true. Once an item got measured, estimatedSize would flip to false and the effect would do nothing on every render after that. So in reality, it wasn't running as often as it looks. Now we just explicitly run this effect only when estimatedSize, key/virtualizer change. If something needs to be remeasured, then they should use the shouldObserveItemSize prop rather than relying on checking on every render.

}

let resizeObserver = new ResizeObserver(() => {
updateSizeEvent();

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 we only call this if the resize was in the vertical direction? or do we want to include width in this as well?

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 probably just vertical direction for now. not sure if there's a good use case for width at this time and something that could be added later

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.

ok, right now it's doing it for both, you'll want to add some logic to filter out the width only changes

Comment thread packages/@react-spectrum/ai/src/Chat.tsx

// because column reversed scrollTop=0 is the bottom and the scrollTop goes negative as you move up
let nearBottom = el.scrollTop > -100;
let nearBottom =

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.

Out of curiosity, did we try doing flex column-reverse for the virtualized container? that should automatically scroll to the bottom and the scrollTop should become negative as you go upwards

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 don't think we could use flex-direction: column reverse for the virtualized container bc the virtualizer bypasses the flex layout and absolutely positions every item

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Items position themselves with respect to their relative ancestor, which is our 'presentation' wrapper, no? The wrapper itself can take part in flex layout, so this should work - I remember having experimented with row-reverse a while back.

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.

ohh okay sorry, took a bit for me to understand but i think i know what you mean now. do you remember if you were able to make it work?

i gave it a try, hoping it would simplify some of the scroll bottom stuff, but the issue is that the scrollview code is written where it expects scrollTop to be > 0 but with column-reverse, the scrollTop would have negative values. so what i'm experiencing is that just some items are "missing". i think fixing it in ScrollView wouldn't be too bad so that it would work for this specific case but for a more complete fix, we'd have to introduce the concept of "reverse" into ScrollView, and that would have implications in many other areas. so not sure if that's something i want to add to the scope of this PR

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.

Thanks, yes, that was what I meant.

Hmm ScrollView is really only used inside Virtualizer, so should have the same impact and I would hope that it simplifies the changes needed in Virtualizer. If you're not finding that to be the case though, I'm ok with continuing on the current route.

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.

okay so considering something like this: switch reversed items to bottom: Npx instead of top: Npx, and iterate backward. Then position is just a running sum (currentBottom -= height) — no need to know total length upfront, same shape as forward's prefix sum, just mirrored.

We'd also want flex-direction: column-reverse on the scroll container so scrollTop becomes bottom-relative, which keeps requestedRect comparisons consistent. That gets us a single-pass algorithm that's similar to buildCollection.

that might help to simplify the algorithm but gonna work prioritize updating the scroll anchoring stuff first since i think this is fine for now

// buildNode — y is unknown here, so calling it would cache items at the wrong position.
let itemHeights = itemNodes.map(node => {
let cached = this.layoutNodes.get(node.key);
return cached && !cached.layoutInfo.estimatedSize

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.

safer to check null instead of falsy since !0 is true?

}
}

let contentLength = itemHeights.reduce(

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.

Comment on lines +843 to +844
// positions from scratch. Items are placed bottom-up, so each item's y depends on the
// heights of every item below it, making incremental invalidation impossible.

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.

I don't quite follow this.
Items placed top-down also depend on the height of every item above it, so it should be the same?

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.

in top-down layout, if item N changes size, you already know where it sits and you know that everything above item N is still valid bc we are always anchored at y=0. that's a guarantee.

however, in a bottom-up approach, we need the sum of every item's height before we can place any single item, since each y is computed relative to the bottom of that total. then if item N changes size, the stack of the items changes and the y position of all the items below item N also change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Couldn't this be avoided if we used "bottom" inset in virtualizer item instead of top?

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.

Yeah, that was where my mind was going as well. Then things "grow" upwards, which is the reverse of the top-down where things "grow" downwards.

@yihuiliao yihuiliao Jul 17, 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.

yeah that's a good point, i didn't really consider that but i'd like to give it a try. we'd need to add "bottom" awareness to LayoutInfo (or something so useVirtualizerItem knows that we want to reverse it). might be worth it but a larger architectural change.

can take some time to think more about the actual implementation

}

private relayout(context: InvalidationContext = {}) {
let isAnchoredToEnd = this._isAnchoredToEnd;

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.

given how big this function has become, maybe a summary at the top of how it works would be more useful than all the interwoven comments. not saying those aren't useful, but I am missing some context it feels like

Comment thread packages/react-stately/src/virtualizer/Virtualizer.ts Outdated
}
}

// Adjusts scroll position after content changes in a reversed layout.

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.

This logic all came from v2's CollectionView?

@devongovett

Copy link
Copy Markdown
Member

The new shouldObserveItemSize prop should fix #9519 right?

@devongovett devongovett 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.

Overall nice work! I'm not sure about isReversed and scrollEndOffset leaking into the core virtualizer itself as they seem somewhat layout-specific. Perhaps we can simplify the anchoring logic a little and avoid the need for those?

}}
scrollEndThreshold={scrollEndThreshold}
shouldObserveItemSize>
<GridList

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.

Did we mean to reuse gridlist above? I see it's re-created here?

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.

yeah it's just a matter of some the extra props (bc of virtualized vs non-virtualized), i can consolidate it

</GridList>
);

if (anchorTo === 'end') {

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.

Seems like they are both anchored to the end (the above has column-reverse), so really this prop is just controlling whether it's virtualized or not?

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.

yeah it just toggles virtualization for Chat but we could remove it. i just remember we talked about offering both a virtualized and non-virtualized chat so we'd need some way for people to choose unless we just want to stick to a virtualized chat experience.

}

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

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.

In theory, instead of re-implementing this, you could call the regular buildCollection(), have that build all the children and calculate the content height, and then loop over its output to reverse the y positions. Did you try something like that?

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.

Yeah I didn't try that but thinking it through, I think this line of code in buildCollection() would be problematic.

if (
  node.type === 'item' &&
  offset + rowHeight < this.requestedRect[offsetProperty] &&
  !this.isValid(node, offset)
) {
  offset += rowHeight;
  continue;
}

Basically in the first pass where we calculate the content height (before we reverse the y) we need each item's real position to decide whether to skip it by comparing it to the requestedRect but that isn't known until the loop is finished. So we would end up skipping the nodes that we actually want to render.

That said, I think there's a version of your idea that could work: switch reversed items to bottom: Npx instead of top: Npx, and iterate backward. Then position is just a running sum (currentBottom -= height) — no need to know total length upfront, same shape as forward's prefix sum, just mirrored.

We'd also want flex-direction: column-reverse on the scroll container so scrollTop becomes bottom-relative, which keeps requestedRect comparisons consistent. That gets us a single-pass algorithm that's similar to buildCollection.

return new LayoutInfo('dropIndicator', target.key + ':' + target.dropPosition, rect);
}

private assertReversedCollectionSupported(nodes: Node<T>[]) {

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.

I assume this is temporary until we add support for those? I think we probably will have sections in ai chat at some point - I've seen designs with groupings by date for example.

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.

Yeah I just wanted to limit the scope and see how this would work with very straightforward collections but ideally would be removed at some point.

// Two things can increase content height, and they need different handling.
// Old messages prepended at the top push existing items down (y increases) — we must adjust
// scroll to compensate so the user's view doesn't jump.
// New messages appended at the bottom leave existing items in place (y unchanged) — no adjustment needed.

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.

Some of this seems a bit specific to chat...

}
if (layoutInfo && layoutInfo.rect.area > 0 && layoutInfo.rect.intersects(visibleRect)) {
let corner = layoutInfo.rect.getCornerInRect(visibleRect) ?? 'topLeft';
// Force top corners: bottom corners on a clipped item can drift if the item resizes.

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.

Are we sure this is right? I think we want the corner that is actually in view. If this returns that the bottom left corner is in view, that means the top left is out of view. After layout, we want the same amount of the bottom edge to be visible as before. If we switch to top left (which is out of view), then offset will be negative. If at the same time the item resized, it would end up that more or less of that item would become visible in the viewport. What was the issue here?

return null;
}
let visibleRect = this.visibleRect;
let adjustment = finalInfo.rect[anchor.corner].y - visibleRect.y - anchor.offset;

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.

We should probably also handle adjustment in the x direction as well

contentHeightDelta: number
): boolean {
// Old messages prepended above viewport → existing items shift down.
if (anchorShiftedDown) {

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 case is this handling that _restoreScrollAnchor doesn't already handle?

// of the viewport, and records its key and distance from the viewport top.
// Top corners are always used. Returns null if the layout is not reversed.
private _getScrollAnchor(): ScrollAnchor | null {
if (!this._isAnchoredToEnd) {

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.

Why only when anchored to the end? Seems like scroll anchoring could be useful in either direction. Agree that it should be behind an option, but perhaps we make it a little more generic.

);
return true;
}
} else if (wasNearBottom && !this._isScrolling && itemSizeChanged) {

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.

same for all of these cases actually. seems like the general scroll anchoring should handle this too


// TODO: promote to protected once the reversed layout API is more stable and tested
private buildReversedCollection(): LayoutNode[] {
let collectionNodes = toArray(this.virtualizer!.collection, node => node.type !== 'content');

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.

I realize I don't have much ground to stand on here since I'm pretty sure I did the same in buildCollection 😅 and it might not be a big deal in the grand scheme of things, but might be nice to optimize this so we don't have to walk through the whole collection multiple time every time this runs(aka this + filter + looping through later).

I remembered that @snowystinger mentioned in #8349 (comment) that we can use https://caniuse.com/?search=Iterator.prototype.filter, seems well supported now

no need to address here in this PR IMO

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.

ah interesting. i'm hoping i can improve the algorithm for this so we can make it one pass but i have to do some other stuff in order to get that to work but can def take a look

@rspbot

rspbot commented Jul 17, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jul 18, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jul 20, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jul 20, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jul 20, 2026

Copy link
Copy Markdown
## API Changes

react-aria-components

/react-aria-components:GridListLoadMoreItem

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

/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> {
   constructor: (TableLayoutProps) => void
   getContentSize: () => Size
   getDropTargetFromPoint: (number, number, (DropTarget) => boolean) => DropTarget | null
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getLayoutInfo: (Key) => LayoutInfo | null
+  getScrollAnchorInfo: (ListLayoutOptions) => {
+    edge: 'start' | 'end'
+  axis: 'x' | 'y'
+  threshold: number
+  isAnchorable?: (LayoutInfo) => boolean
+} | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (TableLayoutProps, TableLayoutProps) => boolean
   update: (InvalidationContext<TableLayoutProps>) => void
   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> {
   constructor: (ListLayoutOptions) => void
   getContentSize: () => Size
   getDropTargetFromPoint: (number, number, (DropTarget) => boolean) => DropTarget | null
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getLayoutInfo: (Key) => LayoutInfo | null
+  getScrollAnchorInfo: (ListLayoutOptions) => {
+    edge: 'start' | 'end'
+  axis: 'x' | 'y'
+  threshold: number
+  isAnchorable?: (LayoutInfo) => boolean
+} | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (ListLayoutOptions, ListLayoutOptions) => boolean
   update: (InvalidationContext<ListLayoutOptions>) => void
   virtualizer: Virtualizer<{}, any> | null
 }

/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
+  getScrollAnchorInfo: (WaterfallLayoutOptions) => {
+    edge: 'start' | 'end'
+  axis: 'x' | 'y'
+  threshold: number
+  isAnchorable?: (LayoutInfo) => boolean
+} | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (WaterfallLayoutOptions, WaterfallLayoutOptions) => boolean
   update: (InvalidationContext<WaterfallLayoutOptions>) => void
   virtualizer: Virtualizer<{}, any> | null
 }

/react-aria-components:Layout

 Layout <O = any, T extends {} = Node<any>> {
   getContentSize: () => Size
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getLayoutInfo: (Key) => LayoutInfo | null
+  getScrollAnchorInfo: (O) => {
+    edge: 'start' | 'end'
+  axis: 'x' | 'y'
+  threshold: number
+  isAnchorable?: (LayoutInfo) => boolean
+} | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (O, O) => boolean
   update: (InvalidationContext<O>) => void
   virtualizer: Virtualizer<{}, any> | null
 }

/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
+  getScrollAnchorInfo: (GridLayoutOptions) => {
+    edge: 'start' | 'end'
+  axis: 'x' | 'y'
+  threshold: number
+  isAnchorable?: (LayoutInfo) => boolean
+} | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (GridLayoutOptions, GridLayoutOptions) => boolean
   update: (InvalidationContext<GridLayoutOptions>) => void
   useLayoutOptions: () => GridLayoutOptions
   virtualizer: Virtualizer<{}, any> | null
 }

/react-aria-components:GridListLoadMoreItemProps

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

/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-components:ListLayoutOptions

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

/react-aria-components:TableLayoutProps

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

@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: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: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: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> {
   _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
+  getScrollAnchorInfo: (CardViewLayoutOptions) => {
+    edge: 'start' | 'end'
+  axis: 'x' | 'y'
+  threshold: number
+  isAnchorable?: (LayoutInfo) => boolean
+} | null
   getVisibleLayoutInfos: (Rect, any) => Array<LayoutInfo>
   isLoading: boolean
   isVisible: (LayoutInfo, Rect, boolean) => boolean
   itemPadding: number
   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> {
   _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
+  getScrollAnchorInfo: (CardViewLayoutOptions) => {
+    edge: 'start' | 'end'
+  axis: 'x' | 'y'
+  threshold: number
+  isAnchorable?: (LayoutInfo) => boolean
+} | null
   getVisibleLayoutInfos: (Rect, any) => Array<LayoutInfo>
   isLoading: boolean
   isVisible: (LayoutInfo, Rect, boolean) => boolean
   itemPadding: number
   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> {
   _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
+  getScrollAnchorInfo: (CardViewLayoutOptions) => {
+    edge: 'start' | 'end'
+  axis: 'x' | 'y'
+  threshold: number
+  isAnchorable?: (LayoutInfo) => boolean
+} | null
   getVisibleLayoutInfos: (Rect, any) => Array<LayoutInfo>
   isLoading: boolean
   isVisible: (LayoutInfo, Rect, boolean) => boolean
   layoutType: string
   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> {
   getContentSize: () => Size
   getDropTargetFromPoint: (number, number, (DropTarget) => boolean) => DropTarget
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getLayoutInfo: (Key) => LayoutInfo | null
+  getScrollAnchorInfo: (GridLayoutOptions) => {
+    edge: 'start' | 'end'
+  axis: 'x' | 'y'
+  threshold: number
+  isAnchorable?: (LayoutInfo) => boolean
+} | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (GridLayoutOptions, GridLayoutOptions) => boolean
   update: (InvalidationContext<GridLayoutOptions>) => void
   virtualizer: Virtualizer<{}, any> | null
 }

/@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
+  getScrollAnchorInfo: (ListLayoutOptions) => {
+    edge: 'start' | 'end'
+  axis: 'x' | 'y'
+  threshold: number
+  isAnchorable?: (LayoutInfo) => boolean
+} | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (ListLayoutOptions, ListLayoutOptions) => boolean
   update: (InvalidationContext<ListLayoutOptions>) => void
   virtualizer: Virtualizer<{}, any> | null
 }

/@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
+  getScrollAnchorInfo: (ListLayoutOptions) => {
+    edge: 'start' | 'end'
+  axis: 'x' | 'y'
+  threshold: number
+  isAnchorable?: (LayoutInfo) => boolean
+} | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (TableLayoutProps, TableLayoutProps) => boolean
   update: (InvalidationContext<TableLayoutProps>) => void
   virtualizer: Virtualizer<{}, any> | null
 }

/@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
+  getScrollAnchorInfo: (WaterfallLayoutOptions) => {
+    edge: 'start' | 'end'
+  axis: 'x' | 'y'
+  threshold: number
+  isAnchorable?: (LayoutInfo) => boolean
+} | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (WaterfallLayoutOptions, WaterfallLayoutOptions) => boolean
   update: (InvalidationContext<WaterfallLayoutOptions>) => void
   virtualizer: Virtualizer<{}, any> | null
 }

/@react-stately/layout:ListLayoutOptions

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

/@react-stately/layout:TableLayoutProps

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

@react-stately/virtualizer

/@react-stately/virtualizer:Layout

 Layout <O = any, T extends {} = Node<any>> {
   getContentSize: () => Size
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
   getLayoutInfo: (Key) => LayoutInfo | null
+  getScrollAnchorInfo: (O) => {
+    edge: 'start' | 'end'
+  axis: 'x' | 'y'
+  threshold: number
+  isAnchorable?: (LayoutInfo) => boolean
+} | null
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (O, O) => boolean
   update: (InvalidationContext<O>) => void
   virtualizer: Virtualizer<{}, any> | null
 }

@rspbot

rspbot commented Jul 20, 2026

Copy link
Copy Markdown

Agent Skills Changes

Modified (6)
Install

React Spectrum S2:

npx skills add https://d1pzu54gtk2aed.cloudfront.net/pr/2409b8838db2e82aa65bcb4d13aa10feef7a8615/

React Aria:

npx skills add https://d5iwopk28bdhl.cloudfront.net/pr/2409b8838db2e82aa65bcb4d13aa10feef7a8615/

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