feat: reverse virtualizer#10258
Conversation
|
Build successful! 🎉 |
|
Build successful! 🎉 |
|
Build successful! 🎉 |
|
Build successful! 🎉 |
| // 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; |
There was a problem hiding this comment.
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[] { |
There was a problem hiding this comment.
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!
|
Build successful! 🎉 |
|
Build successful! 🎉 |
|
Build successful! 🎉 |
| 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 |
There was a problem hiding this comment.
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.
|
Build successful! 🎉 |
|
Build successful! 🎉 |
|
Build successful! 🎉 |
## 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
} |
|
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
left a comment
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
Should this be made UNSTABLE as well?
There was a problem hiding this comment.
just remember that any of these we mark as UNSTABLE will live forever, they can only be deprecated, not removed
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
looks like this is leftover from the move to the AI components package?
| invalidationContext.sizeChanged || | ||
| this.columnsChanged(newCollection, this.lastCollection) | ||
| ) { | ||
| } else if (this.columnsChanged(newCollection, this.lastCollection)) { |
There was a problem hiding this comment.
what prompted this change? I assume TableLayout wasn't supposed to be modified?
There was a problem hiding this comment.
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}; |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
i imagine that it probably will
| // 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; | ||
| } |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
wonder if this should be customizable? Where did this value come from?
There was a problem hiding this comment.
just an arbitrary value. i could def see it being customizable but seems fine for our use case for right now
| /** Which axis `edge` refers to — 'y' for vertical lists, 'x' for horizontal. */ | ||
| axis: 'x' | 'y'; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
oh I guess its internal only, should be fine then
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:
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 optionalgetScrollAnchorInfo()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() callslayout.getScrollAnchorInfo()and, if present, uses aScrollAnchorTracker to capture an anchor before re-layout (
captureBeforeLayout) and resolvea scroll adjustment after (
resolveAfterLayout), adjusting the visibleRect when needed. Thetracker 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:anchorTo: 'end'andscrollEndThresholdoptions.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:
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.✅ 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: