feat: reverse virtualizer#10258
Conversation
|
Build successful! 🎉 |
|
Build successful! 🎉 |
|
Build successful! 🎉 |
LFDanLu
left a comment
There was a problem hiding this comment.
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? |
There was a problem hiding this comment.
I think for now we set it for them, we can open this up later
| * @default 100 | ||
| */ | ||
| scrollEndThreshold?: number; | ||
| anchorTo?: 'end'; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
yeah that seems fine to me
| * 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. | ||
| * |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)?
There was a problem hiding this comment.
are you saying it shouldn't autoscroll to the bottom if the user's focus is on a message that's within the threshold?
| return {messages, isLoadingMore, handleLoadMore, hasMore}; | ||
| } | ||
|
|
||
| export function AsyncLoadingChat() { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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...
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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]); |
There was a problem hiding this comment.
Can you provide more info about this change? seems like a big change and I'd like to know why it's safe
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
should we only call this if the resize was in the vertical direction? or do we want to include width in this as well?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
ok, right now it's doing it for both, you'll want to add some logic to filter out the width only changes
|
|
||
| // because column reversed scrollTop=0 is the bottom and the scrollTop goes negative as you move up | ||
| let nearBottom = el.scrollTop > -100; | ||
| let nearBottom = |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
safer to check null instead of falsy since !0 is true?
| } | ||
| } | ||
|
|
||
| let contentLength = itemHeights.reduce( |
There was a problem hiding this comment.
this should be moveable up into the same loop as https://github.com/adobe/react-spectrum/pull/10258/changes#diff-a917d19ee4c93b5484ec2deedd190a194b2289ce19950b680047679cc1c1febeR489
| // 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. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Couldn't this be avoided if we used "bottom" inset in virtualizer item instead of top?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
| } | ||
| } | ||
|
|
||
| // Adjusts scroll position after content changes in a reversed layout. |
There was a problem hiding this comment.
This logic all came from v2's CollectionView?
|
The new |
devongovett
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Did we mean to reuse gridlist above? I see it's re-created here?
There was a problem hiding this comment.
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') { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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[] { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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>[]) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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'); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
|
Build successful! 🎉 |
|
Build successful! 🎉 |
|
Build successful! 🎉 |
|
Build successful! 🎉 |
## 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
} |
Agent Skills ChangesModified (6)
InstallReact Spectrum S2: React Aria: |
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:
Changes:
ListLayout— newanchorTo: 'end'option; addsbuildReversedCollection()which positions items bottom-up, and anisReversed()method to signal this to the virtualizer.Layout— baseisReversed()method (returns false) so Virtualizer has a way to ask Layout if it is reversedisReversed(). Adds_getScrollAnchor,_restoreScrollAnchor, and_applyReverseAnchorScrollhelpers.shouldObserveItemSizeprop attaches a ResizeObserver to each item's children so dynamic content (e.g. streaming text) triggers re-layout.shouldObserveItemSizeandscrollEndThresholdthrough to the state layer.To-Dos:
Follow-ups:
Threadcurrently derivesisNearBottomfrom live DOM scroll values inhandleScroll, while the virtualizer deriveswasNearBottomfrom its pre-layoutvisibleRect/contentSizesnapshot 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.onNearEndChangeor similar, using the same snapshot it uses for anchored scrolling. ThenThreadScrollButtoncould consume that instead of duplicating the threshold calculation from the DOM.Known Bugs:
scrollEndThresholdof 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:
📝 Test Instructions:
Streaming Chatis the non-virtualized Thread story. Test to make sure that behavior has stayed all the same.Virtualized Streaming Chatis the virtualized Thread story. You'll want to compare this behavior with the non-virtualized (keyboard focus, announcements, scrolling behavior).Empty Chatis 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: