From 4745be92f1e60a0649574f88f187d2e29a14f737 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:46:38 -0700 Subject: [PATCH 1/5] feat: reverse virtualizer (#10258) * add resize observer to virtualized items * update chat stories * update chat component with reverse virtualizer * support reverse virtualization in list layout * change isReversed to anchorTo end * consolidate props/code * fix layout from calling setVisibleRect * format * reverse items in Thread, update reverseBuildCollection * add scroll anchor to chat * remove scroll anchoring logic from list layout * update chat stories * cleanup from removing scroll anchoring logic from list layout * listlayout cleanup * add scroll anchoring * separate out scroll context * fix anchor reserve when submitting key for scroll anchoring * fix tests * remove key based scroll anchoring * fix format * fix lint * fix type issue * cleanup, fix non virtualized chat scrollToEnd button * edit height of chat story * code cleanup * fix formatting * hanle reverse in list layout * fix streaming * add/cleanup comments * fix shift tab * add support for async loading * fix formatting * fix loader y computation * fix bug with snapping to bottom with loading older messages * fix lint * fix lint?? * add more browser tests for chat * add reverse gridlist story * simplify code, update comment * fix lint? * don't force top corners when anchoring reversed virtualizer scroll position * make scroll anchoring more generic, pull out layout speicifc stuff from virtualizer * fix formatting * make loaders inline * update comments * update comment * fix shifting when scrolling up * cleanup from review comments * fix lint? * fix formatting * update tests * fix formatting fr * move virtualizer scroll anchoring into scrollanchortracker * fix formatting * skip tests due to flakiness * add scroll anchor tests * fix formatting * add dev warning for horizontal layouts * strip out nonvirtualized chat * skip browser tests in ci? * cleanup * fix lint? * don't clear cache row heights on height only viewport resize * add missing imports from merge * fix lint * move listlayout to ai package, reduce public surface area * finish cleanup * remove changes to tablelayout * remove warning * updates based on feedback --------- Co-authored-by: Daniel Lu --- packages/@react-spectrum/ai/exports/index.ts | 17 +- packages/@react-spectrum/ai/src/Chat.tsx | 154 ++- packages/@react-spectrum/ai/src/ListLayout.ts | 1111 +++++++++++++++++ .../ai/stories/Chat.stories.tsx | 632 ++++++++-- .../ai/test/Chat.browser.test.tsx | 574 ++++++++- .../@react-spectrum/ai/test/Chat.test.tsx | 68 +- .../react-aria-components/src/GridList.tsx | 11 +- .../react-aria-components/src/ListBox.tsx | 2 +- packages/react-aria-components/src/Tree.tsx | 4 +- .../react-aria-components/src/Virtualizer.tsx | 53 +- .../src/utils/useLoadMoreSentinel.ts | 17 +- .../src/virtualizer/VirtualizerItem.tsx | 6 +- .../src/virtualizer/useVirtualizerItem.ts | 45 +- .../react-stately/src/layout/ListLayout.ts | 4 +- .../react-stately/src/virtualizer/Layout.ts | 8 + .../src/virtualizer/ScrollAnchor.ts | 336 +++++ .../src/virtualizer/Virtualizer.ts | 55 +- .../react-stately/src/virtualizer/types.ts | 2 + .../test/virtualizer/ScrollAnchor.test.ts | 574 +++++++++ vitest.browser.config.ts | 3 +- 20 files changed, 3417 insertions(+), 259 deletions(-) create mode 100644 packages/@react-spectrum/ai/src/ListLayout.ts create mode 100644 packages/react-stately/src/virtualizer/ScrollAnchor.ts create mode 100644 packages/react-stately/test/virtualizer/ScrollAnchor.test.ts diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index 17c2a8408f5..e5c1154a495 100644 --- a/packages/@react-spectrum/ai/exports/index.ts +++ b/packages/@react-spectrum/ai/exports/index.ts @@ -18,7 +18,14 @@ export { PromptFieldVoiceButton } from '../src/PromptField'; export {ResponseStatus, ResponseStatusTitle, ResponseStatusPanel} from '../src/ResponseStatus'; -export {Chat, Thread, ThreadItem, ThreadScrollButton, PromptFocusContext} from '../src/Chat'; +export { + Chat, + Thread, + ThreadItem, + ThreadLoadMoreItem, + ThreadScrollButton, + PromptFocusContext +} from '../src/Chat'; export {TokenFieldValue} from 'react-aria-components/TokenField'; export {UserMessage} from '../src/UserMessage'; @@ -43,6 +50,12 @@ export type { ResponseStatusTitleProps, ResponseStatusPanelProps } from '../src/ResponseStatus'; -export type {ChatProps, ThreadProps, ThreadItemProps, ThreadScrollButtonProps} from '../src/Chat'; +export type { + ChatProps, + ThreadProps, + ThreadItemProps, + ThreadLoadMoreItemProps, + ThreadScrollButtonProps +} from '../src/Chat'; export type {TokenFieldValueOptions} from 'react-aria-components/TokenField'; export type {UserMessageProps} from '../src/UserMessage'; diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index ec6bef5761f..d17c593513b 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -12,34 +12,49 @@ import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; import {ButtonContext} from 'react-aria-components/Button'; +import { + CollectionRendererContext, + createLeafComponent +} from 'react-aria-components/CollectionBuilder'; import { createContext, + ForwardedRef, forwardRef, ReactNode, RefObject, useCallback, useContext, useEffect, + useMemo, useRef, useState } from 'react'; import {DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; -import {DOMRef, forwardRefType} from '@react-types/shared'; +import {DOMRef, forwardRefType, Node} from '@react-types/shared'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; import {focusRing, style, StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import { GridList, GridListItem, GridListItemProps, + GridListLoadMoreItemProps, GridListProps } from 'react-aria-components/GridList'; +import {inertValue} from 'react-aria/private/utils/inertValue'; // @ts-ignore import intlMessages from '../intl/*.json'; +import {ListLayout} from './ListLayout'; +import {ListStateContext} from 'react-aria-components/ListBox'; +import {LoaderNode} from 'react-aria/private/collections/BaseCollection'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {useDOMRef} from './useDOMRef'; import {useEnterAnimation, useExitAnimation} from 'react-aria/private/utils/animation'; import {useFocusWithin} from 'react-aria/useFocusWithin'; import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; +import {useLoadMoreSentinel} from 'react-aria/private/utils/useLoadMoreSentinel'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; +import {useRenderProps} from 'react-aria-components/useRenderProps'; +import {Virtualizer} from 'react-aria-components/Virtualizer'; const scrollButtonWrapper = style({ opacity: { @@ -137,7 +152,7 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( }, {once: true} ); - el.scrollTo({top: 0, behavior: 'smooth'}); + el.scrollTo({top: el.scrollHeight - el.clientHeight, behavior: 'smooth'}); }, []); let [isNearBottom, setIsNearBottom] = useState(true); @@ -217,12 +232,20 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( export interface ThreadProps extends Pick< GridListProps, - 'items' | 'children' | 'UNSTABLE_focusOnEntry' | 'aria-label' | 'aria-labelledby' + 'items' | 'children' | 'aria-label' | 'aria-labelledby' > { /** * Spectrum-defined styles, returned by the `style()` macro. */ styles?: StyleString; + /** + * 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. + * + * @default 100 + */ + scrollEndThreshold?: number; } export function Thread(props: ThreadProps) { @@ -230,7 +253,7 @@ export function Thread(props: ThreadProps) { items, children, styles, - UNSTABLE_focusOnEntry, + scrollEndThreshold = 100, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby } = props; @@ -238,7 +261,6 @@ export function Thread(props: ThreadProps) { let {setIsNearBottom, setScrollElement} = useContext(InternalChatContext); let isNearBottomRef = useRef(true); let gridListRef = useRef(null); - let callbackRef = useCallback( (el: HTMLDivElement | null) => { gridListRef.current = el; @@ -253,45 +275,42 @@ export function Thread(props: ThreadProps) { return; } - // because column reversed scrollTop=0 is the bottom and the scrollTop goes negative as you move up - let nearBottom = el.scrollTop > -100; + let nearBottom = el.scrollTop >= el.scrollHeight - el.clientHeight - scrollEndThreshold; isNearBottomRef.current = nearBottom; setIsNearBottom(nearBottom); - }, [setIsNearBottom]); - - useEffect(() => { - // scrolls to bottom on first render cuz we initialize isNearBottomRef to true, - // otherwise handles scrolling new prompts/etc into view unless you are scrolled up above - // 100px - if (isNearBottomRef.current) { - requestAnimationFrame(() => { - if (gridListRef.current) { - gridListRef.current.scrollTop = 0; - } - }); - } - }, [items]); + }, [setIsNearBottom, scrollEndThreshold]); return ( - - {children} - + shouldObserveItemSize> + + {children} + + ); } @@ -401,3 +420,60 @@ export function ThreadItem(props: ThreadItemProps) { ); } + +export interface ThreadLoadMoreItemProps extends GridListLoadMoreItemProps {} + +// TODO: Reuse GridListLoadMoreItem instead when Thread component moves into RAC. +// Re-implementing here so we can avoid passing 'direction' to the LoadMore item +export const ThreadLoadMoreItem = createLeafComponent( + LoaderNode, + function GridListLoadingIndicator( + props: GridListLoadMoreItemProps, + ref: ForwardedRef, + item: Node + ) { + let state = useContext(ListStateContext)!; + let direction: 'start' | 'end' | undefined = 'start'; + let {isVirtualized} = useContext(CollectionRendererContext); + let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; + + let sentinelRef = useRef(null); + let memoedLoadMoreProps = useMemo( + () => ({onLoadMore, collection: state?.collection, scrollOffset, direction}), + [onLoadMore, scrollOffset, state?.collection, direction] + ); + useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); + + let renderProps = useRenderProps({ + ...otherProps, + id: undefined, + children: item.rendered, + defaultClassName: 'react-aria-GridListLoadingIndicator', + values: undefined + }); + // For now don't include aria-posinset and aria-setsize on loader since they aren't keyboard focusable + // Arguably shouldn't include them ever since it might be confusing to the user to include the loaders as part of the + // item count + + return ( + <> + {/* Alway render the sentinel. For now onus is on the user for styling when using flex + gap (this would introduce a gap even though it doesn't take room) */} + {/* @ts-ignore - compatibility with React < 19 */} +
+
+
+ {isLoading && renderProps.children && ( +
+
+ {renderProps.children} +
+
+ )} + + ); + } +); diff --git a/packages/@react-spectrum/ai/src/ListLayout.ts b/packages/@react-spectrum/ai/src/ListLayout.ts new file mode 100644 index 00000000000..f0dbca74df4 --- /dev/null +++ b/packages/@react-spectrum/ai/src/ListLayout.ts @@ -0,0 +1,1111 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + Collection, + DropTarget, + DropTargetDelegate, + ItemDropTarget, + Key, + Node, + Orientation +} from '@react-types/shared'; +import {getChildNodes} from 'react-stately/private/collections/getChildNodes'; +import { + InvalidationContext, + Layout, + LayoutInfo, + Rect, + Size +} from 'react-stately/useVirtualizerState'; + +const isLoaderAnchorable = (layoutInfo: LayoutInfo): boolean => layoutInfo.type !== 'loader'; + +export interface ScrollAnchorInfo { + /** Which edge of the content the viewport should stay anchored to. */ + edge: 'start' | 'end'; + /** Which axis `edge` refers to — 'y' for vertical lists, 'x' for horizontal. */ + axis: 'x' | 'y'; + /** Distance (px) from `edge` within which the viewport is considered "following" it. */ + threshold: number; + /** + * Optional classifier excluding structural/ephemeral layout infos (e.g. loaders) from being + * selected as the anchor. Defaults to allowing any layoutInfo. + */ + isAnchorable?: (layoutInfo: LayoutInfo) => boolean; +} + +export interface ListLayoutOptions { + /** + * Anchors the vertical list content to the end (bottom) of the viewport. When set to `'end'`, + * the viewport stays pinned to the latest content unless the user scrolls up. + */ + anchorTo?: 'end'; + /** + * The maximum distance in px from the anchored edge 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 `anchorTo`. + * + * @default 0 + */ + scrollEndThreshold?: number; + /** + * The primary orientation of the items. Usually this is the direction that the collection + * scrolls. + * + * @default 'vertical' + */ + orientation?: Orientation; + /** + * The fixed size of a row in px with respect to the applied orientation. + * + * @default 48 + */ + rowSize?: number; + /** + * The estimated size of a row in px with respect to the applied orientation, when row sizes are + * variable. + */ + estimatedRowSize?: number; + /** + * The fixed size of a section header in px with respect to the applied orientation. + * + * @default 48 + */ + headingSize?: number; + /** + * The estimated size of a section header in px with respect to the applied orientation, when + * heading sizes are variable. + */ + estimatedHeadingSize?: number; + /** + * The fixed size of a loader element in px with respect to the applied orientation. This loader + * is specifically for "load more" elements rendered when loading more rows at the root level or + * inside nested row/sections. + * + * @default 48 + */ + loaderSize?: number; + /** + * The thickness of the drop indicator. + * + * @default 2 + */ + dropIndicatorThickness?: number; + /** + * The gap between items. + * + * @default 0 + */ + gap?: number; + /** + * The padding around the list. + * + * @default 0 + */ + padding?: number; + /** + * The fixed height of a row in px. + * + * @deprecated Use `rowSize` instead. + * @default 48 + */ + rowHeight?: number; + /** + * The estimated height of a row, when row heights are variable. + * + * @deprecated Use `estimatedRowSize` instead. + */ + estimatedRowHeight?: number; + /** + * The fixed height of a section header in px. + * + * @deprecated Use `headingSize` instead. + * @default 48 + */ + headingHeight?: number; + /** + * The estimated height of a section header, when the height is variable. + * + * @deprecated Use `estimatedHeadingSize` instead. + */ + estimatedHeadingHeight?: number; + /** + * The fixed height of a loader element in px. This loader is specifically for "load more" + * elements rendered when loading more rows at the root level or inside nested row/sections. + * + * @deprecated Use `loaderSize` instead. + * @default 48 + */ + loaderHeight?: number; +} + +// A wrapper around LayoutInfo that supports hierarchy +export interface LayoutNode { + node?: Node; + layoutInfo: LayoutInfo; + children?: LayoutNode[]; + validRect: Rect; + index?: number; +} + +const DEFAULT_HEIGHT = 48; + +/** + * ListLayout is a virtualizer Layout implementation + * that arranges its items in a stack along its applied orientation. + * It supports both fixed and variable size items. + */ +export class ListLayout + extends Layout, O> + implements DropTargetDelegate +{ + protected rowSize: number | null; + protected orientation: Orientation; + protected estimatedRowSize: number | null; + protected headingSize: number | null; + protected estimatedHeadingSize: number | null; + protected loaderSize: number | null; + protected dropIndicatorThickness: number; + protected gap: number; + protected padding: number; + protected anchorTo: 'end' | undefined; + protected scrollEndThreshold: number; + protected layoutNodes: Map; + protected contentSize: Size; + protected lastCollection: Collection> | null; + protected rootNodes: LayoutNode[]; + private invalidateEverything: boolean; + /** The rectangle containing currently valid layout infos. */ + protected validRect: Rect; + /** The rectangle of requested layout infos so far. */ + protected requestedRect: Rect; + + /** + * Creates a new ListLayout with options. See the list of properties below for a description + * of the options that can be provided. + */ + constructor(options: ListLayoutOptions = {}) { + super(); + this.anchorTo = options.anchorTo; + this.scrollEndThreshold = options.scrollEndThreshold ?? 0; + this.rowSize = options?.rowSize ?? options?.rowHeight ?? null; + this.orientation = options.orientation ?? 'vertical'; + this.estimatedRowSize = options?.estimatedRowSize ?? options?.estimatedRowHeight ?? null; + this.headingSize = options?.headingSize ?? options?.headingHeight ?? null; + this.estimatedHeadingSize = + options?.estimatedHeadingSize ?? options?.estimatedHeadingHeight ?? null; + this.loaderSize = options?.loaderSize ?? options?.loaderHeight ?? null; + this.dropIndicatorThickness = options.dropIndicatorThickness || 2; + this.gap = options.gap || 0; + this.padding = options.padding || 0; + this.layoutNodes = new Map(); + this.rootNodes = []; + this.lastCollection = null; + this.invalidateEverything = false; + this.validRect = new Rect(); + this.requestedRect = new Rect(); + this.contentSize = new Size(); + this.warnIfReversedHorizontal(); + } + + private warnIfReversedHorizontal(): void { + if ( + this.anchorTo === 'end' && + this.orientation === 'horizontal' && + process.env.NODE_ENV !== 'production' + ) { + console.warn( + 'ListLayout: anchorTo="end" is only supported in vertical orientations and will be ignored in horizontal orientation.' + ); + } + } + + UNSTABLE_getScrollAnchorInfo(layoutOptions?: O): ScrollAnchorInfo | null { + let anchorTo = layoutOptions?.anchorTo ?? this.anchorTo; + let orientation = layoutOptions?.orientation ?? this.orientation; + // TODO: Reversed (anchorTo: 'end') layouts are only supported in vertical orientations (for now). + if (anchorTo !== 'end' || orientation === 'horizontal') { + return null; + } + let threshold = layoutOptions?.scrollEndThreshold ?? this.scrollEndThreshold; + return {edge: 'end', axis: 'y', threshold, isAnchorable: isLoaderAnchorable}; + } + + // Backward compatibility for subclassing. + protected get collection(): Collection> { + return this.virtualizer!.collection; + } + + /** @deprecated Use `rowSize` instead. */ + protected get rowHeight(): number | null { + return this.rowSize; + } + + /** @deprecated Use `estimatedRowSize` instead. */ + protected get estimatedRowHeight(): number | null { + return this.estimatedRowSize; + } + + /** @deprecated Use `headingSize` instead. */ + protected get headingHeight(): number | null { + return this.headingSize; + } + /** @deprecated Use `estimatedHeadingSize` instead. */ + protected get estimatedHeadingHeight(): number | null { + return this.estimatedHeadingSize; + } + + /** @deprecated Use `loaderSize` instead. */ + protected get loaderHeight(): number | null { + return this.loaderSize; + } + + getLayoutInfo(key: Key): LayoutInfo | null { + this.ensureLayoutInfo(key); + return this.layoutNodes.get(key)?.layoutInfo || null; + } + + getVisibleLayoutInfos(rect: Rect): LayoutInfo[] { + let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; + let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; + + // Adjust rect to keep number of visible rows consistent. + // (only if height > 1 or width > 1 for getDropTargetFromPoint) + if (rect[heightProperty] > 1) { + let rowHeight = (this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT) + this.gap; + // Clone only before mutating + rect = rect.copy(); + let offset = Math.floor(rect[offsetProperty] / rowHeight) * rowHeight; + let height = rect[heightProperty] + rect[offsetProperty] - offset; + rect[offsetProperty] = offset; + rect[heightProperty] = Math.ceil(height / rowHeight) * rowHeight; + } + + // If layout hasn't yet been done for the requested rect, union the + // new rect with the existing valid rect, and recompute. + this.layoutIfNeeded(rect); + + let res: LayoutInfo[] = []; + + let addNodes = (nodes: LayoutNode[]) => { + for (let node of nodes) { + if (this.isVisible(node, rect)) { + res.push(node.layoutInfo); + + if (node.children) { + addNodes(node.children); + } + } + } + }; + + addNodes(this.rootNodes); + return res; + } + + protected layoutIfNeeded(rect: Rect): void { + if (!this.lastCollection) { + return; + } + + if (!this.requestedRect.containsRect(rect)) { + this.requestedRect = this.requestedRect.union(rect); + this.rootNodes = this.buildCollection(); + } + + // Ensure all of the persisted keys are available. + for (let key of this.virtualizer!.persistedKeys) { + if (this.ensureLayoutInfo(key)) { + return; + } + } + } + + private ensureLayoutInfo(key: Key) { + // If the layout info wasn't found, it might be outside the bounds of the area that we've + // computed layout for so far. This can happen when accessing a random key, e.g pressing Home/End. + // Compute the full layout and try again. + if ( + !this.layoutNodes.has(key) && + this.requestedRect.area < this.contentSize.area && + this.lastCollection + ) { + this.requestedRect = new Rect(0, 0, Infinity, Infinity); + this.rootNodes = this.buildCollection(); + this.requestedRect = new Rect(0, 0, this.contentSize.width, this.contentSize.height); + return true; + } + + return false; + } + + protected isVisible(node: LayoutNode, rect: Rect): boolean { + return ( + node.layoutInfo.rect.intersects(rect) || + node.layoutInfo.isSticky || + node.layoutInfo.type === 'header' || + node.layoutInfo.type === 'loader' || + this.virtualizer!.isPersistedKey(node.layoutInfo.key) + ); + } + + protected shouldInvalidateEverything(invalidationContext: InvalidationContext): boolean { + // Invalidate cache if the cross-axis size of the collection changed (e.g. width, for a + // vertical list): that can change how items wrap, so cached row heights are no longer + // trustworthy. A change to only the main-axis size (e.g. height, for a vertical list) just + // means more or less of the list is visible, and doesn't affect any row's real height, so it + // shouldn't throw away the cache. + // Also invalidate if fixed sizes/gaps change. + let options = invalidationContext.layoutOptions; + let orientation = options?.orientation ?? this.orientation; + let crossAxisSizeChanged = + orientation === 'horizontal' + ? invalidationContext.heightChanged + : invalidationContext.widthChanged; + return ( + crossAxisSizeChanged || + this.rowSize !== (options?.rowSize ?? options?.rowHeight ?? this.rowSize) || + this.orientation !== (options?.orientation ?? this.orientation) || + this.anchorTo !== (options?.anchorTo ?? this.anchorTo) || + this.headingSize !== (options?.headingSize ?? options?.headingHeight ?? this.headingSize) || + this.loaderSize !== (options?.loaderSize ?? options?.loaderHeight ?? this.loaderSize) || + this.gap !== (options?.gap ?? this.gap) || + this.padding !== (options?.padding ?? this.padding) + ); + } + + shouldInvalidateLayoutOptions(newOptions: O, oldOptions: O): boolean { + return ( + (newOptions?.rowSize ?? newOptions?.rowHeight) !== + (oldOptions?.rowSize ?? oldOptions?.rowHeight) || + newOptions.orientation !== oldOptions.orientation || + newOptions.anchorTo !== oldOptions.anchorTo || + (newOptions?.estimatedRowSize ?? newOptions?.estimatedRowHeight) !== + (oldOptions?.estimatedRowSize ?? oldOptions?.estimatedRowHeight) || + (newOptions?.headingSize ?? newOptions?.headingHeight) !== + (oldOptions?.headingSize ?? oldOptions?.headingHeight) || + (newOptions?.estimatedHeadingSize ?? newOptions?.estimatedHeadingHeight) !== + (oldOptions?.estimatedHeadingSize ?? oldOptions?.estimatedHeadingHeight) || + (newOptions?.loaderSize ?? newOptions?.loaderHeight) !== + (oldOptions?.loaderSize ?? oldOptions?.loaderHeight) || + newOptions.dropIndicatorThickness !== oldOptions.dropIndicatorThickness || + newOptions.gap !== oldOptions.gap || + newOptions.padding !== oldOptions.padding || + newOptions.scrollEndThreshold !== oldOptions.scrollEndThreshold + ); + } + + update(invalidationContext: InvalidationContext): void { + let collection = this.virtualizer!.collection; + + // Reset valid rect if we will have to invalidate everything. + // Otherwise we can reuse cached layout infos outside the current visible rect. + this.invalidateEverything = this.shouldInvalidateEverything(invalidationContext); + if (this.invalidateEverything) { + this.requestedRect = this.virtualizer!.visibleRect.copy(); + this.layoutNodes.clear(); + } + + let options = invalidationContext.layoutOptions; + this.rowSize = options?.rowSize ?? options?.rowHeight ?? this.rowSize; + this.orientation = options?.orientation ?? this.orientation; + this.anchorTo = options?.anchorTo ?? this.anchorTo; + this.scrollEndThreshold = options?.scrollEndThreshold ?? this.scrollEndThreshold; + this.estimatedRowSize = + options?.estimatedRowSize ?? options?.estimatedRowHeight ?? this.estimatedRowSize; + this.headingSize = options?.headingSize ?? options?.headingHeight ?? this.headingSize; + this.estimatedHeadingSize = + options?.estimatedHeadingSize ?? options?.estimatedHeadingHeight ?? this.estimatedHeadingSize; + this.loaderSize = options?.loaderSize ?? options?.loaderHeight ?? this.loaderSize; + this.dropIndicatorThickness = options?.dropIndicatorThickness ?? this.dropIndicatorThickness; + this.gap = options?.gap ?? this.gap; + this.padding = options?.padding ?? this.padding; + this.warnIfReversedHorizontal(); + + this.rootNodes = this.buildCollection(); + + // Remove deleted layout nodes + if (this.lastCollection && collection !== this.lastCollection) { + for (let key of this.lastCollection.getKeys()) { + if (!collection.getItem(key)) { + let layoutNode = this.layoutNodes.get(key); + if (layoutNode) { + this.layoutNodes.delete(key); + } + } + } + } + + this.lastCollection = collection; + this.invalidateEverything = false; + this.validRect = this.requestedRect.copy(); + } + + protected buildCollection(offset: number = this.padding): LayoutNode[] { + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + return this.buildReversedCollection(); + } + + let collection = this.virtualizer!.collection; + let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; + let maxOffsetProperty = this.orientation === 'horizontal' ? 'maxX' : 'maxY'; + + // filter out content nodes since we don't want them to affect the height + // Tree specific for now, if we add content nodes to other collection items, we might need to reconsider this + let collectionNodes = toArray(collection, node => node.type !== 'content'); + let loaderNodes = collectionNodes.filter(node => node.type === 'loader'); + let nodes: LayoutNode[] = []; + let isEmptyOrLoading = collection?.size === 0; + if (isEmptyOrLoading) { + offset = 0; + } + + for (let node of collectionNodes) { + let rowHeight = (this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT) + this.gap; + // Skip rows before the valid rectangle unless they are already cached. + if ( + node.type === 'item' && + offset + rowHeight < this.requestedRect[offsetProperty] && + !this.isValid(node, offset) + ) { + offset += rowHeight; + continue; + } + + let layoutNode = + this.orientation === 'horizontal' + ? this.buildChild(node, offset, this.padding, null) + : this.buildChild(node, this.padding, offset, null); + offset = layoutNode.layoutInfo.rect[maxOffsetProperty] + this.gap; + nodes.push(layoutNode); + if (node.type === 'loader') { + let index = loaderNodes.indexOf(node); + loaderNodes.splice(index, 1); + } + + // Build each loader that exists in the collection that is outside the visible rect so that they are persisted + // at the proper estimated location. If the node.type is "section" then we don't do this shortcut since we have to + // build the sections to see how tall they are. + if ( + (node.type === 'item' || node.type === 'loader') && + offset > this.requestedRect[maxOffsetProperty] + ) { + let lastProcessedIndex = collectionNodes.indexOf(node); + for (let loaderNode of loaderNodes) { + let loaderNodeIndex = collectionNodes.indexOf(loaderNode); + // Subtract by an additional 1 since we've already added the current item's height to y + offset += (loaderNodeIndex - lastProcessedIndex - 1) * rowHeight; + let loader = + this.orientation === 'horizontal' + ? this.buildChild(loaderNode, offset, this.padding, null) + : this.buildChild(loaderNode, this.padding, offset, null); + nodes.push(loader); + offset = loader.layoutInfo.rect[maxOffsetProperty]; + lastProcessedIndex = loaderNodeIndex; + } + + // Account for the rest of the items after the last loader spinner, subtract by 1 since we've processed the current node's height already + offset += (collectionNodes.length - lastProcessedIndex - 1) * rowHeight; + break; + } + } + + offset = Math.max(offset - this.gap, 0); + offset += isEmptyOrLoading ? 0 : this.padding; + let contentLength = offset; + this.contentSize = + this.orientation === 'horizontal' + ? new Size(offset, this.virtualizer!.size.height) + : new Size(this.virtualizer!.size.width, contentLength); + + return nodes; + } + + // 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'); + this.assertReversedCollectionSupported(collectionNodes); + + // Height-only pass: walk collectionNodes once, in collection order, to determine every + // node's height (items and loaders alike) + let heights = new Map, number>(); + let loaderHeightIsEstimated = new Map, boolean>(); + let visibleCount = 0; + let sumHeights = 0; + let anyLoader = false; + for (let node of collectionNodes) { + if (node.type === 'loader') { + anyLoader = true; + let height = 0; + let estimated = false; + if (node.props.isLoading) { + let cached = this.layoutNodes.get(node.key); + if (cached && !cached.layoutInfo.estimatedSize && cached.layoutInfo.rect.height > 0) { + height = cached.layoutInfo.rect.height; + } else { + height = this.loaderSize ?? this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT; + estimated = this.loaderSize == null && this.rowSize == null; + } + } + // Not loading: the sentinel is 0px tall and doesn't occupy space. + heights.set(node, height); + loaderHeightIsEstimated.set(node, estimated); + if (height > 0) { + visibleCount++; + sumHeights += height; + } + } else { + let cached = this.layoutNodes.get(node.key); + let height = + cached && !cached.layoutInfo.estimatedSize + ? cached.layoutInfo.rect.height + : (this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT); + heights.set(node, height); + visibleCount++; + sumHeights += height; + } + } + + // Gap count only depends on the total number of visible slots (items + loaders with height > + // 0), not their arrangement, so this holds regardless of how items/loaders interleave. + let contentLength = sumHeights + Math.max(visibleCount - 1, 0) * this.gap; + if (visibleCount > 0 || anyLoader) { + contentLength += this.padding * 2; + } + + let contentHeight = Math.max(contentLength, this.virtualizer!.size.height); + this.contentSize = new Size(this.virtualizer!.size.width, contentHeight); + + // Iterate last → first so the last item in the collection (newest) is placed at the visual + // 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; + + for (let i = collectionNodes.length - 1; i >= 0; i--) { + let node = collectionNodes[i]; + let height = heights.get(node)!; + let layoutNode: LayoutNode; + + if (node.type === 'loader') { + const sentinelYOffset = i === 0 && height === 0 && visibleCount > 0 ? this.gap : 0; + let loaderY = currentBottom - height + sentinelYOffset; + let loaderNode = this.buildNode(node, this.padding, loaderY); + loaderNode.layoutInfo.rect.height = height; + loaderNode.layoutInfo.parentKey = null; + loaderNode.layoutInfo.allowOverflow = true; + if (node.props.isLoading) { + loaderNode.layoutInfo.estimatedSize = loaderHeightIsEstimated.get(node)!; + } + loaderNode.validRect = loaderNode.layoutInfo.rect.intersection(this.requestedRect); + this.layoutNodes.set(loaderNode.layoutInfo.key, loaderNode); + layoutNode = loaderNode; + currentBottom = loaderY - this.gap; + } else { + let y = currentBottom - height; + let cached = this.layoutNodes.get(node.key); + + if (cached && !cached.layoutInfo.estimatedSize) { + let newLayoutInfo = cached.layoutInfo.copy(); + newLayoutInfo.rect.y = y; + layoutNode = {layoutInfo: newLayoutInfo, validRect: new Rect(0, 0, 0, 0), children: []}; + } else { + let itemRect = new Rect(this.padding, y, width, height); + if (itemRect.intersects(this.requestedRect)) { + layoutNode = this.buildNode(node, this.padding, y); + } else { + let layoutInfo = new LayoutInfo(node.type, node.key, itemRect); + layoutInfo.estimatedSize = true; + layoutNode = {layoutInfo, validRect: new Rect(0, 0, 0, 0), children: [], node}; + } + } + + layoutNode.layoutInfo.parentKey = null; + layoutNode.layoutInfo.allowOverflow = true; + layoutNode.validRect = layoutNode.layoutInfo.rect.intersection(this.requestedRect); + this.layoutNodes.set(layoutNode.layoutInfo.key, layoutNode); + currentBottom = y - this.gap; + } + + nodes.push(layoutNode); + } + + return nodes; + } + + protected isValid(node: Node, offset: number): boolean { + let cached = this.layoutNodes.get(node.key); + let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; + return ( + !this.invalidateEverything && + !!cached && + cached.node === node && + offset === cached.layoutInfo.rect[offsetProperty] && + cached.layoutInfo.rect.intersects(this.validRect) && + cached.validRect.containsRect(cached.layoutInfo.rect.intersection(this.requestedRect)) + ); + } + + protected buildChild(node: Node, x: number, y: number, parentKey: Key | null): LayoutNode { + if (this.isValid(node, this.orientation === 'horizontal' ? x : y)) { + return this.layoutNodes.get(node.key)!; + } + + let layoutNode = this.buildNode(node, x, y); + + layoutNode.layoutInfo.parentKey = parentKey ?? null; + layoutNode.layoutInfo.allowOverflow = true; + this.layoutNodes.set(node.key, layoutNode); + return layoutNode; + } + + protected buildNode(node: Node, x: number, y: number): LayoutNode { + switch (node.type) { + case 'section': + return this.buildSection(node, x, y); + case 'item': + return this.buildItem(node, x, y); + case 'header': + return this.buildSectionHeader(node, x, y); + case 'loader': + return this.buildLoader(node, x, y); + case 'separator': + return this.buildItem(node, x, y); + default: + throw new Error('Unsupported node type: ' + node.type); + } + } + + protected buildLoader(node: Node, x: number, y: number): LayoutNode { + let rect = new Rect(x, y, this.padding, 0); + let layoutInfo = new LayoutInfo(node.type, node.key, rect); + + // Note that if the user provides isLoading to their sentinel during a case where they only want to render the emptyState, this will reserve + // room for the loader alongside rendering the emptyState + if (this.orientation === 'horizontal') { + rect.height = this.virtualizer!.contentSize.height - this.padding - y; + rect.width = node.props.isLoading + ? (this.loaderSize ?? this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT) + : 0; + } else { + rect.width = this.virtualizer!.contentSize.width - this.padding - x; + rect.height = node.props.isLoading + ? (this.loaderSize ?? this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT) + : 0; + } + + return { + layoutInfo, + validRect: rect.intersection(this.requestedRect) + }; + } + + protected buildSection(node: Node, x: number, y: number): LayoutNode { + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + throw new Error( + 'ListLayout with anchorTo="end" only supports flat root-level items and an optional root loader.' + ); + } + + let collection = this.virtualizer!.collection; + let width = this.virtualizer!.size.width - this.padding - x; + let height = this.virtualizer!.size.height - this.padding - y; + let rect = + this.orientation === 'horizontal' ? new Rect(x, y, 0, height) : new Rect(x, y, width, 0); + let layoutInfo = new LayoutInfo(node.type, node.key, rect); + + let offset = this.orientation === 'horizontal' ? x : y; + let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; + let maxOffsetProperty = this.orientation === 'horizontal' ? 'maxX' : 'maxY'; + let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; + + let skipped = 0; + let children: LayoutNode[] = []; + for (let child of getChildNodes(node, collection)) { + // skip if it is a content node, Tree specific for now, if we add content nodes to other collection items, we might need to reconsider this + if (child.type === 'content') { + continue; + } + + let rowHeight = (this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT) + this.gap; + + // Skip rows before the valid rectangle unless they are already cached. + if (offset + rowHeight < this.requestedRect[offsetProperty] && !this.isValid(node, offset)) { + offset += rowHeight; + skipped++; + continue; + } + + let layoutNode = + this.orientation === 'horizontal' + ? this.buildChild(child, offset, y, layoutInfo.key) + : this.buildChild(child, x, offset, layoutInfo.key); + offset = layoutNode.layoutInfo.rect[maxOffsetProperty] + this.gap; + children.push(layoutNode); + + if (offset > this.requestedRect[maxOffsetProperty]) { + // Estimate the remaining height for rows that we don't need to layout right now. + offset += + ([...getChildNodes(node, collection)].length - (children.length + skipped)) * rowHeight; + break; + } + } + + offset -= this.gap; + rect[heightProperty] = offset - (this.orientation === 'horizontal' ? x : y); + + return { + layoutInfo, + children, + validRect: layoutInfo.rect.intersection(this.requestedRect), + node + }; + } + + protected buildSectionHeader(node: Node, x: number, y: number): LayoutNode { + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + throw new Error( + 'ListLayout with anchorTo="end" only supports flat root-level items and an optional root loader.' + ); + } + + let widthProperty = this.orientation === 'horizontal' ? 'height' : 'width'; + let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; + let width = + this.virtualizer!.size[widthProperty] - + this.padding - + (this.orientation === 'horizontal' ? y : x); + let rectHeight = this.headingSize; + let isEstimated = false; + + // If no explicit height is available, use an estimated height. + if (rectHeight == null) { + // If a previous version of this layout info exists, reuse its height. + // Mark as estimated if the size of the overall virtualizer changed, + // or the content of the item changed. + let previousLayoutNode = this.layoutNodes.get(node.key); + let previousLayoutInfo = previousLayoutNode?.layoutInfo; + if (previousLayoutInfo) { + let curNode = this.virtualizer!.collection.getItem(node.key); + let lastNode = this.lastCollection ? this.lastCollection.getItem(node.key) : null; + rectHeight = previousLayoutNode!.layoutInfo.rect[heightProperty]; + isEstimated = + width !== previousLayoutInfo.rect[widthProperty] || + curNode !== lastNode || + previousLayoutInfo.estimatedSize; + } else { + rectHeight = node.rendered ? this.estimatedHeadingSize : 0; + isEstimated = true; + } + } + + if (rectHeight == null) { + rectHeight = DEFAULT_HEIGHT; + } + + let headerRect = + this.orientation === 'horizontal' + ? new Rect(x, y, rectHeight, width - y) + : new Rect(x, y, width - x, rectHeight); + let header = new LayoutInfo('header', node.key, headerRect); + header.estimatedSize = isEstimated; + return { + layoutInfo: header, + children: [], + validRect: header.rect.intersection(this.requestedRect), + node + }; + } + + protected buildItem(node: Node, x: number, y: number): LayoutNode { + let widthProperty = this.orientation === 'horizontal' ? 'height' : 'width'; + let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; + + let width = + this.virtualizer!.size[widthProperty] - + this.padding - + (this.orientation === 'horizontal' ? y : x); + let rectHeight = this.rowSize; + let isEstimated = false; + + // If no explicit height is available, use an estimated height. + if (rectHeight == null) { + // If a previous version of this layout info exists, reuse its height. + // Mark as estimated if the size of the overall virtualizer changed, + // or the content of the item changed. + let previousLayoutNode = this.layoutNodes.get(node.key); + if (previousLayoutNode) { + rectHeight = previousLayoutNode.layoutInfo.rect[heightProperty]; + isEstimated = + width !== previousLayoutNode.layoutInfo.rect[widthProperty] || + node !== previousLayoutNode.node || + previousLayoutNode.layoutInfo.estimatedSize; + } else { + rectHeight = this.estimatedRowSize; + isEstimated = true; + } + } + + if (rectHeight == null) { + rectHeight = DEFAULT_HEIGHT; + } + + let rect = + this.orientation === 'horizontal' + ? new Rect(x, y, rectHeight, width) + : new Rect(x, y, width, rectHeight); + let layoutInfo = new LayoutInfo(node.type, node.key, rect); + layoutInfo.estimatedSize = isEstimated; + return { + layoutInfo, + children: [], + validRect: layoutInfo.rect.intersection(this.requestedRect), + node + }; + } + + updateItemSize(key: Key, size: Size): boolean { + let layoutNode = this.layoutNodes.get(key); + // If no layoutInfo, item has been deleted/removed. + if (!layoutNode) { + return false; + } + + let collection = this.virtualizer!.collection; + let layoutInfo = layoutNode.layoutInfo; + let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; + let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; + layoutInfo.estimatedSize = false; + + // 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; + } + + return false; + } + + if (layoutInfo.rect[heightProperty] !== size[heightProperty]) { + // Copy layout info rather than mutating so that later caches are invalidated. + let newLayoutInfo = layoutInfo.copy(); + newLayoutInfo.rect[heightProperty] = size[heightProperty]; + layoutNode.layoutInfo = newLayoutInfo; + + // Items after this layoutInfo will need to be repositioned to account for the new height. + // Adjust the validRect so that only items above remain valid. + this.validRect[heightProperty] = Math.min( + this.validRect[heightProperty], + layoutInfo.rect[offsetProperty] - this.validRect[offsetProperty] + ); + + // The requestedRect also needs to be adjusted to account for the height difference. + if (layoutNode.node?.type === 'item') { + this.requestedRect[heightProperty] += + newLayoutInfo.rect[heightProperty] - layoutInfo.rect[heightProperty]; + } + + // Invalidate layout for this layout node and all parents + this.updateLayoutNode(key, layoutInfo, newLayoutInfo); + + let node = layoutInfo.parentKey != null ? collection.getItem(layoutInfo.parentKey) : null; + while (node) { + this.updateLayoutNode(node.key, layoutInfo, newLayoutInfo); + node = node.parentKey != null ? collection.getItem(node.parentKey) : null; + } + + return true; + } + + return false; + } + + private updateLayoutNode(key: Key, oldLayoutInfo: LayoutInfo, newLayoutInfo: LayoutInfo) { + let n = this.layoutNodes.get(key); + if (n) { + // Invalidate by intersecting the validRect of this node with the overall validRect. + n.validRect = n.validRect.intersection(this.validRect); + + // Replace layout info in LayoutNode + if (n.layoutInfo === oldLayoutInfo) { + n.layoutInfo = newLayoutInfo; + } + } + } + + getContentSize(): Size { + return this.contentSize; + } + + getDropTargetFromPoint( + x: number, + y: number, + isValidDropTarget: (target: DropTarget) => boolean + ): DropTarget | null { + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + throw new Error('Drag and drop is not supported for ListLayout with anchorTo="end".'); + } + + x += this.virtualizer!.visibleRect.x; + y += this.virtualizer!.visibleRect.y; + + // Find the closest item within on either side of the point using the gap width. + let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, Math.max(1, this.gap * 2)); + let candidates = this.getVisibleLayoutInfos(searchRect); + let key: Key | null = null; + let minDistance = Infinity; + for (let candidate of candidates) { + // Ignore items outside the search rect, e.g. persisted keys. + if (!candidate.rect.intersects(searchRect)) { + continue; + } + + let yDist = Math.abs(candidate.rect.y - y); + let maxYDist = Math.abs(candidate.rect.maxY - y); + let dist = Math.min(yDist, maxYDist); + if (dist < minDistance) { + minDistance = dist; + key = candidate.key; + } + } + + if (key == null || this.virtualizer!.collection.size === 0) { + return {type: 'root'}; + } + + let layoutInfo = this.getLayoutInfo(key); + if (!layoutInfo) { + return null; + } + + let rect = layoutInfo.rect; + let target: DropTarget = { + type: 'item', + key: layoutInfo.key, + dropPosition: 'on' + }; + + // If dropping on the item isn't accepted, try the target before or after depending on the y position. + // Otherwise, if dropping on the item is accepted, still try the before/after positions if within 10px + // of the top or bottom of the item. + if (!isValidDropTarget(target)) { + if (y <= rect.y + rect.height / 2 && isValidDropTarget({...target, dropPosition: 'before'})) { + target.dropPosition = 'before'; + } else if (isValidDropTarget({...target, dropPosition: 'after'})) { + target.dropPosition = 'after'; + } + } else if (y <= rect.y + 10 && isValidDropTarget({...target, dropPosition: 'before'})) { + target.dropPosition = 'before'; + } else if (y >= rect.maxY - 10 && isValidDropTarget({...target, dropPosition: 'after'})) { + target.dropPosition = 'after'; + } + + return target; + } + + getDropTargetLayoutInfo(target: ItemDropTarget): LayoutInfo { + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + throw new Error('Drag and drop is not supported for ListLayout with anchorTo="end".'); + } + + let layoutInfo = this.getLayoutInfo(target.key)!; + let rect: Rect; + if (target.dropPosition === 'before') { + rect = + this.orientation === 'horizontal' + ? new Rect( + Math.max(0, layoutInfo.rect.x - this.dropIndicatorThickness / 2), + layoutInfo.rect.y, + this.dropIndicatorThickness, + layoutInfo.rect.height + ) + : new Rect( + layoutInfo.rect.x, + Math.max(0, layoutInfo.rect.y - this.dropIndicatorThickness / 2), + layoutInfo.rect.width, + this.dropIndicatorThickness + ); + } else if (target.dropPosition === 'after') { + // Render after last visible descendant of the drop target. + let targetNode = this.collection.getItem(target.key); + if (targetNode) { + let targetLevel = targetNode.level ?? 0; + let currentKey = this.collection.getKeyAfter(target.key); + + while (currentKey != null) { + let node = this.collection.getItem(currentKey); + if (!node || node.level <= targetLevel) { + break; + } + + layoutInfo = this.getLayoutInfo(currentKey) || layoutInfo; + currentKey = this.collection.getKeyAfter(currentKey); + } + } + rect = + this.orientation === 'horizontal' + ? new Rect( + layoutInfo.rect.maxX - this.dropIndicatorThickness / 2, + layoutInfo.rect.y, + this.dropIndicatorThickness, + layoutInfo.rect.height + ) + : new Rect( + layoutInfo.rect.x, + layoutInfo.rect.maxY - this.dropIndicatorThickness / 2, + layoutInfo.rect.width, + this.dropIndicatorThickness + ); + } else { + rect = layoutInfo.rect; + } + + return new LayoutInfo('dropIndicator', target.key + ':' + target.dropPosition, rect); + } + + private assertReversedCollectionSupported(nodes: Node[]) { + for (let node of nodes) { + if (node.type !== 'item' && node.type !== 'loader' && node.type !== 'separator') { + throw new Error( + 'ListLayout with anchorTo="end" only supports flat root-level items and an optional root loader.' + ); + } + + if (node.parentKey != null || node.level > 0 || node.hasChildNodes) { + throw new Error( + 'ListLayout with anchorTo="end" only supports flat root-level items and an optional root loader.' + ); + } + } + } +} + +function toArray( + collection: Collection>, + predicate: (node: Node) => boolean +): Node[] { + const result: Node[] = []; + for (const node of collection) { + if (predicate(node)) { + result.push(node); + } + } + return result; +} diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index d3bc9040b06..29fa626b867 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -30,6 +30,7 @@ import { SourceListItem, Thread, ThreadItem, + ThreadLoadMoreItem, ThreadScrollButton, TokenFieldValue, UserMessage @@ -37,18 +38,17 @@ import { import {Chat} from '../src/Chat'; import ChatIcon from '@react-spectrum/s2/icons/Chat'; import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; +import {Collection} from 'react-aria-components'; import {Content} from '@react-spectrum/s2/Content'; import {DialogTrigger, Popover} from '@react-spectrum/s2/Popover'; -import {GridList} from 'react-aria-components'; import {Image} from '@react-spectrum/s2/Image'; -import {ListLayout} from 'react-stately/useVirtualizerState'; import {MenuItem} from '@react-spectrum/s2/Menu'; -import type {Meta, StoryObj} from '@storybook/react'; +import type {Meta} from '@storybook/react'; +import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; import {prose} from '../src/style/prose' with {type: 'macro'}; -import {ReactNode, useEffect, useRef, useState} from 'react'; +import {ReactNode, useCallback, useEffect, useRef, useState} from 'react'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {Text} from '@react-spectrum/s2/Text'; -import {Virtualizer} from 'react-aria-components/Virtualizer'; const meta: Meta = { component: Chat, @@ -59,7 +59,7 @@ const meta: Meta = { title: 'AI/Chat', decorators: [ Story => ( -
+
) @@ -67,13 +67,6 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; - -let dummyResponses = [ - "Sure! Here's a summary of the key points based on the assets you shared. The main themes revolve around brand consistency, audience engagement, and clear calls to action across all touchpoints.", - 'Great question. Based on the context provided, I recommend focusing on the narrative arc first, then layering in supporting visuals and data to reinforce the core message.', - "I've analyzed the content and identified three main opportunities: improving visual hierarchy, strengthening the headline, and adding a clearer value proposition in the opening section." -]; type Message = | {id: number; type: 'user' | 'system'; content: string} @@ -215,7 +208,7 @@ function CardMessage({ ); } -function StreamingChatRender() { +export function VirtualizedStreamingChat() { let [messages, setMessages] = useState( initialResponses as StreamingMessage[] ); @@ -391,18 +384,13 @@ function StreamingChatRender() { ]), (timestamp += 1000) ); - addTimeout( - () => - streamText( - 'Based on the assets you shared, I recommend focusing on the narrative arc first, then ' + - 'layering in supporting visuals and data to reinforce the core message. The main themes ' + - 'revolve around brand consistency, audience engagement, and clear calls to action.', - MOCK_SOURCES - ), - (timestamp += 500) - ); + let secondStreamContent = + 'Based on the assets you shared, I recommend focusing on the narrative arc first, then ' + + 'layering in supporting visuals and data to reinforce the core message. The main themes ' + + 'revolve around brand consistency, audience engagement, and clear calls to action.'; + addTimeout(() => streamText(secondStreamContent, MOCK_SOURCES), (timestamp += 500)); - let streamEndTimestamp = timestamp + 500; + let streamEndTimestamp = timestamp + (secondStreamContent.split(' ').length - 1) * 80 + 500; addTimeout(() => { setMessages(prev => [...prev, {id: nextId.current++, type: 'card', ...MOCK_CARD}]); }, streamEndTimestamp); @@ -490,14 +478,12 @@ function StreamingChatRender() {
@@ -521,8 +507,6 @@ function StreamingChatRender() { // (aka it would make sense to auto focus children here but not for a system message that has text and other focusable children) return ( @@ -623,97 +607,189 @@ function StreamingChatRender() { ); } -export const StreamingChat: Story = { - render: () => -}; +let DUMMY_RESPONSES = [ + "That's a great question! I'm here to help. Could you give me a bit more context so I can provide a more tailored response?", + 'Sure! Here is a quick summary: the key points are clarity, brevity, and relevance. Let me know if you want me to expand on any of these.', + "Interesting topic. Here's what I know: this area has been evolving rapidly, and there are a few different perspectives worth considering. Want me to dive deeper?", + "I've processed your message. Based on what you've shared, I'd suggest starting with a clear goal, then breaking it into smaller actionable steps. Does that help?", + 'Great point! I think the best approach here depends on your specific situation. Can you tell me more about your constraints or priorities?' +]; + +export function EmptyChat() { + let [messages, setMessages] = useState([]); + let nextId = useRef(0); + let [isGenerating, setGenerating] = useState(false); + let timeouts = useRef([]); -// Ignore this story, just here for local testing -export function VirtualizedChat() { - let [messages, setMessages] = useState(initialResponses); - let nextId = useRef(initialResponses.length); - let lastMessage = messages.at(-1); - let isPending = lastMessage?.type === 'status' && lastMessage.status === 'pending'; function handleSend(prompt: TokenFieldValue) { + setGenerating(true); setMessages(prev => [ ...prev, - {id: nextId.current++, type: 'user', content: prompt.toString()}, - {id: nextId.current++, type: 'status', status: 'pending'} + {id: nextId.current++, type: 'user', content: prompt.toString()} ]); - setTimeout(() => { - let response = dummyResponses[Math.floor(Math.random() * dummyResponses.length)]; + + let addTimeout = (callback: () => void, delay: number) => { + let timeout = setTimeout(callback, delay); + timeouts.current.push(timeout); + return timeout; + }; + + let response = DUMMY_RESPONSES[Math.floor(Math.random() * DUMMY_RESPONSES.length)]; + + addTimeout(() => { setMessages(prev => [ - ...prev.slice(0, -1), - {id: nextId.current++, type: 'system', content: response} + ...prev, + {id: nextId.current++, type: 'system', content: '', isStreaming: true} ]); - }, 1500); + let tokens = response.split(' '); + let accumulated = ''; + tokens.forEach((token, i) => { + addTimeout(() => { + accumulated += (i === 0 ? '' : ' ') + token; + let isLastToken = i === tokens.length - 1; + setMessages(prev => + prev.map(m => + m.type === 'system' && m.isStreaming + ? {...m, content: accumulated, isStreaming: !isLastToken} + : m + ) + ); + if (isLastToken) { + setGenerating(false); + } + }, i * 60); + }); + }, 600); } return (
- - +
- {msg => { - if (msg.type === 'user') { - return ( - - {msg.content} - - ); - } - if (msg.type === 'status') { - let isPending = msg.status === 'pending'; - let message = isPending ? 'Generating response' : 'Response generated'; - +
+ + + + + +
+ + {(msg: StreamingMessage) => { + if (msg.type === 'user') { + return ( + + {msg.content} + + ); + } + if (msg.type === 'status') { + let announcement = msg.isStreaming ? `${msg.label}…` : `${msg.label} complete`; + let title = msg.isStreaming ? `${msg.label}…` : msg.label; + return ( + + + {title} + + {msg.details && ( +

{msg.details}

+ )} +
+
+
+ ); + } + if (msg.type === 'card') { + return ( + + ); + } + if (msg.type === 'suggestions') { + return ( + + + {msg.suggestions.map((s, i) => ( + {s} + ))} + + + ); + } return ( - - - {message} - - + +
+

{msg.content || ''}

+
+ {!msg.isStreaming && } +
); - } - return ( - -
-

{msg.content}

-
- -
- ); - }} - - - -
- - + }} +
-
+ { + setGenerating(false); + timeouts.current.forEach(clearTimeout); + timeouts.current = []; + }}> +
+ + +
+
+
); } @@ -860,3 +936,357 @@ function SystemMessage({ ); } + +interface AsyncMessage { + id: number; + role: 'user' | 'assistant'; + content: string; +} + +const ALL_HISTORY: AsyncMessage[] = [ + { + id: 1, + role: 'user', + content: 'Hi, my laptop battery has been draining really quickly over the past few days.' + }, + { + id: 2, + role: 'assistant', + content: + "I'm sorry to hear that. I can help troubleshoot. Could you tell me what model of laptop you have and approximately how old it is?" + }, + { + id: 3, + role: 'user', + content: "It's a Dell XPS 13, about two years old." + }, + { + id: 4, + role: 'assistant', + content: + "Thanks. Have you noticed whether the battery drains even when you're doing light tasks like browsing the web, or only during heavier workloads?" + }, + { + id: 5, + role: 'user', + content: 'Mostly browsing, Slack, and Spotify. It used to last almost a full workday.' + }, + { + id: 6, + role: 'assistant', + content: + 'Understood. A sudden drop like that is worth investigating. Have you recently installed any new software or updated Windows?' + }, + { + id: 7, + role: 'user', + content: 'Windows installed an update last week, and I also downloaded DaVinci Resolve.' + }, + { + id: 8, + role: 'assistant', + content: + "Thanks. Sometimes Windows updates perform background tasks for a day or two, and creative software can install services that run in the background. Could you open Task Manager and tell me what's using the most CPU?" + }, + { + id: 9, + role: 'user', + content: + "Chrome is around 7%, Windows Modules Installer is around 14%, and Resolve isn't running." + }, + { + id: 10, + role: 'assistant', + content: + "That Windows process suggests update-related work is still happening. It should settle down once everything finishes. Let's also check your battery health." + }, + { + id: 11, + role: 'user', + content: 'How do I do that?' + }, + { + id: 12, + role: 'assistant', + content: + "Open Command Prompt and run `powercfg /batteryreport`. It will generate an HTML report showing your battery's design capacity and current full charge capacity." + }, + { + id: 13, + role: 'user', + content: + 'The report says the design capacity is 52,000 mWh and the full charge capacity is 39,100 mWh.' + }, + { + id: 14, + role: 'assistant', + content: + "Thanks. That means your battery is retaining about 75% of its original capacity. That's fairly typical after two years, although it will reduce runtime." + }, + { + id: 15, + role: 'user', + content: "Is that enough to explain why I'm only getting around 3 hours?" + }, + { + id: 16, + role: 'assistant', + content: + "Probably not by itself. A 25% reduction in capacity shouldn't usually cut runtime by more than half, so it's likely that background activity is contributing as well." + }, + { + id: 17, + role: 'user', + content: "What's the next thing I should check?" + }, + { + id: 18, + role: 'assistant', + content: + "I'd recommend checking the Battery Usage page in Windows Settings. It can show which apps have been consuming the most power over the last 24 hours." + }, + { + id: 19, + role: 'user', + content: 'Chrome is at 38%, Slack is 18%, and Screen is 22%.' + }, + { + id: 20, + role: 'assistant', + content: 'Those numbers look fairly normal. How bright do you typically keep your display?' + }, + { + id: 21, + role: 'user', + content: 'Usually around 90%. I work near a window.' + }, + { + id: 22, + role: 'assistant', + content: + 'Running at high brightness can have a noticeable impact on battery life. If possible, try lowering it to around 60–70% and see whether that improves things.' + }, + { + id: 23, + role: 'user', + content: "I'll try that tomorrow." + }, + { + id: 24, + role: 'assistant', + content: + "Great. I'd also suggest enabling Battery Saver when you're unplugged. It reduces background activity and can extend runtime." + }, + { + id: 25, + role: 'user', + content: "I don't think I've ever used Battery Saver." + }, + { + id: 26, + role: 'assistant', + content: + 'You can enable it from Quick Settings or configure it to turn on automatically below a certain battery percentage in Settings > System > Power & Battery.' + }, + { + id: 27, + role: 'user', + content: 'Would updating the BIOS help?' + }, + { + id: 28, + role: 'assistant', + content: + "It can. Manufacturers sometimes release BIOS and firmware updates that improve power management or battery performance. Since you have a Dell XPS, it's worth checking Dell SupportAssist or Dell's support website." + }, + { + id: 29, + role: 'user', + content: "I haven't updated the BIOS since I bought it." + }, + { + id: 30, + role: 'assistant', + content: + "In that case, I'd recommend checking for updates. Just make sure the laptop is plugged into power during the update process." + }, + { + id: 31, + role: 'user', + content: 'If none of this helps, should I replace the battery?' + }, + { + id: 32, + role: 'assistant', + content: + "If you've ruled out software causes and battery health continues to decline, replacing the battery is a reasonable next step. It should restore battery life much closer to its original level." + }, + { + id: 33, + role: 'user', + content: 'Does Dell usually sell replacement batteries?' + }, + { + id: 34, + role: 'assistant', + content: + "Yes. Dell offers genuine replacement batteries for many models. I'd recommend purchasing directly from Dell or an authorized reseller to ensure compatibility and quality." + }, + { + id: 35, + role: 'user', + content: 'Is replacing it something I can do myself?' + }, + { + id: 36, + role: 'assistant', + content: + "For many XPS models, it's a relatively straightforward repair if you're comfortable removing the bottom cover. Dell also provides service manuals with step-by-step instructions." + }, + { + id: 37, + role: 'user', + content: "Good to know. I'll try the software fixes first." + }, + { + id: 38, + role: 'assistant', + content: + 'That sounds like a good approach. Monitor battery life for a few days after the Windows background processes finish and after adjusting your settings.' + }, + { + id: 39, + role: 'user', + content: 'Thanks for walking me through everything!' + }, + { + id: 40, + role: 'assistant', + content: + "You're very welcome! If the battery life is still much lower than expected after trying these steps, feel free to come back with an updated battery report and I'd be happy to help you investigate further." + } +]; + +const PAGE_SIZE = 10; + +function renderAsyncMessage(msg: AsyncMessage) { + if (msg.role === 'user') { + return ( + + {msg.content} + + ); + } + return ( + + {msg.content} + + ); +} + +function useAsyncMessages() { + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [messages, setMessages] = useState(ALL_HISTORY.slice(-PAGE_SIZE)); + const [hasMore, setHasMore] = useState(ALL_HISTORY.length - PAGE_SIZE > 0); + const isLoadingRef = useRef(false); + const cursorRef = useRef(ALL_HISTORY.length - PAGE_SIZE); + + const handleLoadMore = useCallback(async () => { + if (isLoadingRef.current || cursorRef.current <= 0) { + return; + } + isLoadingRef.current = true; + setIsLoadingMore(true); + + await new Promise(r => setTimeout(r, 2000)); + + const nextCursor = Math.max(0, cursorRef.current - PAGE_SIZE); + const older = ALL_HISTORY.slice(nextCursor, cursorRef.current); + cursorRef.current = nextCursor; + setHasMore(nextCursor > 0); + + setMessages(prev => [...older, ...prev]); + setIsLoadingMore(false); + isLoadingRef.current = false; + }, []); + + return {messages, isLoadingMore, handleLoadMore, hasMore}; +} + +export function AsyncLoadingChat() { + const {messages, isLoadingMore, handleLoadMore, hasMore} = useAsyncMessages(); + + return ( +
+ +
+
+ + + + + +
+ + +
+ +
+
+ {renderAsyncMessage} +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/packages/@react-spectrum/ai/test/Chat.browser.test.tsx b/packages/@react-spectrum/ai/test/Chat.browser.test.tsx index 23154add82c..758546c0560 100644 --- a/packages/@react-spectrum/ai/test/Chat.browser.test.tsx +++ b/packages/@react-spectrum/ai/test/Chat.browser.test.tsx @@ -10,32 +10,199 @@ * governing permissions and limitations under the License. */ -import {Chat, Thread, ThreadItem} from '../src/Chat'; -import {describe, expect, it} from 'vitest'; -import React from 'react'; +import {Button, Collection} from 'react-aria-components'; +import {Chat, Thread, ThreadItem, ThreadLoadMoreItem, ThreadScrollButton} from '../src/Chat'; +import {describe, expect, it, vi} from 'vitest'; +import React, {useCallback, useRef, useState} from 'react'; import {render} from 'vitest-browser-react'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {userEvent} from 'vitest/browser'; +// Applied to Thread when it needs its own scroll viewport. +let scrollThreadStyles = style({ + flexGrow: 1, + overflowX: 'hidden', + overflowY: 'auto', + padding: 8, + scrollPadding: 8, + rowGap: 16 +}); + +// Applied to Chat when it must be the flex container for ScrollButton + Thread. +let chatFlexStyles = style({ + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + overflow: 'hidden' +}); + +// Message type for async loading tests — includes role for realistic rendering. interface Message { id: string; - text: string; + content: string; + role?: 'user' | 'assistant'; +} + +// Realistic conversation fixture ordered oldest → newest (24 messages). +// The first 4 serve as "older history" loaded on demand; the last 20 are the initial page +// for scroll-anchoring tests. For basic async tests, use CONVERSATION.slice(0, 6). +const CONVERSATION: Message[] = [ + {id: '1', role: 'user', content: 'Hey, can you tell me about cats?'}, + { + id: '2', + role: 'assistant', + content: + 'Of course! Cats are curious, intelligent mammals that have lived alongside humans for thousands of years. They are known for their agility, independence, and playful personalities.' + }, + {id: '3', role: 'user', content: 'Do cats see well in the dark?'}, + { + id: '4', + role: 'assistant', + content: + 'Yes! Cats have excellent night vision. Their eyes are adapted to low-light conditions, allowing them to see much better than humans at dusk and dawn.' + }, + {id: '5', role: 'user', content: 'Why do cats purr?'}, + { + id: '6', + role: 'assistant', + content: + 'Cats often purr when they are relaxed and happy, but they may also purr to comfort themselves when stressed or recovering from an injury.' + }, + {id: '7', role: 'user', content: 'What should I feed a cat?'}, + { + id: '8', + role: 'assistant', + content: + 'Cats are obligate carnivores, so they need a diet rich in animal protein. A complete, balanced commercial cat food is usually the best choice.' + }, + {id: '9', role: 'user', content: 'Why do cats knead blankets?'}, + { + id: '10', + role: 'assistant', + content: + 'Kneading is a behavior kittens use while nursing, and many adult cats continue doing it because it is comforting and helps them relax.' + }, + {id: '11', role: 'user', content: 'Why do cats sleep so much?'}, + { + id: '12', + role: 'assistant', + content: + 'Cats typically sleep 12–16 hours a day. As natural predators, they conserve energy between bursts of activity.' + }, + {id: '13', role: 'user', content: 'Can cats learn tricks?'}, + { + id: '14', + role: 'assistant', + content: + 'Absolutely! With patience and positive reinforcement, many cats can learn tricks like sit, high five, come when called, and even walk on a harness.' + }, + {id: '15', role: 'user', content: 'Why do cats bring me toys?'}, + { + id: '16', + role: 'assistant', + content: + 'Many cats bring toys to their owners as a form of play, attention-seeking, or because they are expressing natural hunting instincts.' + }, + {id: '17', role: 'user', content: 'Why do cats flick their tails?'}, + { + id: '18', + role: 'assistant', + content: + 'A cat’s tail can reveal its mood. Slow swishing may show curiosity, rapid flicking can indicate irritation, and an upright tail often signals confidence or friendliness.' + }, + {id: '19', role: 'user', content: 'What are some popular cat breeds?'}, + { + id: '20', + role: 'assistant', + content: + 'Popular breeds include the Maine Coon, Siamese, Ragdoll, Bengal, British Shorthair, Persian, and Sphynx. Each has its own unique appearance and personality.' + }, + {id: '21', role: 'user', content: 'Do cats like climbing?'}, + { + id: '22', + role: 'assistant', + content: + 'Yes! Most cats love climbing because it gives them a safe vantage point to observe their surroundings. Cat trees and shelves are great enrichment.' + }, + {id: '23', role: 'user', content: 'How can I keep my cat entertained?'}, + { + id: '24', + role: 'assistant', + content: + 'Interactive toys, puzzle feeders, scratching posts, climbing structures, and short daily play sessions with wand toys are excellent ways to keep cats mentally and physically stimulated.' + } +]; + +// Async virtualized thread that starts with the most recent `pageSize` messages and +// prepends older batches on demand. Pass `delay` to slow down loads in tests that +// need to observe the in-flight loading state. +function AsyncVirtualizedThread({ + messages, + pageSize = 5, + delay = 20 +}: { + messages: Message[]; + pageSize?: number; + delay?: number; +}) { + let [visible, setVisible] = useState(() => messages.slice(-pageSize)); + let [isLoading, setIsLoading] = useState(false); + let [hasMore, setHasMore] = useState(messages.length > pageSize); + let isLoadingRef = useRef(false); + let cursorRef = useRef(messages.length - pageSize); + + let handleLoadMore = useCallback(async () => { + if (isLoadingRef.current || cursorRef.current <= 0) { + return; + } + isLoadingRef.current = true; + setIsLoading(true); + await new Promise(r => setTimeout(r, delay)); + let nextCursor = Math.max(0, cursorRef.current - pageSize); + let batch = messages.slice(nextCursor, cursorRef.current); + cursorRef.current = nextCursor; + setHasMore(nextCursor > 0); + setVisible(prev => [...batch, ...prev]); + setIsLoading(false); + isLoadingRef.current = false; + }, [messages, pageSize, delay]); + + return ( +
+ + + + Loading… + + + {item => {item.content}} + + + +
+ ); } const describeOrSkip = parseInt(React.version, 10) < 19 ? describe.skip : describe; +const itOrSkip = process.env.CI === 'true' ? it.skip : it; + describeOrSkip('Chat browser', () => { describe('spatial navigation', () => { // This test is flaky in Firefox. Skipping for now. - it.skip('navigates between items in spatial order via arrow keys', async () => { + itOrSkip('navigates between items in spatial order via arrow keys', async () => { let messages: Message[] = [ - {id: '1', text: 'First message'}, - {id: '2', text: 'Second message'}, - {id: '3', text: 'Third message'} + {id: '1', content: 'First message'}, + {id: '2', content: 'Second message'}, + {id: '3', content: 'Third message'} ]; let {container} = await render( - - {(item: Message) => {item.text}} + + {(item: Message) => {item.content}} ); @@ -56,4 +223,391 @@ describeOrSkip('Chat browser', () => { expect(rows[0]).toHaveTextContent('Third message'); }); }); + + describe('virtualized Thread – focus behavior', () => { + it('focuses the newest (last) item when tabbing in', async () => { + let {container} = await render( +
+ + + + {(item: Message) => { + return {item.content}; + }} + + +
+ ); + + let gridlist = container.querySelector('[role="grid"]') as HTMLElement; + + // Wait for virtualizer to render rows before tabbing. + await vi.waitFor( + () => { + expect(gridlist.querySelectorAll('[role="row"]').length).toBeGreaterThan(0); + }, + {timeout: 2000} + ); + + // Click the button first so focus has a starting point, then tab into the grid. + // Without an initial focused element, userEvent.tab() may not move focus reliably. + let input = container.querySelector('input') as HTMLElement; + await userEvent.click(input); + expect(input).toHaveFocus(); + await userEvent.tab(); + + let rows = gridlist.querySelectorAll('[role="row"]'); + await vi.waitFor( + () => { + // rows[0] = World (newest) — DOM-first in the reversed virtualizer layout. + expect(rows[0]).toHaveFocus(); + }, + {timeout: 2000} + ); + expect(rows[0]).toHaveTextContent('World'); + }); + + // Flaky in Firefox. Skip for now in CI. + itOrSkip('navigates between items with arrow keys', async () => { + let {container} = await render( +
+ + + + {(item: Message) => {item.content}} + + + +
+ ); + + let gridlist = container.querySelector('[role="grid"]') as HTMLElement; + + await vi.waitFor( + () => { + expect(gridlist.querySelectorAll('[role="row"]').length).toBeGreaterThan(0); + }, + {timeout: 2000} + ); + + // Click the button first so focus has a starting point, then tab into the grid. + // Without an initial focused element, userEvent.tab() may not move focus reliably. + let input = container.querySelector('input') as HTMLElement; + await userEvent.click(input); + expect(input).toHaveFocus(); + await userEvent.tab(); + + let rows = gridlist.querySelectorAll('[role="row"]'); + await vi.waitFor(() => expect(rows[0]).toHaveFocus(), {timeout: 2000}); + + // ArrowUp from World (visual bottom) → Hello (visual top) = rows[1] in DOM. + await userEvent.keyboard('{ArrowUp}'); + await vi.waitFor(() => expect(rows[1]).toHaveFocus(), {timeout: 1000}); + expect(rows[1]).toHaveTextContent('Hello'); + + // ArrowDown from Hello → World = rows[0]. + await userEvent.keyboard('{ArrowDown}'); + await vi.waitFor(() => expect(rows[0]).toHaveFocus(), {timeout: 1000}); + expect(rows[0]).toHaveTextContent('World'); + }); + + // Flaky in Firefox. Skip for now in CI. + // We might change this behavior in the future so that it doesn't always re-focus the newest item when re-tabbing in. Instead, it will focus the previous focused item (if there was one). + itOrSkip('always re-focuses the newest item when re-tabbing in', async () => { + let {container} = await render( +
+ + + + {(item: Message) => {item.content}} + + + +
+ ); + + let gridlist = container.querySelector('[role="grid"]') as HTMLElement; + + await vi.waitFor( + () => { + expect(gridlist.querySelectorAll('[role="row"]').length).toBeGreaterThan(0); + }, + {timeout: 2000} + ); + + // Click the button first so focus has a starting point, then tab into the grid. + // Without an initial focused element, userEvent.tab() may not move focus reliably. + let beforeInput = container.querySelectorAll('input')[0] as HTMLElement; + await userEvent.click(beforeInput); + expect(beforeInput).toHaveFocus(); + await userEvent.tab(); + + let rows = gridlist.querySelectorAll('[role="row"]'); + await vi.waitFor(() => expect(rows[0]).toHaveFocus(), {timeout: 2000}); + + // Navigate to Hello (rows[1]). + await userEvent.keyboard('{ArrowUp}'); + await vi.waitFor(() => expect(rows[1]).toHaveFocus(), {timeout: 1000}); + + // Click the After button to move focus outside the grid. + let afterInput = container.querySelectorAll('input')[1] as HTMLElement; + await userEvent.click(afterInput); + expect(afterInput).toHaveFocus(); + + // Shift-Tab to re-enter the grid → focusOnEntry fires again → World (rows[0]). + await userEvent.tab({shift: true}); + await vi.waitFor(() => expect(rows[0]).toHaveFocus(), {timeout: 2000}); + expect(rows[0]).toHaveTextContent('World'); + }); + }); + + describe('virtualized Thread – scroll button', () => { + it('appears when scrolled away from the bottom', async () => { + // 20 messages with long enough text to overflow the 400px container. + let messages: Message[] = Array.from({length: 20}, (_, i) => ({ + id: String(i + 1), + content: `Message number ${i + 1} in the chat thread` + })); + + let {container} = await render( +
+ + + + + + {(item: Message) => {item.content}} + + +
+ ); + + let grid = container.querySelector('[role="grid"]') as HTMLElement; + + // Wait for layout to stabilize — anchorTo="end" snaps scrollTop to the bottom. + await vi.waitFor( + () => { + expect(grid.scrollTop).toBeGreaterThan(0); + }, + {timeout: 3000} + ); + + // Initially at the bottom — scroll button should be hidden. + expect(container.querySelector('[data-testid="scroll-btn"]')).not.toBeInTheDocument(); + + // Scroll to top (away from newest messages at bottom). + grid.scrollTop = 0; + grid.dispatchEvent(new Event('scroll', {bubbles: true})); + + // Scroll button should now appear. + await vi.waitFor( + () => { + expect(container.querySelector('[data-testid="scroll-btn"]')).toBeInTheDocument(); + }, + {timeout: 2000} + ); + }); + + it('stays hidden when near the bottom', async () => { + let messages: Message[] = Array.from({length: 20}, (_, i) => ({ + id: String(i + 1), + content: `Message number ${i + 1} in the chat thread` + })); + + let {container} = await render( +
+ + + + + + {(item: Message) => {item.content}} + + +
+ ); + + let grid = container.querySelector('[role="grid"]') as HTMLElement; + + // Wait for layout to stabilize at the bottom. + await vi.waitFor( + () => { + expect(grid.scrollTop).toBeGreaterThan(0); + }, + {timeout: 3000} + ); + + // No manual scroll — still near bottom. Button should stay hidden. + expect(container.querySelector('[data-testid="scroll-btn"]')).not.toBeInTheDocument(); + }); + + it('scrolls back to the bottom when clicked', async () => { + let messages: Message[] = Array.from({length: 20}, (_, i) => ({ + id: String(i + 1), + content: `Message number ${i + 1} in the chat thread` + })); + + let {container} = await render( +
+ + + + + + {(item: Message) => {item.content}} + + +
+ ); + + let grid = container.querySelector('[role="grid"]') as HTMLElement; + + // Wait for layout at the bottom. + await vi.waitFor( + () => { + expect(grid.scrollTop).toBeGreaterThan(0); + }, + {timeout: 3000} + ); + + // Scroll to top to reveal the scroll button. + grid.scrollTop = 0; + grid.dispatchEvent(new Event('scroll', {bubbles: true})); + + await vi.waitFor( + () => { + expect(container.querySelector('[data-testid="scroll-btn"]')).toBeInTheDocument(); + }, + {timeout: 2000} + ); + + // Click → scrolls back to bottom → button disappears. + await userEvent.click(container.querySelector('[data-testid="scroll-btn"]') as HTMLElement); + + await vi.waitFor( + () => { + expect(container.querySelector('[data-testid="scroll-btn"]')).not.toBeInTheDocument(); + }, + {timeout: 3000} + ); + }); + }); + + describe('async loading – virtualized (anchorTo="end")', () => { + it('shows loading indicator while onLoadMore is in-flight', async () => { + let {container} = await render( + + ); + + await vi.waitFor( + () => { + expect( + container.querySelector('[aria-label="Loading older messages"]') + ).toBeInTheDocument(); + }, + {timeout: 3000} + ); + }); + + it('fires onLoadMore when sentinel is visible and prepends older items', async () => { + // Start with the 3 newest messages; older 3 load automatically when sentinel is visible. + let {container} = await render( + + ); + + // Wait for all 6 rows to appear. + await vi.waitFor( + () => { + let rows = container.querySelectorAll('[role="row"]'); + expect(rows.length).toBe(6); + }, + {timeout: 3000} + ); + + // In the virtualizer, buildReversedCollection puts the NEWEST item first in the DOM. + // After prepend: chronological order is CONVERSATION[0..5]. + // DOM order: newest (CONVERSATION[5]) = rows[0], oldest (CONVERSATION[0]) = rows[5]. + let rows = container.querySelectorAll('[role="row"]'); + expect(rows[0]).toHaveTextContent(CONVERSATION[5].content); + expect(rows[rows.length - 1]).toHaveTextContent(CONVERSATION[0].content); + }); + + it('hides loading indicator after load completes', async () => { + let {container} = await render( + + ); + + // Wait until BOTH all 6 rows are visible AND the indicator is gone. + await vi.waitFor( + () => { + let rows = container.querySelectorAll('[role="row"]'); + expect(rows.length).toBe(6); + expect( + container.querySelector('[aria-label="Loading older messages"]') + ).not.toBeInTheDocument(); + }, + {timeout: 3000} + ); + }); + + it('does not call onLoadMore when onLoadMore is undefined', async () => { + // pageSize equals messages.length → hasMore=false → onLoadMore=undefined from the start. + let {container} = await render( + + ); + + // Wait a moment to confirm no extra rows are added. + await new Promise(r => setTimeout(r, 200)); + let rows = container.querySelectorAll('[role="row"]'); + expect(rows.length).toBe(3); + }); + }); }); diff --git a/packages/@react-spectrum/ai/test/Chat.test.tsx b/packages/@react-spectrum/ai/test/Chat.test.tsx index 0eb80668e2a..390500d1580 100644 --- a/packages/@react-spectrum/ai/test/Chat.test.tsx +++ b/packages/@react-spectrum/ai/test/Chat.test.tsx @@ -26,19 +26,13 @@ interface Message { isStreaming?: boolean; } -function TestThread({ - messages, - UNSTABLE_focusOnEntry -}: { - messages: Message[]; - UNSTABLE_focusOnEntry?: 'first' | 'last'; -}) { +function TestThread({messages}: {messages: Message[]}) { return ( - + {(item: Message) => ( {item.text} @@ -244,62 +238,4 @@ describeOrSkip('Thread', () => { expect(scrollTo).toHaveBeenCalledWith({top: 0, behavior: 'smooth'}); }); }); - - describe('focus behavior', () => { - it('focuses the first item in the list when tabbing in if UNSTABLE_focusOnEntry="first"', async () => { - let {getByRole} = render( - - ); - - let gridlist = getByRole('grid'); - let rows = gridlist.querySelectorAll('[role="row"]'); - await user.tab(); - expect(document.activeElement).toBe(rows[0]); - expect(rows[0]).toHaveTextContent('Hello'); - - await user.keyboard('{ArrowDown}'); - expect(document.activeElement).toBe(rows[1]); - - await user.tab(); - expect(document.activeElement).toBe(getByRole('textbox')); - - // should always move to first item when entering the thread via tab regardless of last focused row - await user.tab({shift: true}); - expect(document.activeElement).toBe(rows[0]); - }); - - it('focuses the last item in the list when tabbing in if UNSTABLE_focusOnEntry="last"', async () => { - let {getByRole} = render( - - ); - - let gridlist = getByRole('grid'); - let rows = gridlist.querySelectorAll('[role="row"]'); - await user.tab(); - expect(document.activeElement).toBe(rows[1]); - expect(rows[1]).toHaveTextContent('World'); - - await user.keyboard('{ArrowUp}'); - expect(document.activeElement).toBe(rows[0]); - - await user.tab(); - expect(document.activeElement).toBe(getByRole('textbox')); - - // should always move to last item when entering the thread via tab regardless of last focused row - await user.tab({shift: true}); - expect(document.activeElement).toBe(rows[1]); - }); - }); }); diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 098cb5c7f49..22ea560aef6 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -819,7 +819,7 @@ function RootDropIndicator() { export interface GridListLoadMoreItemProps extends - Omit, + Omit, StyleProps, DOMRenderProps<'div', undefined>, GlobalDOMAttributes { @@ -853,13 +853,8 @@ export const GridListLoadMoreItem = createLeafComponent( let sentinelRef = useRef(null); let memoedLoadMoreProps = useMemo( - () => ({ - onLoadMore, - collection: state?.collection, - sentinelRef, - scrollOffset - }), - [onLoadMore, scrollOffset, state?.collection] + () => ({onLoadMore, collection: state?.collection, sentinelRef, scrollOffset}), + [onLoadMore, scrollOffset, sentinelRef, state?.collection] ); useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 1db8ee3c600..55c8e8d9c5b 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -718,7 +718,7 @@ const ListBoxDropIndicatorForwardRef = forwardRef(ListBoxDropIndicator); export interface ListBoxLoadMoreItemProps extends - Omit, + Omit, StyleProps, DOMRenderProps<'div', undefined>, GlobalDOMAttributes { diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 0822fd90294..85e47c5aaa0 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -1068,7 +1068,9 @@ export interface TreeLoadMoreItemRenderProps { } export interface TreeLoadMoreItemProps - extends Omit, RenderProps { + extends + Omit, + RenderProps { /** * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the * element. A function may be provided to compute the class based on component state. diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index b870bd94d76..62cd214d7a9 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -48,15 +48,20 @@ export interface VirtualizerProps { layout: LayoutClass | ILayout; /** Options for the layout. */ layoutOptions?: O; + /** + * Whether to observe each item's size with a ResizeObserver and re-measure when it changes. + */ + shouldObserveItemSize?: boolean; } -interface LayoutContextValue { +interface VirtualizerOptionsContextValue { layout: ILayout; layoutOptions?: any; + shouldObserveItemSize?: boolean; } const VirtualizerContext = createContext | null>(null); -const LayoutContext = createContext(null); +const VirtualizerOptionsContext = createContext(null); /** * A Virtualizer renders a scrollable collection of data using customizable layouts. @@ -64,7 +69,7 @@ const LayoutContext = createContext(null); * them as the user scrolls. */ export function Virtualizer(props: VirtualizerProps): JSX.Element { - let {children, layout: layoutProp, layoutOptions} = props; + let {children, layout: layoutProp, layoutOptions, shouldObserveItemSize} = props; let layout = useMemo( () => (typeof layoutProp === 'function' ? new layoutProp() : layoutProp), [layoutProp] @@ -84,7 +89,9 @@ export function Virtualizer(props: VirtualizerProps): JSX.Element { return ( - {children} + + {children} + ); } @@ -95,7 +102,7 @@ function CollectionRoot({ scrollRef, renderDropIndicator }: CollectionRootProps) { - let {layout, layoutOptions} = useContext(LayoutContext)!; + let {layout, layoutOptions, shouldObserveItemSize} = useContext(VirtualizerOptionsContext)!; // oxlint-disable-next-line react/react-compiler let layoutOptions2 = layout.useLayoutOptions?.(); let state = useVirtualizerState({ @@ -114,12 +121,13 @@ function CollectionRoot({ } }, persistedKeys, - layoutOptions: useMemo(() => { - if (layoutOptions && layoutOptions2) { - return {...layoutOptions, ...layoutOptions2}; - } - return layoutOptions || layoutOptions2; - }, [layoutOptions, layoutOptions2]) + layoutOptions: useMemo( + () => + layoutOptions && layoutOptions2 + ? {...layoutOptions, ...layoutOptions2} + : layoutOptions || layoutOptions2, + [layoutOptions, layoutOptions2] + ) }); let {contentProps} = useScrollView( @@ -137,7 +145,7 @@ function CollectionRoot({ return (
- {renderChildren(null, state.visibleViews, renderDropIndicator)} + {renderChildren(null, state.visibleViews, renderDropIndicator, shouldObserveItemSize)}
); @@ -146,28 +154,39 @@ function CollectionRoot({ function CollectionBranch({parent, renderDropIndicator}: CollectionBranchProps) { let virtualizer = useContext(VirtualizerContext); let parentView = virtualizer!.virtualizer.getVisibleView(parent.key)!; - return renderChildren(parentView, Array.from(parentView.children), renderDropIndicator); + let {shouldObserveItemSize} = useContext(VirtualizerOptionsContext)!; + return renderChildren( + parentView, + Array.from(parentView.children), + renderDropIndicator, + shouldObserveItemSize + ); } function renderChildren( parent: View | null, children: View[], - renderDropIndicator?: (target: ItemDropTarget) => ReactNode + renderDropIndicator?: (target: ItemDropTarget) => ReactNode, + shouldObserveItemSize?: boolean ) { - return children.map(view => renderWrapper(parent, view, renderDropIndicator)); + return children.map(view => + renderWrapper(parent, view, renderDropIndicator, shouldObserveItemSize) + ); } function renderWrapper( parent: View | null, reusableView: View, - renderDropIndicator?: (target: ItemDropTarget) => ReactNode + renderDropIndicator?: (target: ItemDropTarget) => ReactNode, + shouldObserveItemSize?: boolean ): ReactNode { let rendered = ( + parent={parent?.layoutInfo} + shouldObserveItemSize={shouldObserveItemSize}> {reusableView.rendered} ); diff --git a/packages/react-aria/src/utils/useLoadMoreSentinel.ts b/packages/react-aria/src/utils/useLoadMoreSentinel.ts index cfb79cc08e4..ddfba155e71 100644 --- a/packages/react-aria/src/utils/useLoadMoreSentinel.ts +++ b/packages/react-aria/src/utils/useLoadMoreSentinel.ts @@ -28,13 +28,20 @@ export interface LoadMoreSentinelProps extends Omit * @default 1 */ scrollOffset?: number; + /** + * The scroll direction that triggers load more. Use 'start' for reversed layouts where older + * content loads when scrolling toward the top. + * + * @default 'end' + */ + direction?: 'start' | 'end'; } export function useLoadMoreSentinel( props: LoadMoreSentinelProps, ref: RefObject ): void { - let {collection, onLoadMore, scrollOffset = 1} = props; + let {collection, onLoadMore, scrollOffset = 1, direction = 'end'} = props; let sentinelObserver = useRef(null); @@ -55,9 +62,13 @@ export function useLoadMoreSentinel( // Tear down and set up a new IntersectionObserver when the collection changes so that we can properly trigger additional loadMores if there is room for more items // Need to do this tear down and set up since using a large rootMargin will mean the observer's callback isn't called even when scrolling the item into view beause its visibility hasn't actually changed // https://codesandbox.io/p/sandbox/magical-swanson-dhgp89?file=%2Fsrc%2FApp.js%3A21%2C21 + const margin = 100 * scrollOffset; + // For direction='start', right/left margins have no affect for vertical scroll containers. We are not supporting reverse horizontal scroll containers for now. + const rootMargin = + direction === 'start' ? `${margin}% 0px 0px 0px` : `0px ${margin}% ${margin}% ${margin}%`; sentinelObserver.current = new IntersectionObserver(triggerLoadMore, { root: getScrollParent(ref?.current) as HTMLElement, - rootMargin: `0px ${100 * scrollOffset}% ${100 * scrollOffset}% ${100 * scrollOffset}%` + rootMargin }); sentinelObserver.current.observe(ref.current); } @@ -67,5 +78,5 @@ export function useLoadMoreSentinel( sentinelObserver.current.disconnect(); } }; - }, [collection, ref, scrollOffset]); + }, [collection, ref, scrollOffset, direction]); } diff --git a/packages/react-aria/src/virtualizer/VirtualizerItem.tsx b/packages/react-aria/src/virtualizer/VirtualizerItem.tsx index c5533cf7b31..81705448919 100644 --- a/packages/react-aria/src/virtualizer/VirtualizerItem.tsx +++ b/packages/react-aria/src/virtualizer/VirtualizerItem.tsx @@ -22,16 +22,18 @@ interface VirtualizerItemProps extends Omit { style?: CSSProperties; className?: string; children: ReactNode; + shouldObserveItemSize?: boolean; } export function VirtualizerItem(props: VirtualizerItemProps): JSX.Element { - let {style, className, layoutInfo, virtualizer, parent, children} = props; + let {style, className, layoutInfo, virtualizer, parent, children, shouldObserveItemSize} = props; let {direction} = useLocale(); let ref = useRef(null); useVirtualizerItem({ layoutInfo, virtualizer, - ref + ref, + shouldObserveItemSize }); return ( diff --git a/packages/react-aria/src/virtualizer/useVirtualizerItem.ts b/packages/react-aria/src/virtualizer/useVirtualizerItem.ts index ad973570b53..a961a7e9af8 100644 --- a/packages/react-aria/src/virtualizer/useVirtualizerItem.ts +++ b/packages/react-aria/src/virtualizer/useVirtualizerItem.ts @@ -13,7 +13,8 @@ import {isElementVisible} from '../utils/isElementVisible'; import {Key, RefObject} from '@react-types/shared'; import {LayoutInfo, Size} from 'react-stately/useVirtualizerState'; -import {useCallback} from 'react'; +import {useCallback, useEffect} from 'react'; +import {useEffectEvent} from '../utils/useEffectEvent'; import {useLayoutEffect} from '../utils/useLayoutEffect'; interface IVirtualizer { @@ -24,10 +25,11 @@ export interface VirtualizerItemOptions { layoutInfo: LayoutInfo | null; virtualizer: IVirtualizer; ref: RefObject; + shouldObserveItemSize?: boolean; } export function useVirtualizerItem(options: VirtualizerItemOptions): {updateSize: () => void} { - let {layoutInfo, virtualizer, ref} = options; + let {layoutInfo, virtualizer, ref, shouldObserveItemSize} = options; let key = layoutInfo?.key; let updateSize = useCallback(() => { @@ -43,11 +45,46 @@ export function useVirtualizerItem(options: VirtualizerItemOptions): {updateSize } }, [virtualizer, key, ref]); + let updateSizeEvent = useEffectEvent(updateSize); + useLayoutEffect(() => { if (layoutInfo?.estimatedSize) { - updateSize(); + updateSizeEvent(); + } + }, [layoutInfo?.estimatedSize]); + + // TODO: Consider using a MutationObserver in addition to ResizeObserver to detect + // when inner DOM structure changes cause an item's height to change. + // The current ResizeObserver only observes direct children, + // so mutations deeper in the tree won't trigger a remeasure, leading to stale cached heights and overlapping items. + // useResizeObserver observes one element via ref, but the wrapper height is fixed by layout + // and won't change when content grows. Observe direct children instead, then remeasure the + // wrapper in updateSize. + useEffect(() => { + if (!shouldObserveItemSize) { + return; } - }); + + let el = ref.current; + if (!el || typeof ResizeObserver === 'undefined') { + return; + } + + let resizeObserver = new ResizeObserver(entries => { + if (!entries.length) { + return; + } + updateSizeEvent(); + }); + + for (let child of el.children) { + resizeObserver.observe(child); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [shouldObserveItemSize, ref, key]); return {updateSize}; } diff --git a/packages/react-stately/src/layout/ListLayout.ts b/packages/react-stately/src/layout/ListLayout.ts index f200f12584d..2edd1fbf200 100644 --- a/packages/react-stately/src/layout/ListLayout.ts +++ b/packages/react-stately/src/layout/ListLayout.ts @@ -301,9 +301,6 @@ export class ListLayout } protected shouldInvalidateEverything(invalidationContext: InvalidationContext): boolean { - // Invalidate cache if the size of the collection changed. - // In this case, we need to recalculate the entire layout. - // Also invalidate if fixed sizes/gaps change. let options = invalidationContext.layoutOptions; return ( invalidationContext.sizeChanged || @@ -449,6 +446,7 @@ export class ListLayout this.orientation === 'horizontal' ? new Size(offset, this.virtualizer!.size.height) : new Size(this.virtualizer!.size.width, offset); + return nodes; } diff --git a/packages/react-stately/src/virtualizer/Layout.ts b/packages/react-stately/src/virtualizer/Layout.ts index fd585fe7f59..30130b782c5 100644 --- a/packages/react-stately/src/virtualizer/Layout.ts +++ b/packages/react-stately/src/virtualizer/Layout.ts @@ -14,6 +14,7 @@ import {InvalidationContext} from './types'; import {ItemDropTarget, Key, LayoutDelegate, Node} from '@react-types/shared'; import {LayoutInfo} from './LayoutInfo'; import {Rect} from './Rect'; +import {ScrollAnchorInfo} from './ScrollAnchor'; import {Size} from './Size'; import {Virtualizer} from './Virtualizer'; @@ -73,6 +74,13 @@ export abstract class Layout, O = any> implements L return newOptions !== oldOptions; } + /** + * Describes the edge-anchoring this layout wants, if any. Returning null (or omitting this + * method) disables scroll-anchoring entirely — the virtualizer's generic anchor-tracking logic + * is skipped. + */ + UNSTABLE_getScrollAnchorInfo?(_layoutOptions?: O): ScrollAnchorInfo | null; + /** * This method allows the layout to perform any pre-computation * it needs to in order to prepare LayoutInfos for retrieval. diff --git a/packages/react-stately/src/virtualizer/ScrollAnchor.ts b/packages/react-stately/src/virtualizer/ScrollAnchor.ts new file mode 100644 index 00000000000..cfa3e317338 --- /dev/null +++ b/packages/react-stately/src/virtualizer/ScrollAnchor.ts @@ -0,0 +1,336 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Key} from '@react-types/shared'; +import {LayoutInfo} from './LayoutInfo'; +import {Rect, RectCorner} from './Rect'; +import {Size} from './Size'; + +export type ScrollAnchorAxis = 'x' | 'y'; +export type ScrollAnchorEdge = 'start' | 'end'; +export interface ScrollAnchor { + key: Key; + corner: RectCorner; + offset: number; +} + +export interface ScrollAnchorInfo { + /** Which edge of the content the viewport should stay anchored to. */ + edge: 'start' | 'end'; + /** Which axis `edge` refers to — 'y' for vertical lists, 'x' for horizontal. */ + axis: 'x' | 'y'; + /** Distance (px) from `edge` within which the viewport is considered "following" it. */ + threshold: number; + /** + * Optional classifier excluding structural/ephemeral layout infos (e.g. loaders) from being + * selected as the anchor. Defaults to allowing any layoutInfo. + */ + isAnchorable?: (layoutInfo: LayoutInfo) => boolean; +} + +/** + * Minimum overlap an item must have with the viewport, along the scroll axis, + * to be eligible as a scroll anchor. Without this, an item that only overlaps the viewport + * 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; + +function dimensionForAxis(axis: ScrollAnchorAxis): 'width' | 'height' { + return axis === 'x' ? 'width' : 'height'; +} + +/** + * Given a previously-captured anchor, computes the new viewport coordinate (along `axis`) needed + * to keep it at the same offset from the viewport's start. + */ +export function computeScrollAnchorTarget( + anchor: ScrollAnchor, + axis: ScrollAnchorAxis, + getLayoutInfo: (key: Key) => LayoutInfo | null, + visibleRect: Rect, + contentSize: Size +): number | null { + let finalInfo = getLayoutInfo(anchor.key); + if (!finalInfo) { + return null; + } + let adjustment = finalInfo.rect[anchor.corner][axis] - visibleRect[axis] - anchor.offset; + if (adjustment === 0) { + return null; + } + let target = visibleRect[axis] + adjustment; + let dimension = dimensionForAxis(axis); + let max = Math.max(0, contentSize[dimension] - visibleRect[dimension]); + let clamped = Math.max(0, Math.min(max, target)); + return clamped !== visibleRect[axis] ? clamped : null; +} + +/** + * Picks the item to anchor scroll to: the one nearest the top of the viewport when + * anchoring to 'end', or nearest the bottom when anchoring to 'start'. Callers can + * exclude certain items (like loaders) with `isAnchorable`. + */ +export function captureScrollAnchor( + edge: ScrollAnchorEdge, + axis: ScrollAnchorAxis, + visibleRect: Rect, + visibleLayoutInfos: Iterable<[Key, LayoutInfo]>, + isAnchorable: (layoutInfo: LayoutInfo) => boolean = () => true +): ScrollAnchor | null { + let dimension = dimensionForAxis(axis); + let best: ScrollAnchor | null = null; + for (let [key, layoutInfo] of visibleLayoutInfos) { + if (!layoutInfo || !isAnchorable(layoutInfo)) { + continue; + } + let overlap = layoutInfo.rect.intersection(visibleRect)[dimension]; + if (layoutInfo.rect.area > 0 && overlap >= MIN_ANCHOR_OVERLAP) { + let corner = layoutInfo.rect.getCornerInRect(visibleRect) ?? 'topLeft'; + let offset = layoutInfo.rect[corner][axis] - visibleRect[axis]; + let isBetter = !best || (edge === 'end' ? offset < best.offset : offset > best.offset); + if (isBetter) { + best = {key, corner, offset}; + } + } + } + return best; +} + +/** Returns the viewport coordinate (along `axis`) that pins the viewport to `edge` of the content. */ +export function getEdgeSnapTarget( + edge: ScrollAnchorEdge, + axis: ScrollAnchorAxis, + contentSize: Size, + previousVisibleRect: Rect +): number { + if (edge === 'start') { + return 0; + } + let dimension = dimensionForAxis(axis); + return Math.max(0, contentSize[dimension] - previousVisibleRect[dimension]); +} + +/** + * Whether the viewport is currently within `threshold` px of the anchored edge — used by + * Virtualizer to compute wasNearAnchorEdge generically, without any layout-specific state. + */ +export function isNearEdge( + visibleRect: Rect, + contentSize: Size, + edge: ScrollAnchorEdge, + axis: ScrollAnchorAxis, + threshold: number +): boolean { + if (edge === 'start') { + return visibleRect[axis] <= threshold; + } + let dimension = dimensionForAxis(axis); + let distanceFromEnd = contentSize[dimension] - (visibleRect[axis] + visibleRect[dimension]); + return distanceFromEnd <= threshold; +} + +/** + * Works out the new scroll position after content changes. Tries to keep the anchor + * item where it was. If that doesn't apply, sticks the view to the edge instead, but + * only if the user was already near the edge and didn't just scroll away on their own. + */ +export function resolveScrollAdjustment( + edge: ScrollAnchorEdge, + axis: ScrollAnchorAxis, + anchor: ScrollAnchor | null, + wasNearAnchorEdge: boolean, + isScrolling: boolean, + itemSizeChanged: boolean, + contentSizeDelta: number, + getLayoutInfo: (key: Key) => LayoutInfo | null, + previousVisibleRect: Rect, + contentSize: Size +): Rect | null { + let withTarget = (target: number): Rect => + axis === 'x' + ? new Rect( + target, + previousVisibleRect.y, + previousVisibleRect.width, + previousVisibleRect.height + ) + : new Rect( + previousVisibleRect.x, + target, + previousVisibleRect.width, + previousVisibleRect.height + ); + + if (anchor) { + let target = computeScrollAnchorTarget( + anchor, + axis, + getLayoutInfo, + previousVisibleRect, + contentSize + ); + if (target != null) { + return withTarget(target); + } + } + + if (wasNearAnchorEdge && !isScrolling && (!itemSizeChanged || contentSizeDelta > 0)) { + let target = withTarget(getEdgeSnapTarget(edge, axis, contentSize, previousVisibleRect)); + return target.equals(previousVisibleRect) ? null : target; + } + + return null; +} + +export interface ResolveAfterLayoutOptions { + anchorInfo: ScrollAnchorInfo | null; + /** The anchor captured by `captureBeforeLayout` before this pass's `layout.update()` ran. */ + anchor: ScrollAnchor | null; + /** The full post-layout visible layout infos, i.e. `virtualizer.getVisibleLayoutInfos()`. */ + postLayoutInfos: Map; + previousVisibleRect: Rect; + previousContentSize: Size; + contentSize: Size; + itemSizeChanged: boolean; + isScrolling: boolean; + getLayoutInfo: (key: Key) => LayoutInfo | null; +} + +/** + * Tracks the cross-pass state needed to keep the viewport anchored to a layout's edge across + * relayouts. + */ +export class ScrollAnchorTracker { + private hasSnappedToEdge = false; + private hadEstimatedVisibleItems = false; + private wasNearAnchorEdge = false; + + /** Resets all tracked state, e.g. when the virtualizer's layout instance changes. */ + reset(): void { + this.hasSnappedToEdge = false; + this.hadEstimatedVisibleItems = false; + this.wasNearAnchorEdge = false; + } + + /** + * Captures the anchor from pre-layout view positions. + */ + captureBeforeLayout( + anchorInfo: ScrollAnchorInfo | null, + preLayoutInfos: Iterable<[Key, LayoutInfo]>, + visibleRect: Rect + ): ScrollAnchor | null { + if (!anchorInfo) { + return null; + } + return captureScrollAnchor( + anchorInfo.edge, + anchorInfo.axis, + visibleRect, + preLayoutInfos, + anchorInfo.isAnchorable + ); + } + + /** + * Runs the full post-layout decision: updates the tracked state for the next pass, and + * returns the resolved scroll target, or null if nothing should change. + */ + resolveAfterLayout(options: ResolveAfterLayoutOptions): Rect | null { + let { + anchorInfo, + anchor, + postLayoutInfos, + previousVisibleRect, + previousContentSize, + contentSize, + itemSizeChanged, + isScrolling, + getLayoutInfo + } = options; + + if (!anchorInfo) { + return null; + } + + // Read the previous pass's state into locals before any writes below overwrite it. + let wasSettlingLastPass = this.hadEstimatedVisibleItems; + let wasNearAnchorEdgeLastPass = this.wasNearAnchorEdge; + + let hasEstimated = false; + for (let layoutInfo of postLayoutInfos.values()) { + if (layoutInfo.estimatedSize) { + hasEstimated = true; + break; + } + } + this.hadEstimatedVisibleItems = hasEstimated; + // Don't recheck "near edge?" mid-resize because it could look like a scroll that never happened. + // Reuse the answer from before the resizing started. + if (!wasSettlingLastPass) { + this.wasNearAnchorEdge = isNearEdge( + previousVisibleRect, + previousContentSize, + anchorInfo.edge, + anchorInfo.axis, + anchorInfo.threshold + ); + } + + if (previousVisibleRect.area === 0) { + return null; + } + + let dimension = anchorInfo.axis === 'x' ? 'width' : 'height'; + let contentSizeDelta = contentSize[dimension] - previousContentSize[dimension]; + let isFirstAnchoredLayout = !this.hasSnappedToEdge; + this.hasSnappedToEdge = true; + + // Only modify scroll when content actually changed (or this is the first layout, which always snaps) + if (!(isFirstAnchoredLayout || contentSizeDelta !== 0 || itemSizeChanged)) { + return null; + } + + let wasNearAnchorEdge = + isFirstAnchoredLayout || + (wasSettlingLastPass && wasNearAnchorEdgeLastPass) || + isNearEdge( + previousVisibleRect, + previousContentSize, + anchorInfo.edge, + anchorInfo.axis, + anchorInfo.threshold + ); + // A first-ever layout always snaps to the edge, even if the raw distance check says + // otherwise. Save that real decision here so later passes in this cascade reuse it. + if (!wasSettlingLastPass) { + this.wasNearAnchorEdge = wasNearAnchorEdge; + } + // Skip restoring to the captured anchor while still resizing because items above it are also still growing, + // and following it would fall short of the edge. + let effectiveAnchor = + isFirstAnchoredLayout || (wasSettlingLastPass && wasNearAnchorEdgeLastPass) ? null : anchor; + return resolveScrollAdjustment( + anchorInfo.edge, + anchorInfo.axis, + effectiveAnchor, + wasNearAnchorEdge, + isScrolling, + itemSizeChanged, + contentSizeDelta, + getLayoutInfo, + previousVisibleRect, + contentSize + ); + } +} diff --git a/packages/react-stately/src/virtualizer/Virtualizer.ts b/packages/react-stately/src/virtualizer/Virtualizer.ts index 7a87676616f..cfbd178f957 100644 --- a/packages/react-stately/src/virtualizer/Virtualizer.ts +++ b/packages/react-stately/src/virtualizer/Virtualizer.ts @@ -19,6 +19,7 @@ import {LayoutInfo} from './LayoutInfo'; import {OverscanManager} from './OverscanManager'; import {Point} from './Point'; import {Rect} from './Rect'; +import {ScrollAnchor, ScrollAnchorTracker} from './ScrollAnchor'; import {Size} from './Size'; interface VirtualizerOptions { @@ -70,6 +71,7 @@ export class Virtualizer { private _isScrolling: boolean; private _invalidationContext: InvalidationContext; private _overscanManager: OverscanManager; + private _scrollAnchor: ScrollAnchorTracker; constructor(options: VirtualizerOptions) { this.delegate = options.delegate; @@ -85,6 +87,7 @@ export class Virtualizer { this._isScrolling = false; this._invalidationContext = {}; this._overscanManager = new OverscanManager(); + this._scrollAnchor = new ScrollAnchorTracker(); } /** Returns whether the given key, or an ancestor, is persisted. */ @@ -167,9 +170,50 @@ export class Virtualizer { } private relayout(context: InvalidationContext = {}) { + let anchorInfo = this.layout.UNSTABLE_getScrollAnchorInfo?.(context.layoutOptions) ?? null; + + // Capture scroll anchor from current (pre-layout) view positions. + // On first render _visibleViews is empty so no anchor will be found. + let anchor: ScrollAnchor | null = null; + if (anchorInfo) { + let preLayoutInfos: [Key, LayoutInfo][] = []; + for (let [key, view] of this._visibleViews) { + let layoutInfo = this.layout.getLayoutInfo(key) ?? view.layoutInfo; + if (layoutInfo) { + preLayoutInfos.push([key, layoutInfo]); + } + } + anchor = this._scrollAnchor.captureBeforeLayout(anchorInfo, preLayoutInfos, this.visibleRect); + } + + let previousContentSize = this.contentSize; + let previousVisibleRect = this.visibleRect; + // Update the layout this.layout.update(context); - (this as Mutable).contentSize = this.layout.getContentSize(); + + let rawContentSize = this.layout.getContentSize(); + (this as Mutable).contentSize = new Size(rawContentSize.width, rawContentSize.height); + + let target = this._scrollAnchor.resolveAfterLayout({ + anchorInfo, + anchor, + postLayoutInfos: anchorInfo ? this.getVisibleLayoutInfos() : new Map(), + previousVisibleRect, + previousContentSize, + contentSize: this.contentSize, + itemSizeChanged: context.itemSizeChanged ?? false, + isScrolling: this._isScrolling, + getLayoutInfo: (key: Key) => this.layout.getLayoutInfo(key) + }); + + if (target) { + // Queues a new render cycle. Return early to skip updateSubviews — running it now + // would position views against the old visibleRect, causing a flash before the + // incoming relayout corrects them. + this.delegate.setVisibleRect(target); + return; + } // Constrain scroll position. // If the content changed, scroll to the top. @@ -282,6 +326,8 @@ export class Virtualizer { let needsLayout = false; let offsetChanged = false; let sizeChanged = false; + let widthChanged = false; + let heightChanged = false; let itemSizeChanged = false; let layoutOptionsChanged = false; let needsUpdate = false; @@ -298,6 +344,7 @@ export class Virtualizer { opts.layout.virtualizer = this; mutableThis.layout = opts.layout; + this._scrollAnchor.reset(); needsLayout = true; } @@ -328,6 +375,8 @@ export class Virtualizer { if (shouldInvalidate) { offsetChanged = !opts.visibleRect.pointEquals(this.visibleRect); sizeChanged = !this.size.equals(opts.size); + widthChanged = this.size.width !== opts.size.width; + heightChanged = this.size.height !== opts.size.height; needsLayout = true; } else { needsUpdate = true; @@ -340,6 +389,8 @@ export class Virtualizer { if (opts.invalidationContext !== this._invalidationContext) { if (opts.invalidationContext) { sizeChanged ||= opts.invalidationContext.sizeChanged || false; + widthChanged ||= opts.invalidationContext.widthChanged || false; + heightChanged ||= opts.invalidationContext.heightChanged || false; offsetChanged ||= opts.invalidationContext.offsetChanged || false; itemSizeChanged ||= opts.invalidationContext.itemSizeChanged || false; layoutOptionsChanged ||= @@ -367,6 +418,8 @@ export class Virtualizer { this.relayout({ offsetChanged, sizeChanged, + widthChanged, + heightChanged, itemSizeChanged, layoutOptionsChanged, layoutOptions: this._invalidationContext.layoutOptions diff --git a/packages/react-stately/src/virtualizer/types.ts b/packages/react-stately/src/virtualizer/types.ts index 7944dceedd8..8154145540d 100644 --- a/packages/react-stately/src/virtualizer/types.ts +++ b/packages/react-stately/src/virtualizer/types.ts @@ -19,6 +19,8 @@ export interface InvalidationContext { contentChanged?: boolean; offsetChanged?: boolean; sizeChanged?: boolean; + widthChanged?: boolean; + heightChanged?: boolean; itemSizeChanged?: boolean; layoutOptionsChanged?: boolean; layoutOptions?: O; diff --git a/packages/react-stately/test/virtualizer/ScrollAnchor.test.ts b/packages/react-stately/test/virtualizer/ScrollAnchor.test.ts new file mode 100644 index 00000000000..96844aab8e9 --- /dev/null +++ b/packages/react-stately/test/virtualizer/ScrollAnchor.test.ts @@ -0,0 +1,574 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + captureScrollAnchor, + computeScrollAnchorTarget, + getEdgeSnapTarget, + isNearEdge, + resolveScrollAdjustment, + ScrollAnchorTracker +} from '../../src/virtualizer/ScrollAnchor'; +import {LayoutInfo} from '../../src/virtualizer/LayoutInfo'; +import {Rect} from '../../src/virtualizer/Rect'; +import {ScrollAnchor, ScrollAnchorInfo} from '../../src/virtualizer/ScrollAnchor'; +import {Size} from '../../src/virtualizer/Size'; + +describe('captureScrollAnchor', () => { + it('does not anchor to an item that only slivers into the viewport by a pixel or two', () => { + let visibleRect = new Rect(0, 1023, 400, 468); + + // Substantially visible: fully inside the viewport, 9px from the top. + let substantiallyVisible = new LayoutInfo( + 'item', + 'substantially-visible', + new Rect(0, 1032, 400, 40) + ); + // Nearly scrolled out: only its bottom 1px overlaps the viewport. + let sliver = new LayoutInfo('item', 'sliver', new Rect(0, 976, 400, 48)); + + let anchor = captureScrollAnchor('end', 'y', visibleRect, [ + ['substantially-visible', substantiallyVisible], + ['sliver', sliver] + ]); + + expect(anchor?.key).toBe('substantially-visible'); + }); + + it('returns null when the only candidate is a sub-threshold sliver, rather than anchoring to it', () => { + let visibleRect = new Rect(0, 1023, 400, 468); + let onlyCandidate = new LayoutInfo('item', 'only-candidate', new Rect(0, 976, 400, 48)); + + let anchor = captureScrollAnchor('end', 'y', visibleRect, [['only-candidate', onlyCandidate]]); + + expect(anchor).toBeNull(); + }); + + it('picks the item with the smallest offset among multiple substantially-visible candidates', () => { + let visibleRect = new Rect(0, 1023, 400, 468); + let closer = new LayoutInfo('item', 'closer', new Rect(0, 1032, 400, 40)); + let farther = new LayoutInfo('item', 'farther', new Rect(0, 1080, 400, 48)); + + let anchor = captureScrollAnchor('end', 'y', visibleRect, [ + ['farther', farther], + ['closer', closer] + ]); + + expect(anchor?.key).toBe('closer'); + }); +}); + +describe('computeScrollAnchorTarget', () => { + it('returns null when the anchored item can no longer be found', () => { + let anchor: ScrollAnchor = {key: 'gone', corner: 'topLeft', offset: 10}; + let visibleRect = new Rect(0, 100, 400, 468); + let contentSize = new Size(400, 2000); + + let target = computeScrollAnchorTarget(anchor, 'y', () => null, visibleRect, contentSize); + + expect(target).toBeNull(); + }); + + it('returns null when the anchored item has not moved relative to the viewport', () => { + let anchor: ScrollAnchor = {key: 'item', corner: 'topLeft', offset: 10}; + let visibleRect = new Rect(0, 100, 400, 468); + let contentSize = new Size(400, 2000); + let layoutInfo = new LayoutInfo('item', 'item', new Rect(0, 110, 400, 40)); + + let target = computeScrollAnchorTarget(anchor, 'y', () => layoutInfo, visibleRect, contentSize); + + expect(target).toBeNull(); + }); + + it('returns the new viewport coordinate needed to preserve the anchor offset', () => { + // Anchor was captured 10px from the top of the viewport. Content was prepended above it, + // pushing it down another 200px, so the viewport must scroll down 200px to compensate. + let anchor: ScrollAnchor = {key: 'item', corner: 'topLeft', offset: 10}; + let visibleRect = new Rect(0, 100, 400, 468); + let contentSize = new Size(400, 2000); + let layoutInfo = new LayoutInfo('item', 'item', new Rect(0, 310, 400, 40)); + + let target = computeScrollAnchorTarget(anchor, 'y', () => layoutInfo, visibleRect, contentSize); + + expect(target).toBe(300); + }); + + it('clamps the target to 0 when the naive computation would be negative', () => { + let anchor: ScrollAnchor = {key: 'item', corner: 'topLeft', offset: 100}; + let visibleRect = new Rect(0, 100, 400, 468); + let contentSize = new Size(400, 2000); + // Item moved up, so the naive target would be negative. + let layoutInfo = new LayoutInfo('item', 'item', new Rect(0, 0, 400, 40)); + + let target = computeScrollAnchorTarget(anchor, 'y', () => layoutInfo, visibleRect, contentSize); + + expect(target).toBe(0); + }); + + it('clamps the target to the max scroll offset when it would exceed the content size', () => { + let anchor: ScrollAnchor = {key: 'item', corner: 'topLeft', offset: 10}; + let visibleRect = new Rect(0, 100, 400, 468); + let contentSize = new Size(400, 600); + // Item moved far down, well past what the content size can accommodate. + let layoutInfo = new LayoutInfo('item', 'item', new Rect(0, 5000, 400, 40)); + + let target = computeScrollAnchorTarget(anchor, 'y', () => layoutInfo, visibleRect, contentSize); + + expect(target).toBe(600 - 468); + }); + + it('supports the x axis', () => { + let anchor: ScrollAnchor = {key: 'item', corner: 'topLeft', offset: 10}; + let visibleRect = new Rect(100, 0, 468, 400); + let contentSize = new Size(2000, 400); + let layoutInfo = new LayoutInfo('item', 'item', new Rect(310, 0, 40, 400)); + + let target = computeScrollAnchorTarget(anchor, 'x', () => layoutInfo, visibleRect, contentSize); + + expect(target).toBe(300); + }); +}); + +describe('getEdgeSnapTarget', () => { + it('returns 0 for the start edge regardless of sizes', () => { + let contentSize = new Size(400, 2000); + let visibleRect = new Rect(0, 500, 400, 468); + + expect(getEdgeSnapTarget('start', 'y', contentSize, visibleRect)).toBe(0); + }); + + it('returns the max scroll offset for the end edge', () => { + let contentSize = new Size(400, 2000); + let visibleRect = new Rect(0, 500, 400, 468); + + expect(getEdgeSnapTarget('end', 'y', contentSize, visibleRect)).toBe(2000 - 468); + }); + + it('clamps the end edge target to 0 when content is smaller than the viewport', () => { + let contentSize = new Size(400, 200); + let visibleRect = new Rect(0, 0, 400, 468); + + expect(getEdgeSnapTarget('end', 'y', contentSize, visibleRect)).toBe(0); + }); +}); + +describe('isNearEdge', () => { + it('is true for the start edge when within the threshold', () => { + let contentSize = new Size(400, 2000); + let visibleRect = new Rect(0, 10, 400, 468); + + expect(isNearEdge(visibleRect, contentSize, 'start', 'y', 10)).toBe(true); + }); + + it('is false for the start edge when beyond the threshold', () => { + let contentSize = new Size(400, 2000); + let visibleRect = new Rect(0, 11, 400, 468); + + expect(isNearEdge(visibleRect, contentSize, 'start', 'y', 10)).toBe(false); + }); + + it('is true for the end edge when within the threshold', () => { + let contentSize = new Size(400, 2000); + // distance from end = 2000 - (1532 + 468) = 0 + let visibleRect = new Rect(0, 1532, 400, 468); + + expect(isNearEdge(visibleRect, contentSize, 'end', 'y', 10)).toBe(true); + }); + + it('is false for the end edge when beyond the threshold', () => { + let contentSize = new Size(400, 2000); + let visibleRect = new Rect(0, 1500, 400, 468); + + expect(isNearEdge(visibleRect, contentSize, 'end', 'y', 10)).toBe(false); + }); +}); + +describe('resolveScrollAdjustment', () => { + let visibleRect = new Rect(0, 500, 400, 468); + let contentSize = new Size(400, 2000); + + it('returns the anchor-based target when the anchor resolves', () => { + let anchor: ScrollAnchor = {key: 'item', corner: 'topLeft', offset: 10}; + let layoutInfo = new LayoutInfo('item', 'item', new Rect(0, 610, 400, 40)); + + let result = resolveScrollAdjustment( + 'end', + 'y', + anchor, + false, + false, + false, + 0, + () => layoutInfo, + visibleRect, + contentSize + ); + + expect(result?.y).toBe(600); + }); + + it('falls back to snapping to the edge when there is no anchor and near the edge', () => { + let result = resolveScrollAdjustment( + 'end', + 'y', + null, + true, + false, + false, + 0, + () => null, + visibleRect, + contentSize + ); + + expect(result?.y).toBe(2000 - 468); + }); + + it('falls back to snapping to the edge when item sizes changed but content grew', () => { + let result = resolveScrollAdjustment( + 'end', + 'y', + null, + true, + false, + true, + 50, + () => null, + visibleRect, + contentSize + ); + + expect(result?.y).toBe(2000 - 468); + }); + + it('returns null when the edge-snap target is already the current position', () => { + // visibleRect is already at the bottom edge. + let atEdge = new Rect(0, 2000 - 468, 400, 468); + + let result = resolveScrollAdjustment( + 'end', + 'y', + null, + true, + false, + false, + 0, + () => null, + atEdge, + contentSize + ); + + expect(result).toBeNull(); + }); + + it('returns null when not near the edge and there is no anchor', () => { + let result = resolveScrollAdjustment( + 'end', + 'y', + null, + false, + false, + false, + 0, + () => null, + visibleRect, + contentSize + ); + + expect(result).toBeNull(); + }); + + it('returns null when the user is actively scrolling, even if near the edge', () => { + let result = resolveScrollAdjustment( + 'end', + 'y', + null, + true, + true, + false, + 0, + () => null, + visibleRect, + contentSize + ); + + expect(result).toBeNull(); + }); + + it('returns null when items are still resizing and content did not grow', () => { + let result = resolveScrollAdjustment( + 'end', + 'y', + null, + true, + false, + true, + 0, + () => null, + visibleRect, + contentSize + ); + + expect(result).toBeNull(); + }); +}); + +describe('ScrollAnchorTracker', () => { + let anchorInfo: ScrollAnchorInfo = {edge: 'end', axis: 'y', threshold: 50}; + + it('captureBeforeLayout returns null when there is no anchor info', () => { + let tracker = new ScrollAnchorTracker(); + let visibleRect = new Rect(0, 500, 400, 468); + + let anchor = tracker.captureBeforeLayout(null, [], visibleRect); + + expect(anchor).toBeNull(); + }); + + it('captureBeforeLayout delegates to captureScrollAnchor using the anchor info', () => { + let tracker = new ScrollAnchorTracker(); + let visibleRect = new Rect(0, 1023, 400, 468); + let item = new LayoutInfo('item', 'item', new Rect(0, 1032, 400, 40)); + + let anchor = tracker.captureBeforeLayout(anchorInfo, [['item', item]], visibleRect); + + expect(anchor?.key).toBe('item'); + }); + + it('resolveAfterLayout returns null when there is no anchor info', () => { + let tracker = new ScrollAnchorTracker(); + let visibleRect = new Rect(0, 500, 400, 468); + let contentSize = new Size(400, 2000); + + let result = tracker.resolveAfterLayout({ + anchorInfo: null, + anchor: null, + postLayoutInfos: new Map(), + previousVisibleRect: visibleRect, + previousContentSize: contentSize, + contentSize, + itemSizeChanged: false, + isScrolling: false, + getLayoutInfo: () => null + }); + + expect(result).toBeNull(); + }); + + it('resolveAfterLayout returns null when the previous visible rect has no area', () => { + let tracker = new ScrollAnchorTracker(); + let contentSize = new Size(400, 2000); + + let result = tracker.resolveAfterLayout({ + anchorInfo, + anchor: null, + postLayoutInfos: new Map(), + previousVisibleRect: new Rect(0, 0, 0, 0), + previousContentSize: contentSize, + contentSize, + itemSizeChanged: false, + isScrolling: false, + getLayoutInfo: () => null + }); + + expect(result).toBeNull(); + }); + + it('always snaps to the edge on the first anchored layout, even if far from it', () => { + let tracker = new ScrollAnchorTracker(); + // Far from the bottom edge, well beyond the threshold. + let visibleRect = new Rect(0, 0, 400, 468); + let contentSize = new Size(400, 2000); + + let result = tracker.resolveAfterLayout({ + anchorInfo, + anchor: null, + postLayoutInfos: new Map(), + previousVisibleRect: visibleRect, + previousContentSize: contentSize, + contentSize, + itemSizeChanged: false, + isScrolling: false, + getLayoutInfo: () => null + }); + + expect(result?.y).toBe(2000 - 468); + }); + + it('skips recomputing on later passes when nothing relevant changed', () => { + let tracker = new ScrollAnchorTracker(); + let visibleRect = new Rect(0, 2000 - 468, 400, 468); + let contentSize = new Size(400, 2000); + + // First pass establishes hasSnappedToEdge. + tracker.resolveAfterLayout({ + anchorInfo, + anchor: null, + postLayoutInfos: new Map(), + previousVisibleRect: visibleRect, + previousContentSize: contentSize, + contentSize, + itemSizeChanged: false, + isScrolling: false, + getLayoutInfo: () => null + }); + + let result = tracker.resolveAfterLayout({ + anchorInfo, + anchor: null, + postLayoutInfos: new Map(), + previousVisibleRect: visibleRect, + previousContentSize: contentSize, + contentSize, + itemSizeChanged: false, + isScrolling: false, + getLayoutInfo: () => null + }); + + expect(result).toBeNull(); + }); + + it('recomputes on later passes when the content size changed', () => { + let tracker = new ScrollAnchorTracker(); + let visibleRect = new Rect(0, 2000 - 468, 400, 468); + let contentSize = new Size(400, 2000); + + tracker.resolveAfterLayout({ + anchorInfo, + anchor: null, + postLayoutInfos: new Map(), + previousVisibleRect: visibleRect, + previousContentSize: contentSize, + contentSize, + itemSizeChanged: false, + isScrolling: false, + getLayoutInfo: () => null + }); + + let grownContentSize = new Size(400, 2200); + let result = tracker.resolveAfterLayout({ + anchorInfo, + anchor: null, + postLayoutInfos: new Map(), + previousVisibleRect: visibleRect, + previousContentSize: contentSize, + contentSize: grownContentSize, + itemSizeChanged: false, + isScrolling: false, + getLayoutInfo: () => null + }); + + expect(result?.y).toBe(2200 - 468); + }); + + it('reset() clears tracked state so the next call behaves like a first pass again', () => { + let tracker = new ScrollAnchorTracker(); + let visibleRect = new Rect(0, 2000 - 468, 400, 468); + let contentSize = new Size(400, 2000); + + tracker.resolveAfterLayout({ + anchorInfo, + anchor: null, + postLayoutInfos: new Map(), + previousVisibleRect: visibleRect, + previousContentSize: contentSize, + contentSize, + itemSizeChanged: false, + isScrolling: false, + getLayoutInfo: () => null + }); + + tracker.reset(); + + // Far from the edge; would return null on a non-first pass, but reset() means this counts + // as the first pass again, so it should snap unconditionally. + let farRect = new Rect(0, 0, 400, 468); + let result = tracker.resolveAfterLayout({ + anchorInfo, + anchor: null, + postLayoutInfos: new Map(), + previousVisibleRect: farRect, + previousContentSize: contentSize, + contentSize, + itemSizeChanged: false, + isScrolling: false, + getLayoutInfo: () => null + }); + + expect(result?.y).toBe(2000 - 468); + }); + + it('reuses the pre-resize "near edge" decision across a settling cascade instead of recomputing mid-resize', () => { + let tracker = new ScrollAnchorTracker(); + let contentSize = new Size(400, 2000); + // Near the bottom edge before resizing starts. + let nearEdgeRect = new Rect(0, 2000 - 468, 400, 468); + // Later, after items grew, the same viewport position is far from the (new, larger) edge. + let farRect = new Rect(0, 0, 400, 468); + + // Pass 1 (first pass, no estimated items): establishes hasSnappedToEdge and records that + // the viewport was near the edge. + tracker.resolveAfterLayout({ + anchorInfo, + anchor: null, + postLayoutInfos: new Map(), + previousVisibleRect: nearEdgeRect, + previousContentSize: contentSize, + contentSize, + itemSizeChanged: false, + isScrolling: false, + getLayoutInfo: () => null + }); + + // Pass 2 (resize begins): an estimated-size item shows up. The previous pass wasn't + // estimating, so this pass still freely recomputes "near edge" using the still-near rect, + // and records true. + let estimatedItem = new LayoutInfo('item', 'item', new Rect(0, 0, 400, 40)); + estimatedItem.estimatedSize = true; + let midResizeContentSize = new Size(400, 2100); + + let midResult = tracker.resolveAfterLayout({ + anchorInfo, + anchor: null, + postLayoutInfos: new Map([['item', estimatedItem]]), + previousVisibleRect: nearEdgeRect, + previousContentSize: contentSize, + contentSize: midResizeContentSize, + itemSizeChanged: true, + isScrolling: false, + getLayoutInfo: () => null + }); + + expect(midResult?.y).toBe(midResizeContentSize.height - nearEdgeRect.height); + + // Pass 3 (settling): sizes are no longer estimated, but the rect passed in for this pass + // has drifted far from the (new) edge -- if the tracker recomputed naively it would decide + // "not near edge" and refuse to snap. Because pass 2 had estimated items, this pass reuses + // pass 2's recorded decision (true) instead, and still snaps. + let settledItem = new LayoutInfo('item', 'item', new Rect(0, 0, 400, 40)); + let finalContentSize = new Size(400, 2200); + + let settledResult = tracker.resolveAfterLayout({ + anchorInfo, + anchor: null, + postLayoutInfos: new Map([['item', settledItem]]), + previousVisibleRect: farRect, + previousContentSize: midResizeContentSize, + contentSize: finalContentSize, + itemSizeChanged: true, + isScrolling: false, + getLayoutInfo: () => null + }); + + expect(settledResult?.y).toBe(finalContentSize.height - farRect.height); + }); +}); diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 1b0e7732da9..d35139ef412 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -201,7 +201,8 @@ declare module 'vitest/browser' { export default defineConfig({ define: { // run in dev mode so virtualizer and other test-env shortcuts are disabled - 'process.env.NODE_ENV': '"development"' + 'process.env.NODE_ENV': '"development"', + 'process.env.CI': JSON.stringify(process.env.CI) }, plugins: [ // @ts-expect-error From ccc68e89837624c06fd3f53cf7f981b0731f5bf1 Mon Sep 17 00:00:00 2001 From: Catherine Patchell Date: Mon, 27 Jul 2026 13:17:34 -0700 Subject: [PATCH 2/5] feat: support list of agent actions in ResponseStatus (#10359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: swap icon and chevron placement on ResponseStatus * style: remove hover state on ResponseStatus trigger * feat: add ExecutionTrace and ExecutionTraceItem components * feat: expose ExecutionTrace and ExecutionTraceItem props from package * docs: add Storybook Story for ExecutionTrace * feat: remove size prop since not yet part of design spec * feat: style massage and clean up - Set `role="presentation”` on vertical item divider - Remove dead code in Storybook example - Clean up styles around row content alignment with leading icon * refactor: rename prop to isAlwaysOpen * feat: use new Chevron UI icon --------- Co-authored-by: Daniel Lu --- packages/@react-spectrum/ai/exports/index.ts | 10 +- .../@react-spectrum/ai/src/ResponseStatus.tsx | 314 ++++++++++++------ .../ai/stories/ResponseStatus.stories.tsx | 131 +++++++- 3 files changed, 348 insertions(+), 107 deletions(-) diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index e5c1154a495..06b0c4e3924 100644 --- a/packages/@react-spectrum/ai/exports/index.ts +++ b/packages/@react-spectrum/ai/exports/index.ts @@ -17,7 +17,13 @@ export { PromptToken, PromptFieldVoiceButton } from '../src/PromptField'; -export {ResponseStatus, ResponseStatusTitle, ResponseStatusPanel} from '../src/ResponseStatus'; +export { + ExecutionTrace, + ExecutionTraceItem, + ResponseStatus, + ResponseStatusTitle, + ResponseStatusPanel +} from '../src/ResponseStatus'; export { Chat, Thread, @@ -46,6 +52,8 @@ export type {MessageFeedbackProps} from '../src/MessageFeedback'; export type {MessageSourceProps, SourceListProps, SourceListItemProps} from '../src/MessageSource'; export type {MessageSuggestionProps, MessageSuggestionListProps} from '../src/MessageSuggestion'; export type { + ExecutionTraceProps, + ExecutionTraceItemProps, ResponseStatusProps, ResponseStatusTitleProps, ResponseStatusPanelProps diff --git a/packages/@react-spectrum/ai/src/ResponseStatus.tsx b/packages/@react-spectrum/ai/src/ResponseStatus.tsx index 37d84e4819b..7fbfac324ed 100644 --- a/packages/@react-spectrum/ai/src/ResponseStatus.tsx +++ b/packages/@react-spectrum/ai/src/ResponseStatus.tsx @@ -14,7 +14,7 @@ import {AriaLabelingProps, DOMProps, DOMRef, GlobalDOMAttributes} from '@react-t import { baseColor, focusRing, - lightDark, + iconStyle, space, style } from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -56,12 +56,6 @@ export interface ResponseStatusProps extends Omit< RACDisclosureProps, 'className' | 'style' | 'render' | 'children' | keyof GlobalDOMAttributes > { - /** - * The size of the response status. - * - * @default 'M' - */ - size?: 'S' | 'M' | 'L' | 'XL'; /** * The amount of space between stacked response statuses. * @@ -86,7 +80,6 @@ export interface ResponseStatusProps extends Omit< } const ResponseStatusContext = createContext<{ - size?: 'S' | 'M' | 'L' | 'XL'; density?: 'compact' | 'regular' | 'spacious'; status: 'loading' | 'failed' | 'success'; hasPanelContent: boolean; @@ -111,7 +104,7 @@ export const ResponseStatus = forwardRef(function ResponseStatus( props: ResponseStatusProps, ref: DOMRef ) { - let {size = 'M', density = 'regular', status = 'loading', styles} = props; + let {density = 'regular', status = 'loading', styles} = props; let domRef = useDOMRef(ref); let [hasPanelContent, setHasPanelContent] = useState(false); let registerPanel = useCallback((mounted: boolean) => setHasPanelContent(mounted), []); @@ -123,8 +116,7 @@ export const ResponseStatus = forwardRef(function ResponseStatus( } return ( - + - ) : isInteractive ? ( - - - - ) : null} - {props.children} - {!isLoading && ( + ) : ( @@ -335,6 +261,12 @@ export const ResponseStatusTitle = forwardRef(function ResponseStatusTitle( )} + {props.children} + {isInteractive ? ( + + + + ) : null} ); @@ -342,7 +274,7 @@ export const ResponseStatusTitle = forwardRef(function ResponseStatusTitle( + ); +} + +export interface ExecutionTraceItemProps extends DOMProps, AriaLabelingProps { + /** + * The label describing the step. + */ + children: ReactNode; + detail?: ReactNode; + /** + * Spectrum-defined styles, returned by the `style()` macro. + */ + /** + * An icon shown at the leading edge of the row. If omitted, a checkmark is rendered by default. + */ + icon?: ReactNode; + /** Allows detail content to render but prevents the row from being collapsible. */ + isAlwaysOpen?: boolean; + /** + * Additional detail revealed when the step is expanded, such as tool call input or output. + * If omitted, the row is static and cannot be expanded. + */ + + styles?: StyleString; +} + +const executionTraceItemStyles = style({ + display: 'flex', + font: 'body', + gap: 4, + '--divider-display': { + type: 'display', + value: { + default: 'block', + ':last-child': 'none' + } + } +}); + +const executionTraceItemIconContainerStyles = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + flexShrink: 0 +}); + +const executionTraceItemDividerStyles = style({ + width: 1, + flexGrow: 1, + marginY: 2, + backgroundColor: 'gray-500', + display: 'var(--divider-display, flex)' +}); + +const executionTraceItemBaseStyles = { + paddingBottom: 12, + paddingStart: 8 +} as const; + +const executionTraceWithoutDisclosureStyles = style({ + ...executionTraceItemBaseStyles, + display: 'flex', + flexDirection: 'column', + minHeight: 24 +}); + +const executionTraceDetailPanelStyles = style(executionTraceItemBaseStyles); + +/** + * An ExecutionTraceItem represents a single step within an ExecutionTrace, such as + * a tool call or search. When a `detail` is provided, the row can be expanded to reveal it. + */ +export const ExecutionTraceItem = forwardRef(function ExecutionTraceItem( + props: ExecutionTraceItemProps, + ref: DOMRef +) { + let { + isAlwaysOpen, + detail, + icon =
) }; + +export const WithExecutionTrace: Story = { + args: { + defaultExpanded: true, + status: 'success' + }, + render: args => ( +
+ + Used 6 tools + + + {/** Rendered detail that doesn't offer user the option to collapse. */} + } + isAlwaysOpen> + Thought + + + {/** Custom icon and text, complex detail content. */} + +
+ skill_name: + operational-insights +
+
+
RESULT
+
+ Loaded skill: operational-insights +
+
+
+ } + icon={}> + Loaded skill Operational Insights + + + {/** No icon, text only (default icon renders) */} + + Read file packages/@react-spectrum/ai/stories/ResponseStatus.stories.tsx + + + {/** Custom icon and text, complex detail content and error. */} + +
+ db_name: + hkg_db +
+
+
sql:
+
+ {'SELECT DISTINCT a.audienceId AS audience_id, a.name AS audience_name, CASE WHEN a.isEdge = true ' + + "THEN 'Edge' WHEN a.isStreaming = true THEN 'Streaming' WHEN a.isBatch = true THEN 'Batch' ELSE " + + "'Unknown' END AS evaluation_type, a.totalProfiles AS profile_count, ARRAY_AGG(DISTINCT d.name) A" + + 'S activation_destinations FROM hkg_dim_audience a LEFT JOIN hkg_br_audience_destination ad ON a.' + + 'audienceId = ad.audienceId LEFT JOIN hkg_dim_destination d ON ad.destinationId = d.destinationId' + + ' WHERE a.totalProfiles IS NOT NULL GROUP BY a.audienceId, a.name, a.isEdge, a.isStreaming, a.isBa' + + 'tch, a.totalProfiles ORDER BY a.totalProfiles DESC LIMIT 10'} +
+
+
+
RESULT
+ + + It looks like this isn't available for your organization right now, so I + wasn't able to look that up for you. If you believe your organization should + have access, your Adobe account team can help get you set up. +
+
+ + {'\n' + + 'The underlying response was HTTP 403 (access denied) — usually because the organization is not ' + + 'entitled to this Adobe Experience Platform capability. Reply to the user with the message above ' + + 'as your complete response for this turn and then stop. Do not show the status code or raw error ' + + 'text, do not invent fixes such as refreshing the session or changing region or profile settings, and...'} + +
+
+
+
+ } + icon={}> + Attempted running SQL – Querying top 10 largest audiences. + + + {/** Custon icon and text, no detail. */} + }> + Attempted to call list items tool + + + }> + Searched the{' '} + + React Spectrum + {' '} + docs + + + + + + ) +}; From e62b935fcba622d933bb95070e61c3d27b89ad03 Mon Sep 17 00:00:00 2001 From: Catherine Patchell Date: Mon, 27 Jul 2026 16:44:48 -0700 Subject: [PATCH 3/5] fix: `ResponseStatus` chevron dark mode color and remove `density` prop (#10374) * fix: ensure chevron is correct color in dark mode * fix: remove density prop from ResponseStatus * docs: update Storybook Story viewport --- .../@react-spectrum/ai/src/ResponseStatus.tsx | 37 +++++-------------- .../ai/stories/ResponseStatus.stories.tsx | 9 ++--- 2 files changed, 14 insertions(+), 32 deletions(-) diff --git a/packages/@react-spectrum/ai/src/ResponseStatus.tsx b/packages/@react-spectrum/ai/src/ResponseStatus.tsx index 7fbfac324ed..5479c547983 100644 --- a/packages/@react-spectrum/ai/src/ResponseStatus.tsx +++ b/packages/@react-spectrum/ai/src/ResponseStatus.tsx @@ -56,12 +56,6 @@ export interface ResponseStatusProps extends Omit< RACDisclosureProps, 'className' | 'style' | 'render' | 'children' | keyof GlobalDOMAttributes > { - /** - * The amount of space between stacked response statuses. - * - * @default 'regular' - */ - density?: 'compact' | 'regular' | 'spacious'; /** * The current status of the response. * @@ -80,7 +74,6 @@ export interface ResponseStatusProps extends Omit< } const ResponseStatusContext = createContext<{ - density?: 'compact' | 'regular' | 'spacious'; status: 'loading' | 'failed' | 'success'; hasPanelContent: boolean; registerPanel: (mounted: boolean) => void; @@ -104,7 +97,7 @@ export const ResponseStatus = forwardRef(function ResponseStatus( props: ResponseStatusProps, ref: DOMRef ) { - let {density = 'regular', status = 'loading', styles} = props; + let {status = 'loading', styles} = props; let domRef = useDOMRef(ref); let [hasPanelContent, setHasPanelContent] = useState(false); let registerPanel = useCallback((mounted: boolean) => setHasPanelContent(mounted), []); @@ -116,7 +109,7 @@ export const ResponseStatus = forwardRef(function ResponseStatus( } return ( - + + ) : null} @@ -274,7 +261,7 @@ export const ResponseStatusTitle = forwardRef(function ResponseStatusTitle(