From f1d3a8d353c4b5a41a463b08b19d69e5f7e1a1d8 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sat, 25 May 2024 13:05:43 -0700 Subject: [PATCH 1/2] Refactor Virtualizer to improve performance, stability, and complexity --- packages/@react-aria/focus/src/FocusScope.tsx | 32 +- .../@react-aria/focus/test/FocusScope.test.js | 65 + .../selection/src/useSelectableCollection.ts | 33 +- packages/@react-aria/utils/src/useEvent.ts | 2 +- .../virtualizer/src/ScrollView.tsx | 4 +- .../virtualizer/src/Virtualizer.tsx | 127 +- .../virtualizer/src/VirtualizerItem.tsx | 4 - .../actionbar/test/ActionBar.test.js | 3 +- .../src/MobileSearchAutocomplete.tsx | 2 +- .../autocomplete/src/SearchAutocomplete.tsx | 3 +- .../@react-spectrum/card/src/BaseLayout.tsx | 39 +- .../@react-spectrum/card/src/CardView.tsx | 74 +- .../@react-spectrum/card/src/GridLayout.tsx | 35 - .../card/src/WaterfallLayout.tsx | 4 +- .../card/test/CardView.test.js | 27 +- .../@react-spectrum/combobox/src/ComboBox.tsx | 3 +- .../combobox/src/MobileComboBox.tsx | 2 +- .../@react-spectrum/list/src/ListView.tsx | 111 +- .../@react-spectrum/list/src/ListViewItem.tsx | 2 +- .../list/test/ListView.test.js | 32 +- .../@react-spectrum/listbox/src/ListBox.tsx | 2 +- .../listbox/src/ListBoxBase.tsx | 105 +- .../listbox/src/ListBoxContext.ts | 11 +- .../listbox/src/ListBoxOption.tsx | 19 +- .../listbox/src/ListBoxSection.tsx | 2 +- .../@react-spectrum/picker/src/Picker.tsx | 3 +- .../picker/test/Picker.test.js | 13 +- .../table/src/TableViewBase.tsx | 274 ++-- .../@react-spectrum/table/test/Table.test.js | 10 +- .../table/test/TableDnd.test.js | 12 +- .../table/test/TreeGridTable.test.tsx | 9 +- .../@react-stately/layout/src/ListLayout.ts | 81 +- .../@react-stately/layout/src/TableLayout.ts | 21 +- .../@react-stately/virtualizer/src/Layout.ts | 65 +- .../virtualizer/src/LayoutInfo.ts | 4 +- .../virtualizer/src/ReusableView.ts | 35 +- .../virtualizer/src/Transaction.ts | 28 - .../virtualizer/src/Virtualizer.ts | 1212 +++-------------- .../@react-stately/virtualizer/src/tween.ts | 83 -- .../@react-stately/virtualizer/src/types.ts | 54 +- .../virtualizer/src/useVirtualizerState.ts | 81 +- .../@react-stately/virtualizer/src/utils.ts | 52 - .../stories/ListBox.stories.tsx | 3 +- 43 files changed, 848 insertions(+), 1935 deletions(-) delete mode 100644 packages/@react-stately/virtualizer/src/Transaction.ts delete mode 100644 packages/@react-stately/virtualizer/src/tween.ts diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 44809030abf..b2f252e5b59 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -65,6 +65,7 @@ interface IFocusContext { } const FocusContext = React.createContext(null); +const RESTORE_FOCUS_EVENT = 'react-aria-focus-scope-restore'; let activeScope: ScopeRef = null; @@ -117,12 +118,21 @@ export function FocusScope(props: FocusScopeProps) { // Find all rendered nodes between the sentinels and add them to the scope. let node = startRef.current?.nextSibling!; let nodes: Element[] = []; + let stopPropagation = e => e.stopPropagation(); while (node && node !== endRef.current) { nodes.push(node as Element); + // Stop custom restore focus event from propagating to parent focus scopes. + node.addEventListener(RESTORE_FOCUS_EVENT, stopPropagation); node = node.nextSibling as Element; } scopeRef.current = nodes; + + return () => { + for (let node of nodes) { + node.removeEventListener(RESTORE_FOCUS_EVENT, stopPropagation); + } + }; }, [children]); useActiveScopeTracker(scopeRef, restoreFocus, contain); @@ -470,7 +480,7 @@ function focusElement(element: FocusableElement | null, scroll = false) { } } -function focusFirstInScope(scope: Element[], tabbable:boolean = true) { +function getFirstInScope(scope: Element[], tabbable = true) { let sentinel = scope[0].previousElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable}, scope); @@ -485,7 +495,11 @@ function focusFirstInScope(scope: Element[], tabbable:boolean = true) { nextNode = walker.nextNode(); } - focusElement(nextNode as FocusableElement); + return nextNode as FocusableElement; +} + +function focusFirstInScope(scope: Element[], tabbable:boolean = true) { + focusElement(getFirstInScope(scope, tabbable)); } function useAutoFocus(scopeRef: RefObject, autoFocus?: boolean) { @@ -692,7 +706,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, let treeNode = clonedTree.getTreeNode(scopeRef); while (treeNode) { if (treeNode.nodeToRestore && treeNode.nodeToRestore.isConnected) { - focusElement(treeNode.nodeToRestore); + restoreFocusToElement(treeNode.nodeToRestore); return; } treeNode = treeNode.parent; @@ -703,7 +717,8 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, treeNode = clonedTree.getTreeNode(scopeRef); while (treeNode) { if (treeNode.scopeRef && treeNode.scopeRef.current && focusScopeTree.getTreeNode(treeNode.scopeRef)) { - focusFirstInScope(treeNode.scopeRef.current, true); + let node = getFirstInScope(treeNode.scopeRef.current, true); + restoreFocusToElement(node); return; } treeNode = treeNode.parent; @@ -715,6 +730,15 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, }, [scopeRef, restoreFocus]); } +function restoreFocusToElement(node: FocusableElement) { + // Dispatch a custom event that parent elements can intercept to customize focus restoration. + // For example, virtualized collection components reuse DOM elements, so the original element + // might still exist in the DOM but representing a different item. + if (node.dispatchEvent(new CustomEvent(RESTORE_FOCUS_EVENT, {bubbles: true, cancelable: true}))) { + focusElement(node); + } +} + /** * Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker} * that matches all focusable/tabbable elements. diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 1520c518e8c..e5d62781e05 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -19,6 +19,7 @@ import {Provider} from '@react-spectrum/provider'; import React, {useEffect, useState} from 'react'; import ReactDOM from 'react-dom'; import {Example as StorybookExample} from '../stories/FocusScope.stories'; +import {useEvent} from '@react-aria/utils'; import userEvent from '@testing-library/user-event'; @@ -764,6 +765,70 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(button2); expect(input1).not.toBeInTheDocument(); }); + + it('should allow restoration to be overridden with a custom event', async function () { + function Test() { + let [show, setShow] = React.useState(false); + let ref = React.useRef(null); + useEvent(ref, 'react-aria-focus-scope-restore', e => { + e.preventDefault(); + }); + + return ( +
+ + {show && + setShow(false)} /> + } +
+ ); + } + + let {getByRole} = render(); + let button = getByRole('button'); + await user.click(button); + + let input = getByRole('textbox'); + expect(document.activeElement).toBe(input); + + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + expect(input).not.toBeInTheDocument(); + expect(document.activeElement).toBe(document.body); + }); + + it('should not bubble focus scope restoration event out of nested focus scopes', async function () { + function Test() { + let [show, setShow] = React.useState(false); + let ref = React.useRef(null); + useEvent(ref, 'react-aria-focus-scope-restore', e => { + e.preventDefault(); + }); + + return ( +
+ + + {show && + setShow(false)} /> + } + +
+ ); + } + + let {getByRole} = render(); + let button = getByRole('button'); + await user.click(button); + + let input = getByRole('textbox'); + expect(document.activeElement).toBe(input); + + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + expect(input).not.toBeInTheDocument(); + expect(document.activeElement).toBe(button); + }); }); describe('auto focus', function () { diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 1996b7e58b5..9a0963ca317 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -293,6 +293,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions }; // Store the scroll position so we can restore it later. + /// TODO: should this happen all the time?? let scrollPos = useRef({top: 0, left: 0}); useEvent(scrollRef, 'scroll', isVirtualized ? null : () => { scrollPos.current = { @@ -342,7 +343,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions scrollRef.current.scrollLeft = scrollPos.current.left; } - if (!isVirtualized && manager.focusedKey != null) { + if (manager.focusedKey != null) { // Refocus and scroll the focused item into view if it exists within the scrollable region. let element = scrollRef.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement; if (element) { @@ -400,17 +401,21 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // If not virtualized, scroll the focused element into view when the focusedKey changes. - // When virtualized, Virtualizer handles this internally. + // Scroll the focused element into view when the focusedKey changes. let lastFocusedKey = useRef(manager.focusedKey); useEffect(() => { - let modality = getInteractionModality(); - if (manager.isFocused && manager.focusedKey != null && scrollRef?.current) { - let element = scrollRef.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement; - if (element && (modality === 'keyboard' || autoFocusRef.current)) { - if (!isVirtualized) { - scrollIntoView(scrollRef.current, element); - } + if (manager.isFocused && manager.focusedKey != null && manager.focusedKey !== lastFocusedKey.current && scrollRef?.current) { + let modality = getInteractionModality(); + let element = ref.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement; + if (!element) { + // If item element wasn't found, return early (don't update autoFocusRef and lastFocusedKey). + // The collection may initially be empty (e.g. virtualizer), so wait until the element exists. + return; + } + + if (modality === 'keyboard' || autoFocusRef.current) { + scrollIntoView(scrollRef.current, element); + // Avoid scroll in iOS VO, since it may cause overlay to close (i.e. RAC submenu) if (modality !== 'virtual') { scrollIntoViewport(element, {containingElement: ref.current}); @@ -425,7 +430,13 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions lastFocusedKey.current = manager.focusedKey; autoFocusRef.current = false; - }, [isVirtualized, scrollRef, manager.focusedKey, manager.isFocused, ref]); + }); + + // Intercept FocusScope restoration since virtualized collections can reuse DOM nodes. + useEvent(ref, 'react-aria-focus-scope-restore', e => { + e.preventDefault(); + manager.setFocused(true); + }); let handlers = { onKeyDown, diff --git a/packages/@react-aria/utils/src/useEvent.ts b/packages/@react-aria/utils/src/useEvent.ts index d4beec4f99c..f2c25811e61 100644 --- a/packages/@react-aria/utils/src/useEvent.ts +++ b/packages/@react-aria/utils/src/useEvent.ts @@ -15,7 +15,7 @@ import {useEffectEvent} from './useEffectEvent'; export function useEvent( ref: RefObject, - event: K, + event: K | (string & {}), handler?: (this: Document, ev: GlobalEventHandlersEventMap[K]) => any, options?: boolean | AddEventListenerOptions ) { diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index e2bf1a171fc..0e007e6ca98 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -155,7 +155,7 @@ function ScrollView(props: ScrollViewProps, ref: RefObject) { updateSize(); }, [updateSize]); let raf = useRef | null>(); - let onResize = () => { + let onResize = useCallback(() => { if (isOldReact) { raf.current ??= requestAnimationFrame(() => { updateSize(); @@ -164,7 +164,7 @@ function ScrollView(props: ScrollViewProps, ref: RefObject) { } else { updateSize(); } - }; + }, [updateSize]); useResizeObserver({ref, onResize}); useEffect(() => { return () => { diff --git a/packages/@react-aria/virtualizer/src/Virtualizer.tsx b/packages/@react-aria/virtualizer/src/Virtualizer.tsx index abbcbfdd23a..dc54bda83b0 100644 --- a/packages/@react-aria/virtualizer/src/Virtualizer.tsx +++ b/packages/@react-aria/virtualizer/src/Virtualizer.tsx @@ -11,14 +11,13 @@ */ import {Collection, Key} from '@react-types/shared'; -import {getInteractionModality} from '@react-aria/interactions'; import {Layout, Rect, ReusableView, useVirtualizerState, VirtualizerState} from '@react-stately/virtualizer'; import {mergeProps, useLayoutEffect} from '@react-aria/utils'; -import React, {createContext, FocusEvent, HTMLAttributes, ReactElement, ReactNode, RefObject, useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {createContext, HTMLAttributes, ReactElement, ReactNode, RefObject, useCallback, useMemo, useRef} from 'react'; import {ScrollView} from './ScrollView'; import {VirtualizerItem} from './VirtualizerItem'; -interface VirtualizerProps extends Omit, 'children'> { +interface VirtualizerProps extends Omit, 'children'> { children: (type: string, content: T) => V, renderWrapper?: ( parent: ReusableView | null, @@ -26,22 +25,19 @@ interface VirtualizerProps extends Omit[], renderChildren: (views: ReusableView[]) => ReactElement[] ) => ReactElement, - layout: Layout, + layout: Layout, collection: Collection, focusedKey?: Key, sizeToFit?: 'width' | 'height', scrollDirection?: 'horizontal' | 'vertical' | 'both', - transitionDuration?: number, isLoading?: boolean, onLoadMore?: () => void, - shouldUseVirtualFocus?: boolean, - scrollToItem?: (key: Key) => void, - autoFocus?: boolean + layoutOptions?: O } export const VirtualizerContext = createContext | null>(null); -function Virtualizer(props: VirtualizerProps, ref: RefObject) { +function Virtualizer(props: VirtualizerProps, ref: RefObject) { let { children: renderView, renderWrapper, @@ -49,19 +45,12 @@ function Virtualizer(props: VirtualizerPr collection, sizeToFit, scrollDirection, - transitionDuration, // eslint-disable-next-line @typescript-eslint/no-unused-vars isLoading, // eslint-disable-next-line @typescript-eslint/no-unused-vars onLoadMore, - // eslint-disable-next-line @typescript-eslint/no-unused-vars focusedKey, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - shouldUseVirtualFocus, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - scrollToItem, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - autoFocus, + layoutOptions, ...otherProps } = props; @@ -69,7 +58,6 @@ function Virtualizer(props: VirtualizerPr ref = ref || fallbackRef; let state = useVirtualizerState({ - transitionDuration, layout, collection, renderView, @@ -77,7 +65,9 @@ function Virtualizer(props: VirtualizerPr onVisibleRectChange(rect) { ref.current.scrollLeft = rect.x; ref.current.scrollTop = rect.y; - } + }, + persistedKeys: useMemo(() => focusedKey ? new Set([focusedKey]) : new Set(), [focusedKey]), + layoutOptions }); let {virtualizerProps, scrollViewProps} = useVirtualizer(props, state, ref); @@ -86,7 +76,6 @@ function Virtualizer(props: VirtualizerPr (props: VirtualizerPr interface VirtualizerOptions { tabIndex?: number, focusedKey?: Key, - scrollToItem?: (key: Key) => void, - shouldUseVirtualFocus?: boolean, - autoFocus?: boolean, isLoading?: boolean, onLoadMore?: () => void } +// eslint-disable-next-line @typescript-eslint/no-unused-vars export function useVirtualizer(props: VirtualizerOptions, state: VirtualizerState, ref: RefObject) { - let {focusedKey, scrollToItem, shouldUseVirtualFocus, isLoading, onLoadMore} = props; - let {virtualizer} = state; - // Scroll to the focusedKey when it changes. Actually focusing the focusedKey - // is up to the implementation using Virtualizer since we don't have refs - // to all of the item DOM nodes. - let lastFocusedKey = useRef(null); - let isFocusWithin = useRef(false); - let autoFocus = useRef(props.autoFocus); - useEffect(() => { - if (virtualizer.visibleRect.height === 0) { - return; - } - - // Only scroll the focusedKey into view if the modality is not pointer to avoid jumps in position when clicking/pressing tall items. - let modality = getInteractionModality(); - if (focusedKey !== lastFocusedKey.current && (modality !== 'pointer' || autoFocus.current)) { - autoFocus.current = false; - if (scrollToItem) { - // If user provides scrolltoitem, then it is their responsibility to call scrollIntoViewport if desired - // since we don't know if their scrollToItem may take some time to actually bring the active element into the virtualizer's visible rect. - scrollToItem(focusedKey); - } else { - virtualizer.scrollToItem(focusedKey, {duration: 0}); - - } - } - - lastFocusedKey.current = focusedKey; - }, [focusedKey, virtualizer.visibleRect.height, virtualizer, lastFocusedKey, scrollToItem, ref]); - - // Persist the focusedKey and prevent it from being removed from the DOM when scrolled out of view. - virtualizer.persistedKeys = useMemo(() => focusedKey ? new Set([focusedKey]) : new Set(), [focusedKey]); - - let onFocus = useCallback((e: FocusEvent) => { - // If the focused item is scrolled out of view and is not in the DOM, the collection - // will have tabIndex={0}. When tabbing in from outside, scroll the focused item into view. - // Ignore focus events that bubble through portals (e.g. focus that happens on a menu popover child of the virtualizer) - // Don't scroll focused key into view if modality is pointer to prevent sudden jump in position (e.g. CardView). - let modality = getInteractionModality(); - if (!isFocusWithin.current && ref.current.contains(e.target) && modality !== 'pointer') { - if (scrollToItem) { - scrollToItem(focusedKey); - } else { - virtualizer.scrollToItem(focusedKey, {duration: 0}); - } - } - - isFocusWithin.current = e.target !== ref.current; - }, [ref, virtualizer, focusedKey, scrollToItem]); - - let onBlur = useCallback((e: FocusEvent) => { - isFocusWithin.current = ref.current.contains(e.relatedTarget as Element); - }, [ref]); - - // Set tabIndex to -1 if there is a focused key, otherwise 0 so that the collection - // itself is tabbable. When the collection receives focus, we scroll the focused item back into - // view, which will allow it to be properly focused. If using virtual focus, don't set a - // tabIndex at all so that VoiceOver on iOS 14 doesn't try to move real DOM focus to the element anyway. - let tabIndex: number; - if (!shouldUseVirtualFocus) { - // When there is no focusedKey the default tabIndex is 0. We include logic for empty collections too. - // For collections that are empty, but have a link in the empty children we want to skip focusing this - // and let focus move to the link similar to link moving to children. - tabIndex = focusedKey != null ? -1 : 0; - - // If the collection is empty, we want the tabIndex provided from props (if any) - // so that we handle when tabbable items are added to the empty state. - if (virtualizer.collection.size === 0 && props.tabIndex != null) { - tabIndex = props.tabIndex; - } - } + let {isLoading, onLoadMore} = props; + let {setVisibleRect, virtualizer} = state; // Handle scrolling, and call onLoadMore when nearing the bottom. let isLoadingRef = useRef(isLoading); let prevProps = useRef(props); let onVisibleRectChange = useCallback((rect: Rect) => { - state.setVisibleRect(rect); + setVisibleRect(rect); if (!isLoadingRef.current && onLoadMore) { - let scrollOffset = state.virtualizer.contentSize.height - rect.height * 2; + let scrollOffset = virtualizer.contentSize.height - rect.height * 2; if (rect.y > scrollOffset) { isLoadingRef.current = true; onLoadMore(); } } - }, [onLoadMore, state]); + }, [onLoadMore, setVisibleRect, virtualizer]); let lastContentSize = useRef(0); useLayoutEffect(() => { - // If animating, wait until we're done. - if (state.isAnimating) { - return; - } - // Only update isLoadingRef if props object actually changed, // not if a local state change occurred. let wasLoading = isLoadingRef.current; @@ -225,14 +138,10 @@ export function useVirtualizer(props: onLoadMore(); } lastContentSize.current = state.contentSize.height; - }, [state.contentSize, state.isAnimating, state.virtualizer, isLoading, onLoadMore, props]); + }, [state.contentSize, state.virtualizer, isLoading, onLoadMore, props]); return { - virtualizerProps: { - tabIndex, - onFocus, - onBlur - }, + virtualizerProps: {}, scrollViewProps: { onVisibleRectChange } @@ -241,7 +150,7 @@ export function useVirtualizer(props: // forwardRef doesn't support generic parameters, so cast the result to the correct type // https://stackoverflow.com/questions/58469229/react-with-typescript-generics-while-using-react-forwardref -const _Virtualizer = React.forwardRef(Virtualizer) as (props: VirtualizerProps & {ref?: RefObject}) => ReactElement; +const _Virtualizer = React.forwardRef(Virtualizer) as (props: VirtualizerProps & {ref?: RefObject}) => ReactElement; export {_Virtualizer as Virtualizer}; function defaultRenderWrapper( diff --git a/packages/@react-aria/virtualizer/src/VirtualizerItem.tsx b/packages/@react-aria/virtualizer/src/VirtualizerItem.tsx index b7f39ad7fb7..5ac63a68417 100644 --- a/packages/@react-aria/virtualizer/src/VirtualizerItem.tsx +++ b/packages/@react-aria/virtualizer/src/VirtualizerItem.tsx @@ -75,10 +75,6 @@ export function layoutInfoToStyle(layoutInfo: LayoutInfo, dir: Direction, parent // Sticky elements are positioned in normal document flow. Display inline-block so that they don't push other sticky columns onto the following rows. display: layoutInfo.isSticky ? 'inline-block' : undefined, overflow: layoutInfo.allowOverflow ? 'visible' : 'hidden', - transition: 'all', - WebkitTransition: 'all', - WebkitTransitionDuration: 'inherit', - transitionDuration: 'inherit', opacity: layoutInfo.opacity, zIndex: layoutInfo.zIndex, transform: layoutInfo.transform, diff --git a/packages/@react-spectrum/actionbar/test/ActionBar.test.js b/packages/@react-spectrum/actionbar/test/ActionBar.test.js index c3b1a4e3293..1e9f4b3df06 100644 --- a/packages/@react-spectrum/actionbar/test/ActionBar.test.js +++ b/packages/@react-spectrum/actionbar/test/ActionBar.test.js @@ -172,6 +172,7 @@ describe('ActionBar', () => { let table = tree.getByRole('grid'); let rows = within(table).getAllByRole('row'); + expect(within(rows[1]).getByRole('rowheader')).toHaveTextContent('Foo 1'); let checkbox = within(rows[1]).getByRole('checkbox'); await user.tab(); @@ -203,11 +204,11 @@ describe('ActionBar', () => { act(() => jest.runAllTimers()); act(() => jest.runAllTimers()); await act(async () => Promise.resolve()); - expect(rows[1]).not.toBeInTheDocument(); rows = within(table).getAllByRole('row'); expect(toolbar).not.toBeInTheDocument(); expect(document.activeElement).toBe(rows[1]); + expect(within(rows[1]).getByRole('rowheader')).toHaveTextContent('Foo 2'); }); it('should restore focus to the new first row if the row we wanted to restore to was removed via actiongroup menu', async () => { diff --git a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx index 086d20b7a9b..d421719dda9 100644 --- a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx +++ b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx @@ -394,7 +394,7 @@ function SearchAutocompleteTray(props: SearchAutocompleteTrayProps) { let popoverRef = useRef(null); let listBoxRef = useRef(null); let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; - let layout = useListBoxLayout(state, isLoading); + let layout = useListBoxLayout(state); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/autocomplete'); let {inputProps, listBoxProps, labelProps, clearButtonProps} = useSearchAutocomplete( diff --git a/packages/@react-spectrum/autocomplete/src/SearchAutocomplete.tsx b/packages/@react-spectrum/autocomplete/src/SearchAutocomplete.tsx index ed8270c0425..fa87f178c11 100644 --- a/packages/@react-spectrum/autocomplete/src/SearchAutocomplete.tsx +++ b/packages/@react-spectrum/autocomplete/src/SearchAutocomplete.tsx @@ -100,7 +100,7 @@ function _SearchAutocompleteBase(props: SpectrumSearchAutocomp validate: useCallback(v => validate?.(v.inputValue), [validate]) } ); - let layout = useListBoxLayout(state, loadingState === 'loadingMore'); + let layout = useListBoxLayout(state); let {inputProps, listBoxProps, labelProps, clearButtonProps, descriptionProps, errorMessageProps, isInvalid, validationErrors, validationDetails} = useSearchAutocomplete( { @@ -179,6 +179,7 @@ function _SearchAutocompleteBase(props: SpectrumSearchAutocomp state={state} shouldUseVirtualFocus isLoading={loadingState === 'loading' || loadingState === 'loadingMore'} + showLoadingSpinner={loadingState === 'loadingMore'} onLoadMore={onLoadMore} renderEmptyState={() => isAsync && ( diff --git a/packages/@react-spectrum/card/src/BaseLayout.tsx b/packages/@react-spectrum/card/src/BaseLayout.tsx index 93e47156f48..d2f6eb9e9c0 100644 --- a/packages/@react-spectrum/card/src/BaseLayout.tsx +++ b/packages/@react-spectrum/card/src/BaseLayout.tsx @@ -27,7 +27,12 @@ export interface BaseLayoutOptions { margin?: number } -export class BaseLayout extends Layout> implements KeyboardDelegate { +interface CardViewLayoutOptions { + isLoading: boolean, + direction: Direction +} + +export class BaseLayout extends Layout, CardViewLayoutOptions> implements KeyboardDelegate { protected contentSize: Size; protected layoutInfos: Map; protected collator: Intl.Collator; @@ -48,8 +53,10 @@ export class BaseLayout extends Layout> implements KeyboardDelegate { this.margin = options.margin || 24; } - validate(invalidationContext: InvalidationContext, unknown>) { + validate(invalidationContext: InvalidationContext) { this.collection = this.virtualizer.collection as GridCollection; + this.isLoading = invalidationContext.layoutOptions.isLoading; + this.direction = invalidationContext.layoutOptions.direction; this.buildCollection(invalidationContext); // Remove layout info that doesn't exist in new collection @@ -73,7 +80,7 @@ export class BaseLayout extends Layout> implements KeyboardDelegate { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - buildCollection(invalidationContext?: InvalidationContext, unknown>) {} + buildCollection(invalidationContext?: InvalidationContext) {} getContentSize() { return this.contentSize; @@ -83,11 +90,11 @@ export class BaseLayout extends Layout> implements KeyboardDelegate { return this.layoutInfos.get(key); } - getVisibleLayoutInfos(rect) { + getVisibleLayoutInfos(rect: Rect, excludePersistedKeys = false) { let res: LayoutInfo[] = []; for (let layoutInfo of this.layoutInfos.values()) { - if (this.isVisible(layoutInfo, rect)) { + if (this.isVisible(layoutInfo, rect, excludePersistedKeys)) { res.push(layoutInfo); } } @@ -95,24 +102,20 @@ export class BaseLayout extends Layout> implements KeyboardDelegate { return res; } - isVisible(layoutInfo: LayoutInfo, rect: Rect) { - return layoutInfo.rect.intersects(rect); - } + isVisible(layoutInfo: LayoutInfo, rect: Rect, excludePersistedKeys: boolean) { + if (layoutInfo.rect.intersects(rect)) { + return true; + } - getInitialLayoutInfo(layoutInfo: LayoutInfo) { - layoutInfo.opacity = 0; - layoutInfo.transform = 'scale3d(0.8, 0.8, 0.8)'; - return layoutInfo; - } + if (!excludePersistedKeys) { + return this.virtualizer.isPersistedKey(layoutInfo.key); + } - getFinalLayoutInfo(layoutInfo: LayoutInfo) { - layoutInfo.opacity = 0; - layoutInfo.transform = 'scale3d(0.8, 0.8, 0.8)'; - return layoutInfo; + return false; } _findClosestLayoutInfo(target: Rect, rect: Rect) { - let layoutInfos = this.getVisibleLayoutInfos(rect); + let layoutInfos = this.getVisibleLayoutInfos(rect, true); let best = null; let bestDistance = Infinity; diff --git a/packages/@react-spectrum/card/src/CardView.tsx b/packages/@react-spectrum/card/src/CardView.tsx index 5646b9bc160..ec63e71b647 100644 --- a/packages/@react-spectrum/card/src/CardView.tsx +++ b/packages/@react-spectrum/card/src/CardView.tsx @@ -47,7 +47,6 @@ function CardView(props: SpectrumCardViewProps, ref: DOMRef let cardViewLayout = useMemo(() => typeof layout === 'function' ? new layout({collator, cardOrientation, scale}) : layout, [layout, collator, cardOrientation, scale]); let layoutType = cardViewLayout.layoutType; - let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/card'); let {direction} = useLocale(); let {collection} = useListState(props); @@ -79,8 +78,6 @@ function CardView(props: SpectrumCardViewProps, ref: DOMRef cardViewLayout.collection = gridCollection; cardViewLayout.disabledKeys = state.disabledKeys; - cardViewLayout.isLoading = isLoading; - cardViewLayout.direction = direction; let {gridProps} = useGrid({ ...props, @@ -89,7 +86,7 @@ function CardView(props: SpectrumCardViewProps, ref: DOMRef }, state, domRef); type View = ReusableView, ReactNode>; - let renderWrapper = (parent: View, reusableView: View) => ( + let renderWrapper = useCallback((parent: View, reusableView: View) => ( (props: SpectrumCardViewProps, ref: DOMRef parent={parent?.layoutInfo}> {reusableView.rendered} - ); + ), []); let focusedKey = state.selectionManager.focusedKey; let focusedItem = gridCollection.getItem(state.selectionManager.focusedKey); @@ -105,18 +102,9 @@ function CardView(props: SpectrumCardViewProps, ref: DOMRef focusedKey = focusedItem.parentKey; } - let margin = cardViewLayout.margin || 0; - let virtualizer = cardViewLayout.virtualizer; - let scrollToItem = useCallback((key) => { - virtualizer && virtualizer.scrollToItem(key, { - duration: 0, - offsetY: margin - }); - }, [margin, virtualizer]); - // TODO: does aria-row count and aria-col count need to be modified? Perhaps aria-col count needs to be omitted return ( - + (props: SpectrumCardViewProps, ref: DOMRef collection={gridCollection} isLoading={isLoading} onLoadMore={onLoadMore} + layoutOptions={useMemo(() => ({isLoading, direction}), [isLoading, direction])} renderWrapper={renderWrapper} - transitionDuration={isLoading ? 160 : 220} - scrollToItem={scrollToItem}> - {(type, item) => { + style={{ + ...styleProps.style, + scrollPaddingTop: cardViewLayout.margin || 0 + }}> + {useCallback((type, item) => { if (type === 'item') { return ( ); } else if (type === 'loader') { - return ( - - 0 ? stringFormatter.format('loadingMore') : stringFormatter.format('loading')} /> - - ); + return ; } else if (type === 'placeholder') { - let emptyState = renderEmptyState ? renderEmptyState() : null; - if (emptyState == null) { - return null; - } - - return ( - - {emptyState} - - ); + return ; } - }} + }, [])} ); } +function LoadingState() { + let {state} = useCardViewContext(); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/card'); + return ( + + 0 ? stringFormatter.format('loadingMore') : stringFormatter.format('loading')} /> + + ); +} + +function EmptyState() { + let {renderEmptyState} = useCardViewContext(); + let emptyState = renderEmptyState ? renderEmptyState() : null; + if (emptyState == null) { + return null; + } + + return ( + + {emptyState} + + ); +} + function CenteredWrapper({children}) { let {state} = useCardViewContext(); return ( diff --git a/packages/@react-spectrum/card/src/GridLayout.tsx b/packages/@react-spectrum/card/src/GridLayout.tsx index 7c6517f1874..682c5520236 100644 --- a/packages/@react-spectrum/card/src/GridLayout.tsx +++ b/packages/@react-spectrum/card/src/GridLayout.tsx @@ -130,41 +130,6 @@ export class GridLayout extends BaseLayout { ); } - getVisibleLayoutInfos(rect) { - let res: LayoutInfo[] = []; - let numItems = this.collection.size; - if (numItems <= 0 || !this.itemSize) { - // If there aren't any items in the collection, we are in a loader/placeholder state. Return those layoutInfos as - // the currently visible items - if (this.layoutInfos.size > 0) { - for (let layoutInfo of this.layoutInfos.values()) { - if (this.isVisible(layoutInfo, rect)) { - res.push(layoutInfo); - } - } - } - } else { - // The approach from v2 uses indexes where other v3 layouts iterate through every node/root node. This feels more efficient - let firstVisibleItem = this.getIndexAtPoint(rect.x, rect.y); - let lastVisibleItem = this.getIndexAtPoint(rect.maxX, rect.maxY); - for (let index = firstVisibleItem; index <= lastVisibleItem; index++) { - let keyFromIndex = this.collection.rows[index].key; - let layoutInfo = this.layoutInfos.get(keyFromIndex); - if (layoutInfo && this.isVisible(layoutInfo, rect)) { - res.push(layoutInfo); - } - } - - // Check if loader is in view and add to res if so - let loader = this.layoutInfos.get('loader'); - if (loader && this.isVisible(loader, rect)) { - res.push(loader); - } - } - - return res; - } - buildCollection() { let visibleWidth = this.virtualizer.visibleRect.width; let visibleHeight = this.virtualizer.visibleRect.height; diff --git a/packages/@react-spectrum/card/src/WaterfallLayout.tsx b/packages/@react-spectrum/card/src/WaterfallLayout.tsx index eebd9203326..ec40eb303b7 100644 --- a/packages/@react-spectrum/card/src/WaterfallLayout.tsx +++ b/packages/@react-spectrum/card/src/WaterfallLayout.tsx @@ -13,7 +13,7 @@ import {BaseLayout, BaseLayoutOptions} from './BaseLayout'; import {getChildNodes, getFirstItem} from '@react-stately/collections'; import {InvalidationContext, LayoutInfo, Rect, Size} from '@react-stately/virtualizer'; -import {Key, KeyboardDelegate, Node} from '@react-types/shared'; +import {Key, KeyboardDelegate} from '@react-types/shared'; export interface WaterfallLayoutOptions extends BaseLayoutOptions { /** @@ -69,7 +69,7 @@ export class WaterfallLayout extends BaseLayout implements KeyboardDelegat return 'waterfall'; } - buildCollection(invalidationContext: InvalidationContext, unknown>) { + buildCollection(invalidationContext: InvalidationContext) { // Compute the number of columns needed to display the content let visibleWidth = this.virtualizer.visibleRect.width; let availableWidth = visibleWidth - this.margin * 2; diff --git a/packages/@react-spectrum/card/test/CardView.test.js b/packages/@react-spectrum/card/test/CardView.test.js index a0ffc4cf181..8b25796f8a3 100644 --- a/packages/@react-spectrum/card/test/CardView.test.js +++ b/packages/@react-spectrum/card/test/CardView.test.js @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +jest.mock('@react-aria/utils/src/scrollIntoView'); import {act, fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {Card, CardView, GalleryLayout, GridLayout, WaterfallLayout} from '../'; import {composeStories} from '@storybook/react'; @@ -19,6 +20,7 @@ import {Image} from '@react-spectrum/image'; import {Provider} from '@react-spectrum/provider'; import React, {useMemo} from 'react'; import scaleMedium from '@adobe/spectrum-css-temp/vars/spectrum-medium-unique.css'; +import {scrollIntoView} from '@react-aria/utils'; import * as stories from '../stories/GridCardView.stories'; import themeLight from '@adobe/spectrum-css-temp/vars/spectrum-light-unique.css'; import {useCollator} from '@react-aria/i18n'; @@ -1161,16 +1163,8 @@ describe('CardView', function () { expect(cards).toBeTruthy(); await user.click(cards[1]); - // Scroll to the 'ideal' end, however, this won't be the true y position after everything has - // been rendered and layout infos are all calculated. So scroll to the beginning again and then back one more time. - // This time we'll end up at the true end and the progress bar will be visible. - await user.keyboard('{End}'); - await user.keyboard('{Home}'); - - await user.keyboard('{End}'); - act(() => { - grid.scrollTop += 100; + grid.scrollTop += 1000; fireEvent.scroll(grid); }); @@ -1199,19 +1193,11 @@ describe('CardView', function () { let cards = tree.getAllByRole('gridcell'); expect(cards).toBeTruthy(); - // Virtualizer calls onLoadMore twice due to initial layout - expect(onLoadMore).toHaveBeenCalledTimes(1); - await user.click(cards[1]); - - await user.keyboard('{End}'); - act(() => { - jest.runAllTimers(); - }); let grid = tree.getByRole('grid'); grid.scrollTop = 3000; fireEvent.scroll(grid); - expect(onLoadMore).toHaveBeenCalledTimes(2); + expect(onLoadMore).toHaveBeenCalledTimes(1); }); it.each` @@ -1274,18 +1260,17 @@ describe('CardView', function () { let cards = tree.getAllByRole('gridcell'); expect(cards).toBeTruthy(); let grid = tree.getByRole('grid'); - let initialScrollTop = grid.scrollTop; await user.click(cards[cards.length - 1]); act(() => { jest.runAllTimers(); }); - expect(grid.scrollTop).toBe(initialScrollTop); + expect(scrollIntoView).not.toHaveBeenCalled(); await user.keyboard('{ArrowDown}'); act(() => { jest.runAllTimers(); }); - expect(grid.scrollTop).toBeGreaterThan(initialScrollTop); + expect(scrollIntoView).toHaveBeenLastCalledWith(grid, document.activeElement); }); }); diff --git a/packages/@react-spectrum/combobox/src/ComboBox.tsx b/packages/@react-spectrum/combobox/src/ComboBox.tsx index f5200ff45ff..b610a302c36 100644 --- a/packages/@react-spectrum/combobox/src/ComboBox.tsx +++ b/packages/@react-spectrum/combobox/src/ComboBox.tsx @@ -107,7 +107,7 @@ const ComboBoxBase = React.forwardRef(function ComboBoxBase(pr allowsEmptyCollection: isAsync } ); - let layout = useListBoxLayout(state, loadingState === 'loadingMore'); + let layout = useListBoxLayout(state); let {buttonProps, inputProps, listBoxProps, labelProps, descriptionProps, errorMessageProps, isInvalid, validationErrors, validationDetails} = useComboBox( { @@ -193,6 +193,7 @@ const ComboBoxBase = React.forwardRef(function ComboBoxBase(pr state={state} shouldUseVirtualFocus isLoading={loadingState === 'loading' || loadingState === 'loadingMore'} + showLoadingSpinner={loadingState === 'loadingMore'} onLoadMore={onLoadMore} renderEmptyState={() => isAsync && ( diff --git a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx index 1948aa02aed..1d32e3600fc 100644 --- a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx +++ b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx @@ -334,7 +334,7 @@ function ComboBoxTray(props: ComboBoxTrayProps) { let popoverRef = useRef(); let listBoxRef = useRef(); let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; - let layout = useListBoxLayout(state, isLoading); + let layout = useListBoxLayout(state); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/combobox'); let {inputProps, listBoxProps, labelProps} = useComboBox( diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index 5a18f0131ce..44409d8c46a 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -11,7 +11,7 @@ */ import {AriaGridListProps, useGridList} from '@react-aria/gridlist'; -import {AsyncLoadable, DOMRef, Key, LoadingState, SpectrumSelectionProps, StyleProps} from '@react-types/shared'; +import {AsyncLoadable, DOMRef, Key, LoadingState, Node, SpectrumSelectionProps, StyleProps} from '@react-types/shared'; import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; import type {DragAndDropHooks} from '@react-spectrum/dnd'; import type {DraggableCollectionState, DroppableCollectionState} from '@react-stately/dnd'; @@ -26,7 +26,7 @@ import {ListState, useListState} from '@react-stately/list'; import listStyles from './styles.css'; import {ListViewItem} from './ListViewItem'; import {ProgressCircle} from '@react-spectrum/progress'; -import React, {JSX, ReactElement, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {JSX, ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import RootDropIndicator from './RootDropIndicator'; import {DragPreview as SpectrumDragPreview} from './DragPreview'; import {useCollator, useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -70,7 +70,8 @@ interface ListViewContextValue { isListDraggable: boolean, isListDroppable: boolean, layout: ListLayout, - loadingState: LoadingState + loadingState: LoadingState, + renderEmptyState?: () => JSX.Element } export const ListViewContext = React.createContext>(null); @@ -118,6 +119,7 @@ function ListView(props: SpectrumListViewProps, ref: DOMRef overflowMode = 'truncate', onAction, dragAndDropHooks, + renderEmptyState, ...otherProps } = props; let isListDraggable = !!dragAndDropHooks?.useDraggableCollectionState; @@ -139,7 +141,6 @@ function ListView(props: SpectrumListViewProps, ref: DOMRef selectionBehavior: props.selectionStyle === 'highlight' ? 'replace' : 'toggle' }); let {collection, selectionManager} = state; - let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/list'); let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; let {styleProps} = useStyleProps(props); @@ -186,9 +187,6 @@ function ListView(props: SpectrumListViewProps, ref: DOMRef onAction }, state, domRef); - // Sync loading state into the layout. - layout.isLoading = isLoading; - let focusedKey = selectionManager.focusedKey; if (dropState?.target?.type === 'item') { focusedKey = dropState.target.key; @@ -209,7 +207,7 @@ function ListView(props: SpectrumListViewProps, ref: DOMRef let hasAnyChildren = useMemo(() => [...collection].some(item => item.hasChildNodes), [collection]); return ( - + (props: SpectrumListViewProps, ref: DOMRef ) } layout={layout} - collection={collection} - transitionDuration={isLoading ? 160 : 220}> - {(type, item) => { + layoutOptions={useMemo(() => ({isLoading}), [isLoading])} + collection={collection}> + {useCallback((type, item) => { if (type === 'item') { - return ( - <> - {isListDroppable && collection.getKeyBefore(item.key) == null && - - } - {isListDroppable && - - } - - {isListDroppable && - - } - - ); + return ; } else if (type === 'loader') { - return ( - - 0 ? stringFormatter.format('loadingMore') : stringFormatter.format('loading')} /> - - ); + return ; } else if (type === 'placeholder') { - let emptyState = props.renderEmptyState ? props.renderEmptyState() : null; - if (emptyState == null) { - return null; - } - - return ( - - {emptyState} - - ); + return ; } - - }} + }, [])} @@ -307,6 +271,55 @@ function ListView(props: SpectrumListViewProps, ref: DOMRef ); } +function Item({item}: {item: Node}) { + let {isListDroppable, state, onAction} = useContext(ListViewContext); + return ( + <> + {isListDroppable && state.collection.getKeyBefore(item.key) == null && + + } + {isListDroppable && + + } + + {isListDroppable && + + } + + ); +} + +function LoadingView() { + let {state} = useContext(ListViewContext); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/list'); + return ( + + 0 ? stringFormatter.format('loadingMore') : stringFormatter.format('loading')} /> + + ); +} + +function EmptyState() { + let {renderEmptyState} = useContext(ListViewContext); + let emptyState = renderEmptyState ? renderEmptyState() : null; + if (emptyState == null) { + return null; + } + + return ( + + {emptyState} + + ); +} + function CenteredWrapper({children}) { let {state} = useContext(ListViewContext); return ( diff --git a/packages/@react-spectrum/list/src/ListViewItem.tsx b/packages/@react-spectrum/list/src/ListViewItem.tsx index 42ffd209058..34316bec925 100644 --- a/packages/@react-spectrum/list/src/ListViewItem.tsx +++ b/packages/@react-spectrum/list/src/ListViewItem.tsx @@ -158,7 +158,7 @@ export function ListViewItem(props: ListViewItemProps) { // with bottom border let isFlushWithContainerBottom = false; if (isLastRow && loadingState !== 'loadingMore') { - if (layout.getContentSize()?.height >= layout.virtualizer?.getVisibleRect().height) { + if (layout.getContentSize()?.height >= layout.virtualizer?.visibleRect.height) { isFlushWithContainerBottom = true; } } diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index 61d52f43f9e..6f4eab54f16 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -12,6 +12,7 @@ jest.mock('@react-aria/live-announcer'); +jest.mock('@react-aria/utils/src/scrollIntoView'); import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render as renderComponent, within} from '@react-spectrum/test-utils-internal'; import {ActionButton} from '@react-spectrum/button'; import {announce} from '@react-aria/live-announcer'; @@ -20,6 +21,7 @@ import {Item, ListView} from '../src'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {renderEmptyState} from '../stories/ListView.stories'; +import {scrollIntoView} from '@react-aria/utils'; import {Text} from '@react-spectrum/text'; import {theme} from '@react-spectrum/theme-default'; import userEvent from '@testing-library/user-event'; @@ -1434,7 +1436,6 @@ describe('ListView', function () { jest.runAllTimers(); }); await user.tab(); - expect(grid.scrollTop).toBe(0); let rows = tree.getAllByRole('row'); let rowWrappers = rows.map(item => item.parentElement); @@ -1446,16 +1447,28 @@ describe('ListView', function () { // scroll us down far enough that item 0 isn't in the view moveFocus('ArrowDown'); + expect(scrollIntoView).toHaveBeenLastCalledWith(grid, getRow(tree, 'Item 1')); + grid.scrollTop = 40; + fireEvent.scroll(grid); moveFocus('ArrowDown'); + expect(scrollIntoView).toHaveBeenLastCalledWith(grid, getRow(tree, 'Item 2')); + grid.scrollTop = 80; + fireEvent.scroll(grid); moveFocus('ArrowDown'); - expect(document.activeElement).toBe(getRow(tree, 'Item 3')); - expect(grid.scrollTop).toBe(100); + expect(scrollIntoView).toHaveBeenLastCalledWith(grid, getRow(tree, 'Item 3')); + grid.scrollTop = 120; + fireEvent.scroll(grid); moveFocus('ArrowUp'); + expect(scrollIntoView).toHaveBeenLastCalledWith(grid, getRow(tree, 'Item 2')); + grid.scrollTop = 80; + fireEvent.scroll(grid); moveFocus('ArrowUp'); + expect(scrollIntoView).toHaveBeenLastCalledWith(grid, getRow(tree, 'Item 1')); + grid.scrollTop = 40; + fireEvent.scroll(grid); moveFocus('ArrowUp'); - expect(document.activeElement).toBe(getRow(tree, 'Item 0')); - expect(grid.scrollTop).toBe(0); + expect(scrollIntoView).toHaveBeenLastCalledWith(grid, getRow(tree, 'Item 0')); }); it('should scroll to a row when it is focused', function () { @@ -1485,12 +1498,10 @@ describe('ListView', function () { act(() => { jest.runAllTimers(); }); - expect(grid.scrollTop).toBe(0); focusRow(tree, 'Item 1'); expect(document.activeElement).toBe(getRow(tree, 'Item 1')); - - expect(grid.scrollTop).toBe(20); + expect(scrollIntoView).toHaveBeenLastCalledWith(grid, document.activeElement); }); it('should scroll to a row when it is focused off screen', function () { @@ -1512,21 +1523,18 @@ describe('ListView', function () { let row = getRow(tree, 'Item 0'); act(() => row.focus()); expect(document.activeElement).toBe(row); - expect(body.scrollTop).toBe(0); // When scrolling the focused item out of view, focus should remain on the item - body.scrollTop = 1000; fireEvent.scroll(body); - expect(body.scrollTop).toBe(1000); expect(document.activeElement).toBe(row); // item isn't reused by virutalizer expect(tree.queryByText('Item 0')).toBe(row.firstElementChild.firstElementChild.firstElementChild); // Moving focus should scroll the new focused item into view moveFocus('ArrowDown'); - expect(body.scrollTop).toBe(0); expect(document.activeElement).toBe(getRow(tree, 'Item 1')); + expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); }); }); diff --git a/packages/@react-spectrum/listbox/src/ListBox.tsx b/packages/@react-spectrum/listbox/src/ListBox.tsx index 26b67a1d709..650d7133baa 100644 --- a/packages/@react-spectrum/listbox/src/ListBox.tsx +++ b/packages/@react-spectrum/listbox/src/ListBox.tsx @@ -19,7 +19,7 @@ import {useListState} from '@react-stately/list'; function ListBox(props: SpectrumListBoxProps, ref: DOMRef) { let state = useListState(props); - let layout = useListBoxLayout(state, props.isLoading); + let layout = useListBoxLayout(state); let domRef = useDOMRef(ref); return ( diff --git a/packages/@react-spectrum/listbox/src/ListBoxBase.tsx b/packages/@react-spectrum/listbox/src/ListBoxBase.tsx index 57fcfbc3c2d..977af2827eb 100644 --- a/packages/@react-spectrum/listbox/src/ListBoxBase.tsx +++ b/packages/@react-spectrum/listbox/src/ListBoxBase.tsx @@ -21,9 +21,9 @@ import {ListBoxOption} from './ListBoxOption'; import {ListBoxSection} from './ListBoxSection'; import {ListLayout} from '@react-stately/layout'; import {ListState} from '@react-stately/list'; -import {mergeProps, useLayoutEffect} from '@react-aria/utils'; +import {mergeProps} from '@react-aria/utils'; import {ProgressCircle} from '@react-spectrum/progress'; -import React, {HTMLAttributes, ReactElement, ReactNode, RefObject, useMemo} from 'react'; +import React, {HTMLAttributes, ReactElement, ReactNode, RefObject, useCallback, useContext, useMemo} from 'react'; import {ReusableView} from '@react-stately/virtualizer'; import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; import {useCollator, useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -40,15 +40,15 @@ interface ListBoxBaseProps extends AriaListBoxOptions, DOMProps, AriaLabel domProps?: HTMLAttributes, disallowEmptySelection?: boolean, shouldUseVirtualFocus?: boolean, - transitionDuration?: number, isLoading?: boolean, + showLoadingSpinner?: boolean, onLoadMore?: () => void, renderEmptyState?: () => ReactNode, onScroll?: () => void } /** @private */ -export function useListBoxLayout(state: ListState, isLoading: boolean): ListLayout { +export function useListBoxLayout(state: ListState): ListLayout { let {scale} = useProvider(); let collator = useCollator({usage: 'search', sensitivity: 'base'}); let layout = useMemo(() => @@ -64,32 +64,23 @@ export function useListBoxLayout(state: ListState, isLoading: boolean): Li layout.collection = state.collection; layout.disabledKeys = state.disabledKeys; - - useLayoutEffect(() => { - // Sync loading state into the layout. - if (layout.isLoading !== isLoading) { - layout.isLoading = isLoading; - layout.virtualizer?.relayoutNow(); - } - }, [layout, isLoading]); return layout; } /** @private */ function ListBoxBase(props: ListBoxBaseProps, ref: RefObject) { - let {layout, state, shouldSelectOnPressUp, focusOnPointerEnter, shouldUseVirtualFocus, domProps = {}, transitionDuration = 0, onScroll} = props; + let {layout, state, shouldFocusOnHover, shouldUseVirtualFocus, domProps = {}, isLoading, showLoadingSpinner = isLoading, onScroll, renderEmptyState} = props; let {listBoxProps} = useListBox({ ...props, keyboardDelegate: layout, isVirtualized: true }, state, ref); let {styleProps} = useStyleProps(props); - let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/listbox'); // This overrides collection view's renderWrapper to support hierarchy of items in sections. // The header is extracted from the children so it can receive ARIA labeling properties. - type View = ReusableView, ReactNode>; - let renderWrapper = (parent: View, reusableView: View, children: View[], renderChildren: (views: View[]) => ReactElement[]) => { + type View = ReusableView, ReactElement>; + let renderWrapper = useCallback((parent: View, reusableView: View, children: View[], renderChildren: (views: View[]) => ReactElement[]) => { if (reusableView.viewType === 'section') { return ( (props: ListBoxBaseProps, ref: RefObject ); - }; + }, []); return ( - + (props: ListBoxBaseProps, ref: RefObject ({ + isLoading: showLoadingSpinner + }), [showLoadingSpinner])} collection={state.collection} renderWrapper={renderWrapper} - transitionDuration={transitionDuration} - isLoading={props.isLoading} + isLoading={isLoading} onLoadMore={props.onLoadMore} - shouldUseVirtualFocus={shouldUseVirtualFocus} onScroll={onScroll}> - {(type, item: Node) => { + {useCallback((type, item: Node) => { if (type === 'item') { - return ( - - ); + return ; } else if (type === 'loader') { - return ( - // aria-selected isn't needed here since this option is not selectable. - // eslint-disable-next-line jsx-a11y/role-has-required-aria-props -
- 0 ? stringFormatter.format('loadingMore') : stringFormatter.format('loading')} - UNSAFE_className={classNames(styles, 'spectrum-Dropdown-progressCircle')} /> -
- ); + return ; } else if (type === 'placeholder') { - let emptyState = props.renderEmptyState ? props.renderEmptyState() : null; - if (emptyState == null) { - return null; - } - - return ( -
- {emptyState} -
- ); + return ; } - }} + }, [])}
@@ -187,3 +151,36 @@ function ListBoxBase(props: ListBoxBaseProps, ref: RefObject(props: ListBoxBaseProps & {ref?: RefObject}) => ReactElement; export {_ListBoxBase as ListBoxBase}; + +function LoadingState() { + let {state} = useContext(ListBoxContext); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/listbox'); + return ( + // aria-selected isn't needed here since this option is not selectable. + // eslint-disable-next-line jsx-a11y/role-has-required-aria-props +
+ 0 ? stringFormatter.format('loadingMore') : stringFormatter.format('loading')} + UNSAFE_className={classNames(styles, 'spectrum-Dropdown-progressCircle')} /> +
+ ); +} + +function EmptyState() { + let {renderEmptyState} = useContext(ListBoxContext); + let emptyState = renderEmptyState ? renderEmptyState() : null; + if (emptyState == null) { + return null; + } + + return ( +
+ {emptyState} +
+ ); +} diff --git a/packages/@react-spectrum/listbox/src/ListBoxContext.ts b/packages/@react-spectrum/listbox/src/ListBoxContext.ts index 405bbde4058..a1424f80a9d 100644 --- a/packages/@react-spectrum/listbox/src/ListBoxContext.ts +++ b/packages/@react-spectrum/listbox/src/ListBoxContext.ts @@ -11,6 +11,13 @@ */ import {ListState} from '@react-stately/list'; -import React from 'react'; +import React, {ReactNode} from 'react'; -export const ListBoxContext = React.createContext>(null); +interface ListBoxContextValue { + state: ListState, + renderEmptyState?: () => ReactNode, + shouldFocusOnHover: boolean, + shouldUseVirtualFocus: boolean +} + +export const ListBoxContext = React.createContext(null); diff --git a/packages/@react-spectrum/listbox/src/ListBoxOption.tsx b/packages/@react-spectrum/listbox/src/ListBoxOption.tsx index f4550aa7cd9..bf8583ac196 100644 --- a/packages/@react-spectrum/listbox/src/ListBoxOption.tsx +++ b/packages/@react-spectrum/listbox/src/ListBoxOption.tsx @@ -24,37 +24,26 @@ import {Text} from '@react-spectrum/text'; import {useOption} from '@react-aria/listbox'; interface OptionProps { - item: Node, - shouldSelectOnPressUp?: boolean, - shouldFocusOnHover?: boolean, - shouldUseVirtualFocus?: boolean + item: Node } /** @private */ export function ListBoxOption(props: OptionProps) { - let { - item, - shouldSelectOnPressUp, - shouldFocusOnHover, - shouldUseVirtualFocus - } = props; + let {item} = props; let { rendered, key } = item; let ElementType: React.ElementType = item.props.href ? 'a' : 'div'; - let state = useContext(ListBoxContext); + let {state, shouldFocusOnHover, shouldUseVirtualFocus} = useContext(ListBoxContext); let ref = useRef(); let {optionProps, labelProps, descriptionProps, isSelected, isDisabled, isFocused} = useOption( { 'aria-label': item['aria-label'], key, - shouldSelectOnPressUp, - shouldFocusOnHover, - isVirtualized: true, - shouldUseVirtualFocus + isVirtualized: true }, state, ref diff --git a/packages/@react-spectrum/listbox/src/ListBoxSection.tsx b/packages/@react-spectrum/listbox/src/ListBoxSection.tsx index 12a801f647a..e98dcc77008 100644 --- a/packages/@react-spectrum/listbox/src/ListBoxSection.tsx +++ b/packages/@react-spectrum/listbox/src/ListBoxSection.tsx @@ -42,7 +42,7 @@ export function ListBoxSection(props: ListBoxSectionProps) { }); let {direction} = useLocale(); - let state = useContext(ListBoxContext); + let {state} = useContext(ListBoxContext); return ( diff --git a/packages/@react-spectrum/picker/src/Picker.tsx b/packages/@react-spectrum/picker/src/Picker.tsx index 1a5ab055f96..8ca21b4664d 100644 --- a/packages/@react-spectrum/picker/src/Picker.tsx +++ b/packages/@react-spectrum/picker/src/Picker.tsx @@ -76,7 +76,7 @@ function Picker(props: SpectrumPickerProps, ref: DOMRef(props: SpectrumPickerProps, ref: DOMRef ); diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index 1deab903927..99c1a8cc651 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +jest.mock('@react-aria/utils/src/scrollIntoView'); import {act, fireEvent, mockClickDefault, pointerMap, render, simulateDesktop, within} from '@react-spectrum/test-utils-internal'; import AlignCenter from '@spectrum-icons/workflow/AlignCenter'; import AlignLeft from '@spectrum-icons/workflow/AlignLeft'; @@ -22,11 +23,11 @@ import {Item, Picker, Section} from '../src'; import Paste from '@spectrum-icons/workflow/Paste'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; +import {scrollIntoView} from '@react-aria/utils'; import {states} from './data'; import {Text} from '@react-spectrum/text'; import {theme} from '@react-spectrum/theme-default'; import userEvent from '@testing-library/user-event'; -import {Virtualizer} from '../../../@react-stately/virtualizer/src/Virtualizer'; describe('Picker', function () { let offsetWidth, offsetHeight; @@ -37,7 +38,6 @@ describe('Picker', function () { user = userEvent.setup({delay: null, pointerMap}); offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000); offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); - window.HTMLElement.prototype.scrollIntoView = jest.fn(); simulateDesktop(); jest.useFakeTimers(); }); @@ -407,10 +407,7 @@ describe('Picker', function () { }); it('scrolls the selected item into view on menu open', async function () { - let scrollToSpy = jest.fn(); - let virtualizerMock = jest.spyOn(Virtualizer.prototype, 'scrollToItem').mockImplementationOnce(scrollToSpy); // Mock scroll height so that the picker heights actually have a value - let scrollHeightSpy = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 500); let {getByRole, queryByRole} = render( @@ -430,10 +427,8 @@ describe('Picker', function () { let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); act(() => jest.runAllTimers()); - expect(scrollToSpy.mock.calls[0][0]).toBe('four'); - - virtualizerMock.mockReset(); - scrollHeightSpy.mockReset(); + expect(document.activeElement).toBe(within(listbox).getAllByRole('option')[3]); + expect(scrollIntoView).toHaveBeenLastCalledWith(listbox, document.activeElement); }); }); diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 32ca1556476..7deacc5de7e 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -35,13 +35,13 @@ import {InsertionIndicator} from './InsertionIndicator'; // @ts-ignore import intlMessages from '../intl/*.json'; import {Item, Menu, MenuTrigger} from '@react-spectrum/menu'; +import {LayoutInfo, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; import {layoutInfoToStyle, ScrollView, setScrollLeft, useVirtualizer, VirtualizerItem} from '@react-aria/virtualizer'; import ListGripper from '@spectrum-icons/ui/ListGripper'; import {Nubbin} from './Nubbin'; import {ProgressCircle} from '@react-spectrum/progress'; import React, {DOMAttributes, HTMLAttributes, ReactElement, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {Resizer} from './Resizer'; -import {ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; import {RootDropIndicator} from './RootDropIndicator'; import {DragPreview as SpectrumDragPreview} from './DragPreview'; import {SpectrumTableProps} from './TableViewWrapper'; @@ -122,7 +122,8 @@ export interface TableContextValue { onResize: (widths: Map) => void, onResizeEnd: (widths: Map) => void, headerMenuOpen: boolean, - setHeaderMenuOpen: (val: boolean) => void + setHeaderMenuOpen: (val: boolean) => void, + renderEmptyState?: () => ReactElement } export const TableContext = React.createContext>(null); @@ -166,7 +167,6 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef): ColumnSize | null | undefined => { @@ -203,7 +203,6 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef(); let bodyRef = useRef(); - let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/table'); let density = props.density || 'regular'; let columnLayout = useMemo( @@ -278,25 +277,21 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef, ReactNode>; - let renderWrapper = (parent: View, reusableView: View, children: View[], renderChildren: (views: View[]) => ReactElement[]) => { - let style = layoutInfoToStyle(reusableView.layoutInfo, direction, parent && parent.layoutInfo); - if (style.overflow === 'hidden') { - style.overflow = 'visible'; // needed to support position: sticky - } - + let renderWrapper = useCallback((parent: View, reusableView: View, children: View[], renderChildren: (views: View[]) => ReactElement[]) => { if (reusableView.viewType === 'rowgroup') { return ( - - {isTableDroppable && - - } + {renderChildren(children)} ); @@ -306,7 +301,8 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef + layoutInfo={reusableView.layoutInfo} + parent={parent?.layoutInfo}> {renderChildren(children)} ); @@ -317,10 +313,8 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef + layoutInfo={reusableView.layoutInfo} + parent={parent?.layoutInfo}> {renderChildren(children)} ); @@ -331,46 +325,26 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef {renderChildren(children)} ); } - let isDropTarget: boolean; - let isRootDroptarget: boolean; - if (isTableDroppable) { - if (parent.content) { - isDropTarget = dropState.isDropTarget({type: 'item', dropPosition: 'on', key: parent.content.key}); - } - isRootDroptarget = dropState.isDropTarget({type: 'root'}); - } - + return ( - + parent={parent}> {reusableView.rendered} - + ); - }; + }, []); - let renderView = (type: string, item: GridNode) => { + let renderView = useCallback((type: string, item: GridNode) => { switch (type) { case 'header': case 'rowgroup': @@ -417,34 +391,19 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef; + return ; } return ( ); case 'loader': - return ( - - 0 ? stringFormatter.format('loadingMore') : stringFormatter.format('loading')} /> - - ); + return ; case 'empty': { - let emptyState = props.renderEmptyState ? props.renderEmptyState() : null; - if (emptyState == null) { - return null; - } - - return ( - - {emptyState} - - ); + return ; } } - }; + }, []); let [isVerticalScrollbarVisible, setVerticalScollbarVisible] = useState(false); let [isHorizontalScrollbarVisible, setHorizontalScollbarVisible] = useState(false); @@ -489,7 +448,27 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef + (props: TableBaseProps, ref: DOMRef({ layout, collection, @@ -566,39 +540,18 @@ function TableVirtualizer(props) { bodyRef.current.scrollTop = rect.y; setScrollLeft(bodyRef.current, direction, rect.x); }, - transitionDuration + persistedKeys: useMemo(() => focusedKey ? new Set([focusedKey]) : new Set(), [focusedKey]) }); - let scrollToItem = useCallback((key) => { - let item = collection.getItem(key); - let column = collection.columns[0]; - let virtualizer = state.virtualizer; - - virtualizer.scrollToItem(key, { - duration: 0, - // Prevent scrolling to the top when clicking on column headers. - shouldScrollY: item?.type !== 'column', - // Offset scroll position by width of selection cell - // (which is sticky and will overlap the cell we're scrolling to). - offsetX: column.props.isSelectionCell || column.props.isDragButtonCell - ? layout.getColumnWidth(column.key) - : 0 - }); - - // Sync the scroll positions of the column headers and the body so scrollIntoViewport can - // properly decide if the column is outside the viewport or not - headerRef.current.scrollLeft = bodyRef.current.scrollLeft; - }, [collection, bodyRef, headerRef, layout, state.virtualizer]); - let memoedVirtualizerProps = useMemo(() => ({ tabIndex: otherProps.tabIndex, focusedKey, - scrollToItem, isLoading, onLoadMore - }), [otherProps.tabIndex, focusedKey, scrollToItem, isLoading, onLoadMore]); + }), [otherProps.tabIndex, focusedKey, isLoading, onLoadMore]); let {virtualizerProps, scrollViewProps: {onVisibleRectChange}} = useVirtualizer(memoedVirtualizerProps, state, domRef); + let onVisibleRectChangeMemo = useMemo(() => chain(onVisibleRectChange, onVisibleRectChangeProp), [onVisibleRectChange, onVisibleRectChangeProp]); // this effect runs whenever the contentSize changes, it doesn't matter what the content size is // only that it changes in a resize, and when that happens, we want to sync the body to the @@ -638,6 +591,12 @@ function TableVirtualizer(props) { isVirtualDragging && {tabIndex: null} ); + let firstColumn = collection.columns[0]; + let scrollPadding = 0; + if (firstColumn.props.isSelectionCell || firstColumn.props.isDragButtonCell) { + scrollPadding = layout.getColumnWidth(firstColumn.key); + } + return ( @@ -652,7 +611,7 @@ function TableVirtualizer(props) { overflow: 'hidden', position: 'relative', willChange: state.isScrolling ? 'scroll-position' : undefined, - transition: state.isAnimating ? `none ${state.virtualizer.transitionDuration}ms` : undefined + scrollPaddingInlineStart: scrollPadding }} ref={headerRef}> {state.visibleViews[0]} @@ -677,11 +636,14 @@ function TableVirtualizer(props) { ) } tabIndex={isVirtualDragging ? null : -1} - style={{flex: 1}} - innerStyle={{overflow: 'visible', transition: state.isAnimating ? `none ${state.virtualizer.transitionDuration}ms` : undefined}} + style={{ + flex: 1, + scrollPaddingInlineStart: scrollPadding + }} + innerStyle={{overflow: 'visible'}} ref={bodyRef} contentSize={state.contentSize} - onVisibleRectChange={chain(onVisibleRectChange, onVisibleRectChangeProp)} + onVisibleRectChange={onVisibleRectChangeMemo} onScrollStart={state.startScrolling} onScrollEnd={state.endScrolling} onScroll={onScroll}> @@ -696,11 +658,21 @@ function TableVirtualizer(props) { ); } -function TableHeader({children, ...otherProps}) { +function useStyle(layoutInfo: LayoutInfo, parent: LayoutInfo | null) { + let {direction} = useLocale(); + let style = layoutInfoToStyle(layoutInfo, direction, parent); + if (style.overflow === 'hidden') { + style.overflow = 'visible'; // needed to support position: sticky + } + return style; +} + +function TableHeader({children, layoutInfo, parent, ...otherProps}) { let {rowGroupProps} = useTableRowGroup(); + let style = useStyle(layoutInfo, parent); return ( -
+
{children}
); @@ -1047,11 +1019,16 @@ function TableDragHeaderCell({column}) { ); } -function TableRowGroup({children, ...otherProps}) { +function TableRowGroup({children, layoutInfo, parent, ...otherProps}) { let {rowGroupProps} = useTableRowGroup(); + let {isTableDroppable} = useContext(TableContext); + let style = useStyle(layoutInfo, parent); return ( -
+
+ {isTableDroppable && + + } {children}
); @@ -1091,19 +1068,18 @@ export function useTableRowContext() { return useContext(TableRowContext); } -function TableRow({item, children, hasActions, isTableDraggable, isTableDroppable, ...otherProps}) { +function TableRow({item, children, layoutInfo, parent, ...otherProps}) { let ref = useRef(); - let {state, layout, dragAndDropHooks, dragState, dropState} = useTableContext(); - let allowsInteraction = state.selectionManager.selectionMode !== 'none' || hasActions; - let isDisabled = !allowsInteraction || state.disabledKeys.has(item.key); - let isDroppable = isTableDroppable && !isDisabled; + let {state, layout, dragAndDropHooks, isTableDraggable, isTableDroppable, dragState, dropState} = useTableContext(); let isSelected = state.selectionManager.isSelected(item.key); - let {rowProps} = useTableRow({ + let {rowProps, hasAction, allowsSelection} = useTableRow({ node: item, isVirtualized: true, shouldSelectOnPressUp: isTableDraggable }, state, ref); + let isDisabled = !allowsSelection && !hasAction; + let isDroppable = isTableDroppable && !isDisabled; let {pressProps, isPressed} = usePress({isDisabled}); // The row should show the focus background style when any cell inside it is focused. @@ -1120,7 +1096,7 @@ function TableRow({item, children, hasActions, isTableDraggable, isTableDroppabl // border corners of the last row when selected. let isFlushWithContainerBottom = false; if (isLastRow) { - if (layout.getContentSize()?.height >= layout.virtualizer?.getVisibleRect().height) { + if (layout.getContentSize()?.height >= layout.virtualizer?.visibleRect.height) { isFlushWithContainerBottom = true; } } @@ -1150,9 +1126,12 @@ function TableRow({item, children, hasActions, isTableDraggable, isTableDroppabl elementType: 'div' }, dragButtonRef); + let style = useStyle(layoutInfo, parent); + let props = mergeProps( rowProps, otherProps, + {style}, focusWithinProps, focusProps, hoverProps, @@ -1220,11 +1199,12 @@ function TableRow({item, children, hasActions, isTableDraggable, isTableDroppabl ); } -function TableHeaderRow({item, children, style, ...props}) { +function TableHeaderRow({item, children, layoutInfo, parent, ...props}) { let {state, headerMenuOpen} = useTableContext(); let ref = useRef(); let {rowProps} = useTableHeaderRow({node: item, isVirtualized: true}, state, ref); let {hoverProps} = useHover({...props, isDisabled: headerMenuOpen}); + let style = useStyle(layoutInfo, parent); return (
@@ -1378,6 +1358,40 @@ function TableCell({cell}) { ); } +function TableCellWrapper({layoutInfo, virtualizer, parent, children}) { + let {isTableDroppable, dropState} = useContext(TableContext); + let isDropTarget: boolean; + let isRootDroptarget: boolean; + if (isTableDroppable) { + if (parent.content) { + isDropTarget = dropState.isDropTarget({type: 'item', dropPosition: 'on', key: parent.content.key}); + } + isRootDroptarget = dropState.isDropTarget({type: 'root'}); + } + + return ( + + {children} + + ); +} + function ExpandableRowChevron({cell}) { // TODO: move some/all of the chevron button setup into a separate hook? let {direction} = useLocale(); @@ -1424,6 +1438,32 @@ function ExpandableRowChevron({cell}) { ); } +function LoadingState() { + let {state} = useContext(TableContext); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/table'); + return ( + + 0 ? stringFormatter.format('loadingMore') : stringFormatter.format('loading')} /> + + ); +} + +function EmptyState() { + let {renderEmptyState} = useContext(TableContext); + let emptyState = renderEmptyState ? renderEmptyState() : null; + if (emptyState == null) { + return null; + } + + return ( + + {emptyState} + + ); +} + function CenteredWrapper({children}) { let {state} = useTableContext(); let rowProps; diff --git a/packages/@react-spectrum/table/test/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js index a6444c6ca98..290e2390900 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -11,6 +11,7 @@ */ jest.mock('@react-aria/live-announcer'); +jest.mock('@react-aria/utils/src/scrollIntoView'); import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render as renderComponent, within} from '@react-spectrum/test-utils-internal'; import {ActionButton, Button} from '@react-spectrum/button'; import Add from '@spectrum-icons/workflow/Add'; @@ -29,6 +30,7 @@ import {Item, Picker} from '@react-spectrum/picker'; import {Link} from '@react-spectrum/link'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; +import {scrollIntoView} from '@react-aria/utils'; import * as stories from '../stories/Table.stories'; import {Switch} from '@react-spectrum/switch'; import {TextField} from '@react-spectrum/textfield'; @@ -1820,10 +1822,9 @@ export let tableTests = () => { it('should scroll to a cell when it is focused', function () { let tree = renderMany(); let body = tree.getByRole('grid').childNodes[1]; - expect(body.scrollTop).toBe(0); focusCell(tree, 'Baz 25'); - expect(body.scrollTop).toBe(24); + expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); }); it('should scroll to a cell when it is focused off screen', function () { @@ -1840,6 +1841,7 @@ export let tableTests = () => { body.scrollTop = 1000; body.scrollLeft = 1000; fireEvent.scroll(body); + act(() => jest.runAllTimers()); expect(body.scrollTop).toBe(1000); expect(document.activeElement).toBe(cell); @@ -1859,8 +1861,8 @@ export let tableTests = () => { // Moving focus should scroll the new focused item into view moveFocus('ArrowLeft'); - expect(body.scrollTop).toBe(164); expect(document.activeElement).toBe(getCell(tree, 'Foo 5 4')); + expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); }); it('should not scroll when a column header receives focus', function () { @@ -1879,7 +1881,7 @@ export let tableTests = () => { focusCell(tree, 'Bar'); expect(document.activeElement).toHaveAttribute('role', 'columnheader'); expect(document.activeElement).toHaveTextContent('Bar'); - expect(body.scrollTop).toBe(1000); + expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); }); }); }); diff --git a/packages/@react-spectrum/table/test/TableDnd.test.js b/packages/@react-spectrum/table/test/TableDnd.test.js index 107012a5cb2..08282279139 100644 --- a/packages/@react-spectrum/table/test/TableDnd.test.js +++ b/packages/@react-spectrum/table/test/TableDnd.test.js @@ -2588,13 +2588,11 @@ describe('TableView', function () { grid = getByRole('grid'); rowgroups = within(grid).getAllByRole('rowgroup'); rows = within(rowgroups[1]).getAllByRole('row'); - expect(within(rows[0]).getAllByRole('rowheader')[0]).toHaveTextContent('Vin'); - // TODO - // expect(within(rows[1]).getAllByRole('rowheader')[0]).toHaveTextContent('Dodie'); - // expect(within(rows[2]).getAllByRole('rowheader')[0]).toHaveTextContent('Lexy'); - // expect(within(rows[3]).getAllByRole('rowheader')[0]).toHaveTextContent('Robbi'); - - // expect(document.activeElement).toBe(rows[3]); + expect(within(rows[0]).getAllByRole('rowheader')[0]).toHaveTextContent('Robbi'); + expect(within(rows[1]).getAllByRole('rowheader')[0]).toHaveTextContent('Vin'); + expect(within(rows[2]).getAllByRole('rowheader')[0]).toHaveTextContent('Lexy'); + expect(within(rows[3]).getAllByRole('rowheader')[0]).toHaveTextContent('Dodie'); + expect(document.activeElement).toBe(rows[2]); }); it('should allow moving one row into another table', async function () { diff --git a/packages/@react-spectrum/table/test/TreeGridTable.test.tsx b/packages/@react-spectrum/table/test/TreeGridTable.test.tsx index e71ce3baf51..3c175047a92 100644 --- a/packages/@react-spectrum/table/test/TreeGridTable.test.tsx +++ b/packages/@react-spectrum/table/test/TreeGridTable.test.tsx @@ -11,6 +11,7 @@ */ jest.mock('@react-aria/live-announcer'); +jest.mock('@react-aria/utils/src/scrollIntoView'); import { act, fireEvent, @@ -25,6 +26,7 @@ import {enableTableNestedRows} from '@react-stately/flags'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {Scale} from '@react-types/provider'; +import {scrollIntoView} from '@react-aria/utils'; import * as stories from '../stories/TreeGridTable.stories'; import {theme} from '@react-spectrum/theme-default'; import userEvent from '@testing-library/user-event'; @@ -808,10 +810,9 @@ describe('TableView with expandable rows', function () { it('should scroll to a cell when it is focused', function () { let treegrid = render(); let body = (treegrid.getByRole('treegrid').childNodes[1] as HTMLElement); - expect(body.scrollTop).toBe(0); focusCell(treegrid, 'Row 9, Lvl 1, Foo'); - expect(body.scrollTop).toBe(24); + expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); }); it('should scroll to a nested row cell when it is focused off screen', function () { @@ -820,13 +821,13 @@ describe('TableView with expandable rows', function () { let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); act(() => cell.focus()); expect(document.activeElement).toBe(cell); - expect(body.scrollTop).toBe(0); // When scrolling the focused item out of view, focus should remain on the item, // virtualizer keeps focused items from being reused body.scrollTop = 1000; body.scrollLeft = 1000; fireEvent.scroll(body); + act(() => jest.runAllTimers()); expect(body.scrollTop).toBe(1000); expect(document.activeElement).toBe(cell); @@ -852,8 +853,8 @@ describe('TableView with expandable rows', function () { // Moving focus should scroll the new focused item into view moveFocus('ArrowRight'); - expect(body.scrollTop).toBe(82); expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 3, Bar')); + expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); }); }); }); diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index a60fc3f46ea..1e83941d067 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -38,6 +38,10 @@ export interface LayoutNode { index?: number } +interface ListLayoutProps { + isLoading?: boolean +} + const DEFAULT_HEIGHT = 48; /** @@ -50,7 +54,7 @@ const DEFAULT_HEIGHT = 48; * delegate with an additional method to do this (it uses the same delegate object as * the collection view itself). */ -export class ListLayout extends Layout> implements KeyboardDelegate, DropTargetDelegate { +export class ListLayout extends Layout, ListLayoutProps> implements KeyboardDelegate, DropTargetDelegate { protected rowHeight: number; protected estimatedRowHeight: number; protected headingHeight: number; @@ -101,30 +105,14 @@ export class ListLayout extends Layout> implements KeyboardDelegate, } getLayoutInfo(key: Key) { - let res = this.layoutInfos.get(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 (!res && this.validRect.area < this.contentSize.area && this.lastCollection) { - this.lastValidRect = this.validRect; - this.validRect = new Rect(0, 0, Infinity, Infinity); - this.rootNodes = this.buildCollection(); - this.validRect = new Rect(0, 0, this.contentSize.width, this.contentSize.height); - res = this.layoutInfos.get(key); - } - - return res!; + this.ensureLayoutInfo(key); + return this.layoutInfos.get(key)!; } getVisibleLayoutInfos(rect: Rect) { // If layout hasn't yet been done for the requested rect, union the // new rect with the existing valid rect, and recompute. - if (!this.validRect.containsRect(rect) && this.lastCollection) { - this.lastValidRect = this.validRect; - this.validRect = this.validRect.union(rect); - this.rootNodes = this.buildCollection(); - } + this.layoutIfNeeded(rect); let res: LayoutInfo[] = []; @@ -147,25 +135,60 @@ export class ListLayout extends Layout> implements KeyboardDelegate, return res; } + layoutIfNeeded(rect: Rect) { + if (!this.lastCollection) { + return; + } + + if (!this.validRect.containsRect(rect)) { + this.lastValidRect = this.validRect; + this.validRect = this.validRect.union(rect); + this.rootNodes = this.buildCollection(); + } else { + // Ensure all of the persisted keys are available. + for (let key of this.virtualizer.persistedKeys) { + if (this.ensureLayoutInfo(key)) { + break; + } + } + } + } + + 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.layoutInfos.has(key) && this.validRect.area < this.contentSize.area && this.lastCollection) { + this.lastValidRect = this.validRect; + this.validRect = new Rect(0, 0, Infinity, Infinity); + this.rootNodes = this.buildCollection(); + this.validRect = new Rect(0, 0, this.contentSize.width, this.contentSize.height); + return true; + } + + return false; + } + isVisible(node: LayoutNode, rect: Rect) { return node.layoutInfo.rect.intersects(rect) || node.layoutInfo.isSticky || this.virtualizer.isPersistedKey(node.layoutInfo.key); } - protected shouldInvalidateEverything(invalidationContext: InvalidationContext, unknown>) { + protected shouldInvalidateEverything(invalidationContext: InvalidationContext) { // Invalidate cache if the size of the collection changed. // In this case, we need to recalculate the entire layout. return invalidationContext.sizeChanged; } - validate(invalidationContext: InvalidationContext, unknown>) { + validate(invalidationContext: InvalidationContext) { this.collection = this.virtualizer.collection; + this.isLoading = invalidationContext.layoutOptions?.isLoading || false; // 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.lastValidRect = this.validRect; - this.validRect = this.virtualizer.getVisibleRect(); + this.validRect = this.virtualizer.visibleRect.copy(); } this.rootNodes = this.buildCollection(); @@ -558,18 +581,6 @@ export class ListLayout extends Layout> implements KeyboardDelegate, return null; } - getInitialLayoutInfo(layoutInfo: LayoutInfo) { - layoutInfo.opacity = 0; - layoutInfo.transform = 'scale3d(0.8, 0.8, 0.8)'; - return layoutInfo; - } - - getFinalLayoutInfo(layoutInfo: LayoutInfo) { - layoutInfo.opacity = 0; - layoutInfo.transform = 'scale3d(0.8, 0.8, 0.8)'; - return layoutInfo; - } - getDropTargetFromPoint(x: number, y: number, isValidDropTarget: (target: DropTarget) => boolean): DropTarget { x += this.virtualizer.visibleRect.x; y += this.virtualizer.visibleRect.y; diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 658dc5dcca1..7019f5a4667 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -11,7 +11,7 @@ */ import {ColumnSize, TableCollection} from '@react-types/table'; -import {DropTarget, Key, Node} from '@react-types/shared'; +import {DropTarget, Key} from '@react-types/shared'; import {getChildNodes} from '@react-stately/collections'; import {GridNode} from '@react-types/grid'; import {InvalidationContext, LayoutInfo, Point, Rect, Size} from '@react-stately/virtualizer'; @@ -51,7 +51,7 @@ export class TableLayout extends ListLayout { this.uncontrolledWidths = this.columnLayout.getInitialUncontrolledWidths(uncontrolledColumns); } - protected shouldInvalidateEverything(invalidationContext: InvalidationContext, unknown>): boolean { + protected shouldInvalidateEverything(invalidationContext: InvalidationContext): boolean { // If columns changed, clear layout cache. return super.shouldInvalidateEverything(invalidationContext) || ( !this.lastCollection || @@ -102,10 +102,10 @@ export class TableLayout extends ListLayout { let map = new Map(Array.from(this.uncontrolledColumns).map(([key]) => [key, newSizes.get(key)])); map.set(key, width); this.uncontrolledWidths = map; - // relayoutNow still uses setState, should happen at the same time the parent + // invalidate still uses setState, should happen at the same time the parent // component's state is processed as a result of props.onColumnResize if (this.uncontrolledWidths.size > 0) { - this.virtualizer.relayoutNow({sizeChanged: true}); + this.virtualizer.invalidate({sizeChanged: true}); } return newSizes; } @@ -471,6 +471,7 @@ export class TableLayout extends ListLayout { let idx = persistedRowIndices[persistIndex++]; if (idx < node.children.length) { res.push(node.children[idx].layoutInfo); + this.addVisibleLayoutInfos(res, node.children[idx], rect); } } break; @@ -570,18 +571,6 @@ export class TableLayout extends ListLayout { } } - getInitialLayoutInfo(layoutInfo: LayoutInfo) { - let res = super.getInitialLayoutInfo(layoutInfo); - res.transform = null; - return res; - } - - getFinalLayoutInfo(layoutInfo: LayoutInfo) { - let res = super.getFinalLayoutInfo(layoutInfo); - res.transform = null; - return res; - } - // Checks if Chrome version is 105 or greater private checkChrome105() { if (typeof window === 'undefined' || window.navigator == null) { diff --git a/packages/@react-stately/virtualizer/src/Layout.ts b/packages/@react-stately/virtualizer/src/Layout.ts index 86bd557b31f..739a01d7f35 100644 --- a/packages/@react-stately/virtualizer/src/Layout.ts +++ b/packages/@react-stately/virtualizer/src/Layout.ts @@ -13,17 +13,15 @@ import {InvalidationContext} from './types'; import {Key} from '@react-types/shared'; import {LayoutInfo} from './LayoutInfo'; -// import {Point} from './Point'; import {Rect} from './Rect'; import {Size} from './Size'; import {Virtualizer} from './Virtualizer'; -// import { DragTarget, DropTarget } from '@react-types/shared'; /** - * [CollectionView]{@link CollectionView} supports arbitrary layout objects, which compute what views are visible, and how + * [Virtualizer]{@link Virtualizer} supports arbitrary layout objects, which compute what views are visible, and how * to position and style them. However, layouts do not create the views themselves directly. Instead, * layouts produce lightweight {@link LayoutInfo} objects which describe various properties of a view, - * such as its position and size. The {@link CollectionView} is then responsible for creating the actual + * such as its position and size. The {@link Virtualizer} is then responsible for creating the actual * views as needed, based on this layout information. * * Every layout extends from the {@link Layout} abstract base class. Layouts must implement a minimum of the @@ -32,14 +30,14 @@ import {Virtualizer} from './Virtualizer'; * @see {@link getVisibleLayoutInfos} * @see {@link getLayoutInfo} */ -export abstract class Layout { - /** The CollectionView the layout is currently attached to. */ +export abstract class Layout { + /** The Virtualizer the layout is currently attached to. */ virtualizer: Virtualizer; /** * Returns whether the layout should invalidate in response to * visible rectangle changes. By default, it only invalidates - * when the collection view's size changes. Return true always + * when the virtualizer's size changes. Return true always * to make the layout invalidate while scrolling (e.g. sticky headers). */ shouldInvalidate(newRect: Rect, oldRect: Rect): boolean { @@ -51,10 +49,10 @@ export abstract class Layout { /** * This method allows the layout to perform any pre-computation * it needs to in order to prepare {@link LayoutInfo}s for retrieval. - * Called by the collection view before {@link getVisibleLayoutInfos} + * Called by the virtualizer before {@link getVisibleLayoutInfos} * or {@link getLayoutInfo} are called. */ - validate(invalidationContext: InvalidationContext) {} // eslint-disable-line @typescript-eslint/no-unused-vars + validate(invalidationContext: InvalidationContext) {} // eslint-disable-line @typescript-eslint/no-unused-vars /** * Returns an array of {@link LayoutInfo} objects which are inside the given rectangle. @@ -75,51 +73,8 @@ export abstract class Layout { */ abstract getContentSize(): Size; - /** - * Returns a {@link DragTarget} describing a view at the given point to be dragged. - * Return `null` to cancel the drag. The default implementation returns the view at the given point. - * @param point The point at which the drag occurred. + /** + * Updates the size of the given item. */ - // getDragTarget(point: Point): DragTarget | null { - // let target = this.virtualizer.keyAtPoint(point); - // if (!target) { - // return null; - // } - - // return { - // type: 'item', - // key: target - // }; - // } - - /** - * Returns a {@link DragTarget} object describing where a drop should occur. Return `null` - * to reject the drop. The dropped items will be inserted before the resulting target. - * @param point The point at which the drop occurred. - */ - // getDropTarget(point: Point): DropTarget | null { - // return null; - // } - - /** - * Returns the starting attributes for an animated insertion. - * The view is animated from this {@link LayoutInfo} to the one returned by {@link getLayoutInfo}. - * The default implementation just returns its input. - * - * @param layoutInfo The proposed LayoutInfo for this view. - */ - getInitialLayoutInfo(layoutInfo: LayoutInfo): LayoutInfo { - return layoutInfo; - } - - /** - * Returns the ending attributes for an animated removal. - * The view is animated from the {@link LayoutInfo} returned by {@link getLayoutInfo} - * to the one returned by this method. The default implementation returns its input. - * - * @param layoutInfo The original LayoutInfo for this view. - */ - getFinalLayoutInfo(layoutInfo: LayoutInfo): LayoutInfo { - return layoutInfo; - } + updateItemSize?(key: Key, size: Size): boolean; } diff --git a/packages/@react-stately/virtualizer/src/LayoutInfo.ts b/packages/@react-stately/virtualizer/src/LayoutInfo.ts index e6213dd7103..0f6ffb39792 100644 --- a/packages/@react-stately/virtualizer/src/LayoutInfo.ts +++ b/packages/@react-stately/virtualizer/src/LayoutInfo.ts @@ -15,9 +15,9 @@ import {Rect} from './Rect'; /** * Instances of this lightweight class are created by {@link Layout} subclasses - * to represent each view in the {@link CollectionView}. LayoutInfo objects describe + * to represent each view in the {@link Virtualizer}. LayoutInfo objects describe * various properties of a view, such as its position and size, and style information. - * The collection view uses this information when creating actual views to display. + * The virtualizer uses this information when creating actual views to display. */ export class LayoutInfo { /** diff --git a/packages/@react-stately/virtualizer/src/ReusableView.ts b/packages/@react-stately/virtualizer/src/ReusableView.ts index f2bf03b7828..4a817da88e3 100644 --- a/packages/@react-stately/virtualizer/src/ReusableView.ts +++ b/packages/@react-stately/virtualizer/src/ReusableView.ts @@ -17,20 +17,17 @@ import {Virtualizer} from './Virtualizer'; let KEY = 0; /** - * [CollectionView]{@link CollectionView} creates instances of the [ReusableView]{@link ReusableView} class to - * represent views currently being displayed. ReusableViews manage a DOM node, handle - * applying {@link LayoutInfo} objects to the view, and render content - * as needed. Subclasses must implement the {@link render} method at a - * minimum. Other methods can be overridden to customize behavior. + * [Virtualizer]{@link Virtualizer} creates instances of the [ReusableView]{@link ReusableView} class to + * represent views currently being displayed. */ export class ReusableView { - /** The CollectionVirtualizer this view is a part of. */ + /** The Virtualizer this view is a part of. */ virtualizer: Virtualizer; /** The LayoutInfo this view is currently representing. */ layoutInfo: LayoutInfo | null; - /** The content currently being displayed by this view, set by the collection view. */ + /** The content currently being displayed by this view, set by the virtualizer. */ content: T; rendered: V; @@ -38,9 +35,16 @@ export class ReusableView { viewType: string; key: Key; + parent: ReusableView | null; + children: Set>; + reusableViews: Record[]>; + constructor(virtualizer: Virtualizer) { this.virtualizer = virtualizer; this.key = ++KEY; + this.parent = null; + this.children = new Set(); + this.reusableViews = {}; } /** @@ -51,4 +55,21 @@ export class ReusableView { this.rendered = null; this.layoutInfo = null; } + + getReusableView(reuseType: string) { + let reusable = this.reusableViews[reuseType]; + let view = reusable?.length > 0 + ? reusable.pop() + : new ReusableView(this.virtualizer); + + view.viewType = reuseType; + view.parent = this; + return view; + } + + reuseChild(child: ReusableView) { + child.prepareForReuse(); + this.reusableViews[child.viewType] ||= []; + this.reusableViews[child.viewType].push(child); + } } diff --git a/packages/@react-stately/virtualizer/src/Transaction.ts b/packages/@react-stately/virtualizer/src/Transaction.ts deleted file mode 100644 index 76411f7ea41..00000000000 --- a/packages/@react-stately/virtualizer/src/Transaction.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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 {Key} from '@react-types/shared'; -import {LayoutInfo} from './LayoutInfo'; -import {ReusableView} from './ReusableView'; - -type LayoutInfoMap = Map; -export class Transaction { - level = 0; - actions: (() => void)[] = []; - animated = true; - initialMap: LayoutInfoMap = new Map(); - finalMap: LayoutInfoMap = new Map(); - initialLayoutInfo: LayoutInfoMap = new Map(); - finalLayoutInfo: LayoutInfoMap = new Map(); - removed: Map> = new Map(); - toRemove: Map> = new Map(); -} diff --git a/packages/@react-stately/virtualizer/src/Virtualizer.ts b/packages/@react-stately/virtualizer/src/Virtualizer.ts index 051f0788195..2b801901a60 100644 --- a/packages/@react-stately/virtualizer/src/Virtualizer.ts +++ b/packages/@react-stately/virtualizer/src/Virtualizer.ts @@ -10,16 +10,9 @@ * governing permissions and limitations under the License. */ -import {CancelablePromise, easeOut, tween} from './tween'; import {Collection, Key} from '@react-types/shared'; -import {concatIterators, difference, isSetEqual} from './utils'; -import { - InvalidationContext, - ScrollAnchor, - ScrollToItemOptions, - VirtualizerDelegate, - VirtualizerOptions -} from './types'; +import {InvalidationContext, Mutable, VirtualizerDelegate, VirtualizerRenderOptions} from './types'; +import {isSetEqual} from './utils'; import {Layout} from './Layout'; import {LayoutInfo} from './LayoutInfo'; import {OverscanManager} from './OverscanManager'; @@ -27,237 +20,70 @@ import {Point} from './Point'; import {Rect} from './Rect'; import {ReusableView} from './ReusableView'; import {Size} from './Size'; -import {Transaction} from './Transaction'; /** - * The CollectionView class renders a scrollable collection of data using customizable layouts, - * and manages animated updates to the data over time. It supports very large collections by - * only rendering visible views to the DOM, reusing them as you scroll. Collection views can - * present any type of view, including non-item views such as section headers and footers. - * Optionally, the {@link EditableCollectionView} subclass can be used to enable user interaction - * with the collection, including drag and drop, multiple selection, and keyboard interacton. + * The Virtualizer class renders a scrollable collection of data using customizable layouts. + * It supports very large collections by only rendering visible views to the DOM, reusing + * them as you scroll. Virtualizer can present any type of view, including non-item views + * such as section headers and footers. * - * Collection views get their data from a {@link DataSource} object that you provide. Items are - * grouped into sections by the data source, and the collection view calls its methods to retrieve - * the data. When data changes, the data source emits change events, and the collection view - * updates as appropriate, optionally with an animated transition. There is one built-in data source - * implementation, {@link ArrayDataSource}, which renders content from a 2d array. - * - * Collection views use {@link Layout} objects to compute what views should be visible, and how - * to position and style them. This means that collection views can have their items arranged in + * Virtualizer uses {@link Layout} objects to compute what views should be visible, and how + * to position and style them. This means that virtualizer can have its items arranged in * a stack, a grid, a circle, or any other layout you can think of. The layout can be changed - * dynamically at runtime as well, optionally with an animated transition between the layouts. + * dynamically at runtime as well. * - * Layouts produce information on what views should appear in the collection view, but do not create - * the views themselves directly. It is the responsibility of the {@link CollectionViewDelegate} object - * to create instances of {@link ReusableView} subclasses which render the items into DOM nodes. - * The delegate determines what type of view to display for each item, and creates instances of - * views as needed by the collection view. Those views are then reused by the collection view as - * the user scrolls through the content. + * Layouts produce information on what views should appear in the virtualizer, but do not create + * the views themselves directly. It is the responsibility of the {@link VirtualizerDelegate} object + * to render elements for each layout info. The virtualizer manages a set of {@link ReusableView} objects, + * which are reused as the user scrolls by swapping their content with cached elements returned by the delegate. */ export class Virtualizer { /** - * The collection view delegate. The delegate is used by the collection view + * The virtualizer delegate. The delegate is used by the virtualizer * to create and configure views. */ delegate: VirtualizerDelegate; - /** The duration of animated layout changes, in milliseconds. Default is 500ms. */ - transitionDuration: number; - - /** - * Whether to enable scroll anchoring. This will attempt to restore the scroll position - * after layout changes outside the viewport. Default is off. - */ - anchorScrollPosition: boolean; + /** The current content of the virtualizer. */ + readonly collection: Collection; + /** The layout object that determines the visible views. */ + readonly layout: Layout; + /** The size of the scrollable content. */ + readonly contentSize: Size; + /** The currently visible rectangle. */ + readonly visibleRect: Rect; + /** The set of persisted keys that are always present in the DOM, even if not currently in view. */ + readonly persistedKeys: Set; - /** Whether to anchor the scroll position when at the top of the content. Default is off. */ - anchorScrollPositionAtTop: boolean; - - /** - * Whether to overscan the visible area to pre-render items slightly outside and - * improve performance. Default is on. - */ - shouldOverscan: boolean; - - private _collection: Collection; - private _layout: Layout; - private _contentSize: Size; - private _visibleRect: Rect; - private _visibleLayoutInfos: Map; - private _reusableViews: {[type: string]: ReusableView[]}; private _visibleViews: Map>; private _renderedContent: WeakMap; - private _children: Set>; - private _viewsByParentKey: Map[]>; - private _invalidationContext: InvalidationContext | null; - private _overscanManager: OverscanManager; - private _persistedKeys: Set; - private _relayoutRaf: number | null; - private _scrollAnimation: CancelablePromise | null; + private _rootView: ReusableView; private _isScrolling: boolean; - private _sizeUpdateQueue: Map; - private _animatedContentOffset: Point; - private _transaction: Transaction | null; - private _nextTransaction: Transaction | null; - private _transactionQueue: Transaction[]; - - constructor(options: VirtualizerOptions = {}) { - this._contentSize = new Size; - this._visibleRect = new Rect; + private _invalidationContext: InvalidationContext | null; + private _overscanManager: OverscanManager; - this._reusableViews = {}; - this._visibleLayoutInfos = new Map(); + constructor(delegate: VirtualizerDelegate) { + this.delegate = delegate; + this.contentSize = new Size; + this.visibleRect = new Rect; + this.persistedKeys = new Set(); this._visibleViews = new Map(); this._renderedContent = new WeakMap(); - this._children = new Set(); + this._rootView = new ReusableView(this); + this._isScrolling = false; this._invalidationContext = null; this._overscanManager = new OverscanManager(); - this._persistedKeys = new Set(); - - this._scrollAnimation = null; - this._isScrolling = false; - this._sizeUpdateQueue = new Map(); - this._animatedContentOffset = new Point(0, 0); - - this._transaction = null; - this._nextTransaction = null; - this._transactionQueue = []; - - // Set options from passed object if given - this.transitionDuration = options.transitionDuration ?? 500; - this.anchorScrollPosition = options.anchorScrollPosition || false; - this.anchorScrollPositionAtTop = options.anchorScrollPositionAtTop || false; - this.shouldOverscan = options.shouldOverscan !== false; - for (let key of ['delegate', 'size', 'layout', 'collection']) { - if (options[key]) { - this[key] = options[key]; - } - } - } - - _setContentSize(size: Size) { - this._contentSize = size; - this.delegate.setContentSize(size); - } - - _setContentOffset(offset: Point) { - let rect = new Rect(offset.x, offset.y, this._visibleRect.width, this._visibleRect.height); - this.delegate.setVisibleRect(rect); - } - - /** - * Get the size of the scrollable content. - */ - get contentSize(): Size { - return this._contentSize; - } - - /** - * Get the collection view's currently visible rectangle. - */ - get visibleRect(): Rect { - return this._visibleRect; - } - - /** - * Set the collection view's currently visible rectangle. - */ - set visibleRect(rect: Rect) { - this._setVisibleRect(rect); - } - - _setVisibleRect(rect: Rect, forceUpdate = false) { - let current = this._visibleRect; - - // Ignore if the rects are equal - if (rect.equals(current)) { - return; - } - - if (this.shouldOverscan) { - this._overscanManager.setVisibleRect(rect); - } - - let shouldInvalidate = this.layout && this.layout.shouldInvalidate(rect, this._visibleRect); - - this._resetAnimatedContentOffset(); - this._visibleRect = rect; - - if (shouldInvalidate) { - // We are already in a layout effect when this method is called, so relayoutNow is appropriate. - this.relayoutNow({ - offsetChanged: !rect.pointEquals(current), - sizeChanged: !rect.sizeEquals(current) - }); - } else { - this.updateSubviews(forceUpdate); - } - } - - get collection(): Collection { - return this._collection; - } - - set collection(data: Collection) { - this._setData(data); - } - - private _setData(data: Collection) { - if (data === this._collection) { - return; - } - - if (this._collection) { - this._runTransaction(() => { - this._collection = data; - }, this.transitionDuration > 0); - } else { - this._collection = data; - this.reloadData(); - } - } - - /** - * Reloads the data from the data source and relayouts the collection view. - * Does not animate any changes. Equivalent to re-assigning the same data source - * to the collection view. - */ - reloadData() { - this.relayout({ - contentChanged: true - }); - } - - /** - * Returns the item with the given key. - */ - getItem(key: Key) { - return this._collection ? this._collection.getItem(key) : null; - } - - /** The set of persisted keys are always present in the DOM, even if not currently in view. */ - get persistedKeys(): Set { - return this._persistedKeys; - } - - /** The set of persisted keys are always present in the DOM, even if not currently in view. */ - set persistedKeys(persistedKeys: Set) { - if (!isSetEqual(persistedKeys, this._persistedKeys)) { - this._persistedKeys = persistedKeys; - this.updateSubviews(); - } } /** Returns whether the given key, or an ancestor, is persisted. */ isPersistedKey(key: Key) { // Quick check if the key is directly in the set of persisted keys. - if (this._persistedKeys.has(key)) { + if (this.persistedKeys.has(key)) { return true; } // If not, check if the key is an ancestor of any of the persisted keys. - for (let k of this._persistedKeys) { + for (let k of this.persistedKeys) { while (k != null) { let layoutInfo = this.layout.getLayoutInfo(k); if (!layoutInfo) { @@ -275,96 +101,17 @@ export class Virtualizer { return false; } - /** - * Get the collection view's layout. - */ - get layout(): Layout { - return this._layout; - } - - /** - * Set the collection view's layout. - */ - set layout(layout: Layout) { - this.setLayout(layout); - } - - /** - * Sets the collection view's layout, optionally with an animated transition - * from the current layout to the new layout. - * @param layout The layout to switch to. - * @param animated Whether to animate the layout change. - */ - setLayout(layout: Layout, animated = false) { - if (layout === this._layout) { - return; - } - - let applyLayout = () => { - if (this._layout) { - // @ts-ignore - this._layout.virtualizer = null; - } - - layout.virtualizer = this; - this._layout = layout; - }; - - if (animated) { - // Animated layout transitions are really simple, thanks to our transaction support. - // We just set the layout inside a transaction action, which runs after the initial - // layout infos for the animation are retrieved from the previous layout. Then, the - // final layout infos are retrieved from the new layout, and animations occur. - this._runTransaction(applyLayout); - } else { - applyLayout(); - this.relayout(); - } - } - - private _getReuseType(layoutInfo: LayoutInfo, content: T | null) { - if (layoutInfo.type === 'item' && content) { - let type = this.delegate.getType ? this.delegate.getType(content) : 'item'; - let reuseType = type === 'item' ? 'item' : layoutInfo.type + '_' + type; - return {type, reuseType}; - } - - return { - type: layoutInfo.type, - reuseType: layoutInfo.type - }; - } - - getReusableView(layoutInfo: LayoutInfo): ReusableView { - let content = this.getItem(layoutInfo.key); - let {reuseType} = this._getReuseType(layoutInfo, content); - - if (!this._reusableViews[reuseType]) { - this._reusableViews[reuseType] = []; - } - - let reusable = this._reusableViews[reuseType]; - let view = reusable.length > 0 - ? reusable.pop() - : new ReusableView(this); - - view.viewType = reuseType; - - if (!this._animatedContentOffset.isOrigin()) { - layoutInfo = layoutInfo.copy(); - layoutInfo.rect.x += this._animatedContentOffset.x; - layoutInfo.rect.y += this._animatedContentOffset.y; - } - + private getReusableView(layoutInfo: LayoutInfo): ReusableView { + let parentView = layoutInfo.parentKey != null ? this._visibleViews.get(layoutInfo.parentKey) : this._rootView; + let view = parentView.getReusableView(layoutInfo.type); view.layoutInfo = layoutInfo; - this._renderView(view); return view; } private _renderView(reusableView: ReusableView) { let {type, key} = reusableView.layoutInfo; - reusableView.content = this.getItem(key); + reusableView.content = this.collection.getItem(key); reusableView.rendered = this._renderContent(type, reusableView.content); } @@ -381,44 +128,6 @@ export class Virtualizer { return rendered; } - /** - * Returns an array of all currently visible views, including both - * item views and supplementary views. - */ - get visibleViews(): ReusableView[] { - return Array.from(this._visibleViews.values()); - } - - /** - * Gets the visible view for the given type and key. Returns null if - * the view is not currently visible. - * - * @param key The key of the view to retrieve. - */ - getView(key: Key): ReusableView | null { - return this._visibleViews.get(key) || null; - } - - /** - * Returns an array of visible views matching the given type. - * @param type The view type to find. - */ - getViewsOfType(type: string): ReusableView[] { - return this.visibleViews.filter(v => v.layoutInfo && v.layoutInfo.type === type); - } - - /** - * Returns the key for the given view. Returns null - * if the view is not currently visible. - */ - keyForView(view: ReusableView): Key | null { - if (view && view.layoutInfo) { - return view.layoutInfo.key; - } - - return null; - } - /** * Returns the key for the item view currently at the given point. */ @@ -437,810 +146,205 @@ export class Virtualizer { return null; } - /** - * Cleanup for when the Virtualizer will be unmounted. - */ - willUnmount() { - cancelAnimationFrame(this._relayoutRaf); - } - - /** - * Triggers a layout invalidation, and updates the visible subviews. - */ - relayout(context: InvalidationContext = {}) { - // Ignore relayouts while animating the scroll position - if (this._scrollAnimation || typeof requestAnimationFrame === 'undefined') { - return; - } - - // If we already scheduled a relayout, extend the invalidation - // context so we coalesce multiple relayouts in the same frame. - if (this._invalidationContext) { - Object.assign(this._invalidationContext, context); - return; - } - - this._invalidationContext = context; - } - - /** - * Performs a relayout immediately. Prefer {@link relayout} over this method - * where possible, since it coalesces multiple layout passes in the same tick. - */ - relayoutNow(context: InvalidationContext = this._invalidationContext || {}) { - // Cancel the scheduled relayout, since we're doing it now. - if (this._relayoutRaf) { - cancelAnimationFrame(this._relayoutRaf); - this._relayoutRaf = null; - // Update the provided context with the current invalidationContext since we are cancelling - // a scheduled relayoutNow call that has this._invalidationContext set as its default context arg (relayoutNow() in relayout) - context = {...this._invalidationContext, ...context}; - } - - // Reset the invalidation context - this._invalidationContext = null; - - // Do nothing if we don't have a layout or content, or we are - // in the middle of an animated scroll transition. - if (!this.layout || !this._collection || this._scrollAnimation) { - return; - } - - let scrollAnchor = this._getScrollAnchor(); - - // Trigger the beforeLayout hook, if provided - if (typeof context.beforeLayout === 'function') { - context.beforeLayout(); - } - + private relayout(context: InvalidationContext = {}) { // Validate the layout this.layout.validate(context); - this._setContentSize(this.layout.getContentSize()); - - // Trigger the afterLayout hook, if provided - if (typeof context.afterLayout === 'function') { - context.afterLayout(); - } + (this as Mutable).contentSize = this.layout.getContentSize(); - // Adjust scroll position based on scroll anchor, and constrain. + // Constrain scroll position. // If the content changed, scroll to the top. - let visibleRect = this.getVisibleRect(); - let restoredScrollAnchor = this._restoreScrollAnchor(scrollAnchor, context); - let contentOffsetX = context.contentChanged ? 0 : restoredScrollAnchor.x; - let contentOffsetY = context.contentChanged ? 0 : restoredScrollAnchor.y; + let visibleRect = this.visibleRect; + let contentOffsetX = context.contentChanged ? 0 : visibleRect.x; + let contentOffsetY = context.contentChanged ? 0 : visibleRect.y; contentOffsetX = Math.max(0, Math.min(this.contentSize.width - visibleRect.width, contentOffsetX)); contentOffsetY = Math.max(0, Math.min(this.contentSize.height - visibleRect.height, contentOffsetY)); - let hasLayoutUpdates = false; if (contentOffsetX !== visibleRect.x || contentOffsetY !== visibleRect.y) { - // If this is an animated relayout, we do not immediately scroll because it would be jittery. - // Save the difference between the current and new content offsets, and apply it to the - // individual content items instead. At the end of the animation, we'll reset and set the - // scroll offset for real. This ensures jitter-free animation since we don't need to sync - // the scroll animation and the content animation. - if (context.animated || !this._animatedContentOffset.isOrigin()) { - this._animatedContentOffset.x += visibleRect.x - contentOffsetX; - this._animatedContentOffset.y += visibleRect.y - contentOffsetY; - hasLayoutUpdates = this.updateSubviews(context.contentChanged); - } else { - this._setContentOffset(new Point(contentOffsetX, contentOffsetY)); - } + // If the offset changed, trigger a new re-render. + let rect = new Rect(contentOffsetX, contentOffsetY, visibleRect.width, visibleRect.height); + this.delegate.setVisibleRect(rect); } else { - hasLayoutUpdates = this.updateSubviews(context.contentChanged); - } - - // Apply layout infos, unless this is coming from an animated transaction - if (!(context.transaction && context.animated)) { - this._applyLayoutInfos(); - } - - // Wait for animations, and apply the afterAnimation hook, if provided - if (context.animated && hasLayoutUpdates) { - this._enableTransitions(); - - let done = () => { - this._disableTransitions(); - - // Reset scroll position after animations (see above comment). - if (!this._animatedContentOffset.isOrigin()) { - // Get the content offset to scroll to, taking _animatedContentOffset into account. - let {x, y} = this.getVisibleRect(); - this._resetAnimatedContentOffset(); - this._setContentOffset(new Point(x, y)); - } - - if (typeof context.afterAnimation === 'function') { - context.afterAnimation(); - } - }; - - // Sometimes the animation takes slightly longer than expected. - setTimeout(done, this.transitionDuration + 100); - return; - } else if (typeof context.afterAnimation === 'function') { - context.afterAnimation(); - } - } - - /** - * Corrects DOM order of visible views to match item order of collection. - */ - private _correctItemOrder() { - // Defer until after scrolling and animated transactions are complete - if (this._isScrolling || this._transaction) { - return; - } - - for (let key of this._visibleLayoutInfos.keys()) { - let view = this._visibleViews.get(key); - this._children.delete(view); - this._children.add(view); - } - } - - private _enableTransitions() { - this.delegate.beginAnimations(); - } - - private _disableTransitions() { - this.delegate.endAnimations(); - } - - private _getScrollAnchor(): ScrollAnchor | null { - if (!this.anchorScrollPosition) { - return null; - } - - let visibleRect = this.getVisibleRect(); - - // Ask the delegate to provide a scroll anchor, if possible - if (this.delegate.getScrollAnchor) { - let key = this.delegate.getScrollAnchor(visibleRect); - if (key != null) { - let layoutInfo = this.layout.getLayoutInfo(key); - let corner = layoutInfo.rect.getCornerInRect(visibleRect); - if (corner) { - let key = layoutInfo.key; - let offset = layoutInfo.rect[corner].y - visibleRect.y; - return {key, layoutInfo, corner, offset}; - } - } - } - - // No need to anchor the scroll position if it is at the top - if (visibleRect.y === 0 && !this.anchorScrollPositionAtTop) { - return null; - } - - // Find a view with a visible corner that has the smallest distance to the top of the collection view - let cornerAnchor: ScrollAnchor | null = null; - - for (let [key, view] of this._visibleViews) { - let layoutInfo = view.layoutInfo; - if (layoutInfo && layoutInfo.rect.area > 0) { - let corner = layoutInfo.rect.getCornerInRect(visibleRect); - - if (corner) { - let offset = layoutInfo.rect[corner].y - visibleRect.y; - if (!cornerAnchor || (offset < cornerAnchor.offset)) { - cornerAnchor = {key, layoutInfo, corner, offset}; - } - } - } - } - - return cornerAnchor; - } - - private _restoreScrollAnchor(scrollAnchor: ScrollAnchor | null, context: InvalidationContext) { - let contentOffset = this.getVisibleRect(); - - if (scrollAnchor) { - let finalAnchor = context.transaction?.animated - ? context.transaction.finalMap.get(scrollAnchor.key) - : this.layout.getLayoutInfo(scrollAnchor.layoutInfo.key); - - if (finalAnchor) { - let adjustment = (finalAnchor.rect[scrollAnchor.corner].y - contentOffset.y) - scrollAnchor.offset; - contentOffset.y += adjustment; - } + this.updateSubviews(); } - - return contentOffset; - } - - getVisibleRect(): Rect { - let v = this.visibleRect; - let x = v.x - this._animatedContentOffset.x; - let y = v.y - this._animatedContentOffset.y; - return new Rect(x, y, v.width, v.height); } getVisibleLayoutInfos() { let isTestEnv = process.env.NODE_ENV === 'test' && !process.env.VIRT_ON; + let isClientWidthMocked = isTestEnv && typeof HTMLElement !== 'undefined' && Object.getOwnPropertyNames(HTMLElement.prototype).includes('clientWidth'); + let isClientHeightMocked = isTestEnv && typeof HTMLElement !== 'undefined' && Object.getOwnPropertyNames(HTMLElement.prototype).includes('clientHeight'); - let isClientWidthMocked = Object.getOwnPropertyNames(window.HTMLElement.prototype).includes('clientWidth'); - let isClientHeightMocked = Object.getOwnPropertyNames(window.HTMLElement.prototype).includes('clientHeight'); - - let rect; + let rect: Rect; if (isTestEnv && !(isClientWidthMocked && isClientHeightMocked)) { - rect = this._getContentRect(); + rect = new Rect(0, 0, this.contentSize.width, this.contentSize.height); } else { - rect = this.shouldOverscan ? this._overscanManager.getOverscannedRect() : this.getVisibleRect(); + rect = this._overscanManager.getOverscannedRect(); } - this._visibleLayoutInfos = this._getLayoutInfoMap(rect); - return this._visibleLayoutInfos; - } - - private _getLayoutInfoMap(rect: Rect, copy = false) { let layoutInfos = this.layout.getVisibleLayoutInfos(rect); let map = new Map; - for (let layoutInfo of layoutInfos) { - if (copy) { - layoutInfo = layoutInfo.copy(); - } - map.set(layoutInfo.key, layoutInfo); } return map; } - updateSubviews(forceUpdate = false) { - if (!this._collection) { - return; - } - + private updateSubviews() { let visibleLayoutInfos = this.getVisibleLayoutInfos(); - let currentlyVisible = this._visibleViews; - let toAdd, toRemove, toUpdate; - - // If this is a force update, remove and re-add all views. - // Otherwise, find and update the diff. - if (forceUpdate) { - toAdd = visibleLayoutInfos; - toRemove = currentlyVisible; - toUpdate = new Set(); - } else { - ({toAdd, toRemove, toUpdate} = difference(currentlyVisible, visibleLayoutInfos)); - - for (let key of toUpdate) { - let view = currentlyVisible.get(key); - if (!view || !view.layoutInfo) { - continue; - } - - let item = this.getItem(visibleLayoutInfos.get(key).key); - if (view.content === item) { - toUpdate.delete(key); - } else { - // If the view type changes, delete and recreate the view instead of updating - let {reuseType} = this._getReuseType(view.layoutInfo, item); - if (view.viewType !== reuseType) { - toUpdate.delete(key); - toAdd.add(key); - toRemove.add(key); - } - } - } - // We are done if the sets are equal - if (toAdd.size === 0 && toRemove.size === 0 && toUpdate.size === 0) { - if (this._transaction) { - this._applyLayoutInfos(); - } - - return; - } - } - - // Track views that should be removed. They are not removed from - // the DOM immediately, since we may reuse and need to re-insert - // them back into the DOM anyway. let removed = new Set>(); - - for (let key of toRemove.keys()) { - let view = this._visibleViews.get(key); - if (view) { - removed.add(view); + for (let [key, view] of this._visibleViews) { + if (!visibleLayoutInfos.has(key)) { this._visibleViews.delete(key); - - // If we are in the middle of a transaction, wait until the end - // of the animations to remove the views from the DOM. Also means - // we can't reuse those views immediately. - if (this._transaction) { - this._transaction.toRemove.set(key, view); - } else { - this.reuseView(view); - } + view.parent.reuseChild(view); + removed.add(view); // Defer removing in case we reuse this view. } } - for (let key of toAdd.keys()) { - let layoutInfo = visibleLayoutInfos.get(key); - let view: ReusableView | void; - - // If we're in a transaction, and a layout change happens - // during the animations such that a view that was going - // to be removed is now not, we don't create a new view - // since the old one is still in the DOM, marked as toRemove. - if (this._transaction) { - // if transaction, get initial layout attributes for the animation - if (this._transaction.initialLayoutInfo.has(key)) { - layoutInfo = this._transaction.initialLayoutInfo.get(key); - } - - view = this._transaction.toRemove.get(key); - if (view) { - this._transaction.toRemove.delete(key); - this._applyLayoutInfo(view, layoutInfo); - } - } - + for (let [key, layoutInfo] of visibleLayoutInfos) { + let view = this._visibleViews.get(key); if (!view) { - // Create or reuse a view for this row view = this.getReusableView(layoutInfo); + view.parent.children.add(view); + this._visibleViews.set(key, view); + removed.delete(view); + } else { + view.layoutInfo = layoutInfo; - // Add the view to the DOM if needed - if (!removed.has(view)) { - this._children.add(view); + let item = this.collection.getItem(layoutInfo.key); + if (view.content !== item) { + this._renderedContent.delete(view.content); + this._renderView(view); } } - - this._visibleViews.set(key, view); - removed.delete(view); - } - - for (let key of toUpdate) { - let view = currentlyVisible.get(key) as ReusableView; - this._renderedContent.delete(key); - this._renderView(view); - } - - // Remove the remaining rows to delete from the DOM - if (!this._transaction) { - this.removeViews(removed); - } - - this._correctItemOrder(); - this._flushVisibleViews(); - - let hasLayoutUpdates = this._transaction && (toAdd.size > 0 || toRemove.size > 0 || this._hasLayoutUpdates()); - if (hasLayoutUpdates) { - requestAnimationFrame(() => { - // If we're in a transaction, apply animations to visible views - // and "to be removed" views, which animate off screen. - if (this._transaction) { - requestAnimationFrame(() => this._applyLayoutInfos()); - } - }); } - return hasLayoutUpdates; - } - - afterRender() { - if (this._transactionQueue.length > 0) { - this._processTransactionQueue(); - } else if (this._invalidationContext) { - this.relayoutNow(); + for (let view of removed) { + view.parent.children.delete(view); } - if (this.shouldOverscan) { - this._overscanManager.collectMetrics(); - } - } - - private _flushVisibleViews() { - // CollectionVirtualizer deals with a flattened set of LayoutInfos, but they can represent hierarchy - // by referencing a parentKey. Just before rendering the visible views, we rebuild this hierarchy - // by creating a mapping of views by parent key and recursively calling the delegate's renderWrapper - // method to build the final tree. - this._viewsByParentKey = new Map([[null, []]]); - for (let view of this._children) { - if (view.layoutInfo?.parentKey != null && !this._viewsByParentKey.has(view.layoutInfo.parentKey)) { - this._viewsByParentKey.set(view.layoutInfo.parentKey, []); - } - - this._viewsByParentKey.get(view.layoutInfo?.parentKey)?.push(view); - if (!this._viewsByParentKey.has(view.layoutInfo?.key)) { - this._viewsByParentKey.set(view.layoutInfo?.key, []); + // Reordering DOM nodes is costly, so we defer this until scrolling stops. + // DOM order does not affect visual order (due to absolute positioning), + // but does matter for assistive technology users. + if (!this._isScrolling) { + // Layout infos must be in topological order (parents before children). + for (let key of visibleLayoutInfos.keys()) { + let view = this._visibleViews.get(key)!; + view.parent.children.delete(view); + view.parent.children.add(view); } } - - let children = this.getChildren(null); - this.delegate.setVisibleViews(children); } - getChildren(key: Key) { - let buildTree = (parent: ReusableView, views: ReusableView[]): W[] => views.map(view => { - let children = this._viewsByParentKey.get(view.layoutInfo.key); - return this.delegate.renderWrapper( - parent, - view, - children, - (childViews) => buildTree(view, childViews) - ); - }); - - let parent = this._visibleViews.get(key)!; - return buildTree(parent, this._viewsByParentKey.get(key)); - } + /** Performs layout and updates visible views as needed. */ + render(opts: VirtualizerRenderOptions): W[] { + let mutableThis: Mutable = this; + let needsLayout = false; + let offsetChanged = false; + let sizeChanged = false; + let itemSizeChanged = false; + let needsUpdate = false; - private _applyLayoutInfo(view: ReusableView, layoutInfo: LayoutInfo) { - if (view.layoutInfo === layoutInfo) { - return false; + if (opts.collection !== this.collection) { + mutableThis.collection = opts.collection; + needsLayout = true; } - view.layoutInfo = layoutInfo; - return true; - } - - private _applyLayoutInfos() { - let updated = false; - - // Apply layout infos to visible views - for (let view of this._visibleViews.values()) { - let cur = view.layoutInfo; - if (cur?.key != null) { - let layoutInfo = this.layout.getLayoutInfo(cur.key); - if (this._applyLayoutInfo(view, layoutInfo)) { - updated = true; - } + if (opts.layout !== this.layout) { + if (this.layout) { + this.layout.virtualizer = null; } - } - - // Apply final layout infos for views that will be removed - if (this._transaction) { - for (let view of this._transaction.toRemove.values()) { - let cur = view.layoutInfo; - if (cur?.key != null) { - let layoutInfo = this.layout.getLayoutInfo(cur.key); - if (this._applyLayoutInfo(view, layoutInfo)) { - updated = true; - } - } - } - - for (let view of this._transaction.removed.values()) { - let cur = view.layoutInfo; - let layoutInfo = this._transaction.finalLayoutInfo.get(cur.key) || cur; - layoutInfo = this.layout.getFinalLayoutInfo(layoutInfo.copy()); - if (this._applyLayoutInfo(view, layoutInfo)) { - updated = true; - } - } - } - if (updated) { - this._flushVisibleViews(); + opts.layout.virtualizer = this; + mutableThis.layout = opts.layout; + needsLayout = true; } - } - private _hasLayoutUpdates() { - if (!this._transaction) { - return false; + if (opts.persistedKeys && !isSetEqual(opts.persistedKeys, this.persistedKeys)) { + mutableThis.persistedKeys = opts.persistedKeys; + needsUpdate = true; } - for (let view of this._visibleViews.values()) { - let cur = view.layoutInfo; - if (!cur) { - return true; - } - - let layoutInfo = this.layout.getLayoutInfo(cur.key); - if ( - // Uses equals rather than pointEquals so that width/height changes are taken into account - !cur.rect.equals(layoutInfo.rect) || - cur.opacity !== layoutInfo.opacity || - cur.transform !== layoutInfo.transform - ) { - return true; + if (!this.visibleRect.equals(opts.visibleRect)) { + this._overscanManager.setVisibleRect(opts.visibleRect); + let shouldInvalidate = this.layout.shouldInvalidate(opts.visibleRect, this.visibleRect); + + if (shouldInvalidate) { + offsetChanged = !opts.visibleRect.pointEquals(this.visibleRect); + sizeChanged = !opts.visibleRect.sizeEquals(this.visibleRect); + needsLayout = true; + } else { + needsUpdate = true; } - } - - return false; - } - - reuseView(view: ReusableView) { - view.prepareForReuse(); - this._reusableViews[view.viewType].push(view); - } - - removeViews(toRemove: Set>) { - for (let view of toRemove) { - this._children.delete(view); - } - } - - updateItemSize(key: Key, size: Size) { - // TODO: we should be able to invalidate a single index path - // @ts-ignore - if (!this.layout.updateItemSize) { - return; - } - // If the scroll position is currently animating, add the update - // to a queue to be processed after the animation is complete. - if (this._scrollAnimation) { - this._sizeUpdateQueue.set(key, size); - return; - } - - // @ts-ignore - let changed = this.layout.updateItemSize(key, size); - if (changed) { - this.relayout(); + mutableThis.visibleRect = opts.visibleRect; } - } - - startScrolling() { - this._isScrolling = true; - } - - endScrolling() { - this._isScrolling = false; - this._correctItemOrder(); - this._flushVisibleViews(); - } - - private _resetAnimatedContentOffset() { - // Reset the animated content offset of subviews. See comment in relayoutNow for details. - if (!this._animatedContentOffset.isOrigin()) { - this._animatedContentOffset = new Point(0, 0); - this._applyLayoutInfos(); - } - } - - /** - * Scrolls the item with the given key into view, optionally with an animation. - * @param key The key of the item to scroll into view. - * @param duration The duration of the scroll animation. - */ - scrollToItem(key: Key, options?: ScrollToItemOptions) { - // key can be 0, so check if null or undefined - if (key == null) { - return; - } - - let layoutInfo = this.layout.getLayoutInfo(key); - if (!layoutInfo) { - return; - } - - let { - duration = 300, - shouldScrollX = true, - shouldScrollY = true, - offsetX = 0, - offsetY = 0 - } = options; - let x = this.visibleRect.x; - let y = this.visibleRect.y; - let minX = layoutInfo.rect.x - offsetX; - let minY = layoutInfo.rect.y - offsetY; - let maxX = x + this.visibleRect.width; - let maxY = y + this.visibleRect.height; - - if (shouldScrollX) { - if (minX <= x || maxX === 0) { - x = minX; - } else if (layoutInfo.rect.maxX > maxX) { - x += layoutInfo.rect.maxX - maxX; + if (opts.invalidationContext !== this._invalidationContext) { + if (opts.invalidationContext) { + sizeChanged ||= opts.invalidationContext.sizeChanged || false; + offsetChanged ||= opts.invalidationContext.offsetChanged || false; + itemSizeChanged ||= opts.invalidationContext.itemSizeChanged || false; + needsLayout ||= itemSizeChanged || sizeChanged || offsetChanged; + needsLayout ||= opts.invalidationContext.layoutOptions !== this._invalidationContext.layoutOptions; } + this._invalidationContext = opts.invalidationContext; } - if (shouldScrollY) { - if (minY <= y || maxY === 0) { - y = minY; - } else if (layoutInfo.rect.maxY > maxY) { - y += layoutInfo.rect.maxY - maxY; + if (opts.isScrolling !== this._isScrolling) { + this._isScrolling = opts.isScrolling; + if (!opts.isScrolling) { + // Update to fix the DOM order after scrolling. + needsUpdate = true; } } - return this.scrollTo(new Point(x, y), duration); - } - - /** - * Performs an animated scroll to the given offset. - * @param offset - The offset to scroll to. - * @param duration The duration of the animation. - * @returns A promise that resolves when the animation is complete. - */ - scrollTo(offset: Point, duration: number = 300): Promise { - // Cancel the current scroll animation - if (this._scrollAnimation) { - this._scrollAnimation.cancel(); - this._scrollAnimation = null; - } - - // Set the content offset synchronously if the duration is zero - if (duration <= 0 || this.visibleRect.pointEquals(offset)) { - this._setContentOffset(offset); - return Promise.resolve(); + if (needsLayout) { + this.relayout({ + offsetChanged, + sizeChanged, + itemSizeChanged, + layoutOptions: this._invalidationContext.layoutOptions + }); + } else if (needsUpdate) { + this.updateSubviews(); } - this.startScrolling(); - - this._scrollAnimation = tween(this.visibleRect, offset, duration, easeOut, offset => {this._setContentOffset(offset);}); - this._scrollAnimation.then(() => { - this._scrollAnimation = null; - - // Process view size updates that occurred during the animation. - // Only views that are still visible will be actually updated. - for (let [key, size] of this._sizeUpdateQueue) { - this.updateItemSize(key, size); - } - - this._sizeUpdateQueue.clear(); - this.relayout(); - this._processTransactionQueue(); - this.endScrolling(); - }); - - return this._scrollAnimation; + return this.getChildren(null); } - private _runTransaction(action: () => void, animated?: boolean) { - this._startTransaction(); - if (this._nextTransaction) { - this._nextTransaction.actions.push(action); - } - this._endTransaction(animated); + afterRender() { + this._overscanManager.collectMetrics(); } - private _startTransaction() { - if (!this._nextTransaction) { - this._nextTransaction = new Transaction; - } + getChildren(key: Key | null): W[] { + let parent = key == null ? this._rootView : this._visibleViews.get(key); + let renderChildren = (parent: ReusableView, views: ReusableView[]) => views.map(view => { + return this.delegate.renderWrapper( + parent, + view, + view.children ? Array.from(view.children) : [], + childViews => renderChildren(view, childViews) + ); + }); - this._nextTransaction.level++; + return renderChildren(parent, Array.from(parent.children)); } - private _endTransaction(animated?: boolean) { - if (!this._nextTransaction) { - return false; - } - - // Save whether the transaction should be animated. - if (animated != null) { - this._nextTransaction.animated = animated; - } - - // If we haven't reached level 0, we are still in a - // nested transaction. Wait for the parent to end. - if (--this._nextTransaction.level > 0) { - return false; - } - - // Do nothing for empty transactions - if (this._nextTransaction.actions.length === 0) { - this._nextTransaction = null; - return false; - } - - // Default animations to true - if (this._nextTransaction.animated == null) { - this._nextTransaction.animated = true; - } - - // Enqueue the transaction - this._transactionQueue.push(this._nextTransaction); - this._nextTransaction = null; - - return true; + invalidate(context: InvalidationContext) { + this.delegate.invalidate(context); } - private _processTransactionQueue() { - // If the current transaction is animating, wait until the end - // to process the next transaction. - if (this._transaction || this._scrollAnimation) { + updateItemSize(key: Key, size: Size) { + if (!this.layout.updateItemSize) { return; } - let next = this._transactionQueue.shift(); - if (next) { - this._performTransaction(next); - } - } - - private _getContentRect(): Rect { - return new Rect(0, 0, this.contentSize.width, this.contentSize.height); - } - - private _performTransaction(transaction: Transaction) { - this._transaction = transaction; - - this.relayoutNow({ - transaction: transaction, - animated: transaction.animated, - - beforeLayout: () => { - // Get the initial layout infos for all views before the updates - // so we can figure out which views to add and remove. - if (transaction.animated) { - transaction.initialMap = this._getLayoutInfoMap(this._getContentRect(), true); - } - - // Apply the actions that occurred during this transaction - for (let action of transaction.actions) { - action(); - } - }, - - afterLayout: () => { - // Get the final layout infos after the updates - if (transaction.animated) { - transaction.finalMap = this._getLayoutInfoMap(this._getContentRect()); - this._setupTransactionAnimations(transaction); - } else { - this._transaction = null; - } - }, - - afterAnimation: () => { - // Remove and reuse views when animations are done - if (transaction.toRemove.size > 0 || transaction.removed.size > 0) { - for (let view of concatIterators(transaction.toRemove.values(), transaction.removed.values())) { - this._children.delete(view); - this.reuseView(view); - } - } - - this._transaction = null; - - // Ensure DOM order is correct for accessibility after animations are complete - this._correctItemOrder(); - this._flushVisibleViews(); - - this._processTransactionQueue(); - } - }); - } - - private _setupTransactionAnimations(transaction: Transaction) { - let {initialMap, finalMap} = transaction; - - // Store initial and final layout infos for animations - for (let [key, layoutInfo] of initialMap) { - if (finalMap.has(key)) { - // Store the initial layout info for use during animations. - transaction.initialLayoutInfo.set(key, layoutInfo); - } else { - // This view was removed. Store the layout info for use - // in Layout#getFinalLayoutInfo during animations. - transaction.finalLayoutInfo.set(layoutInfo.key, layoutInfo); - } - } - - // Get initial layout infos for views that were added - for (let [key, layoutInfo] of finalMap) { - if (!initialMap.has(key)) { - let initialLayoutInfo = this.layout.getInitialLayoutInfo(layoutInfo.copy()); - transaction.initialLayoutInfo.set(key, initialLayoutInfo); - } - } - - // Figure out which views were removed. - for (let [key, view] of this._visibleViews) { - // If an item has a width of 0, there is no need to remove it from the _visibleViews. - // Removing an item with width of 0 can cause a loop where the item gets added, removed, - // added, removed... etc in a loop. - if (!finalMap.has(key) && view.layoutInfo.rect.width > 0) { - transaction.removed.set(key, view); - this._visibleViews.delete(key); - - // In case something weird happened, where we have a view but no - // initial layout info, use the one attached to the view. - if (view.layoutInfo) { - if (!transaction.finalLayoutInfo.has(view.layoutInfo.key)) { - transaction.finalLayoutInfo.set(view.layoutInfo.key, view.layoutInfo); - } - } - } + let changed = this.layout.updateItemSize(key, size); + if (changed) { + this.invalidate({ + itemSizeChanged: true + }); } } } diff --git a/packages/@react-stately/virtualizer/src/tween.ts b/packages/@react-stately/virtualizer/src/tween.ts deleted file mode 100644 index 08ca7afff2f..00000000000 --- a/packages/@react-stately/virtualizer/src/tween.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * 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 {Point} from './Point'; - -// use high res timer if available -let perf = typeof window !== 'undefined' ? window.performance : null; -// @ts-ignore -let perfNow = perf && (perf.now || perf.webkitNow || perf.msNow || perf.mozNow); -let getTime = perfNow ? perfNow.bind(perf) : function () { - return Date.now ? Date.now() : new Date().getTime(); -}; - -let fixTs: boolean; - -export interface CancelablePromise extends Promise { - cancel(): void -} - -export function tween(begin, end, duration, ease, fn): CancelablePromise { - let canceled = false; - let raf_id: number; - - let promise = new Promise(resolve => { - let start = getTime(); - let diffX = end.x - begin.x; - let diffY = end.y - begin.y; - - raf_id = requestAnimationFrame(function run(t) { - // if we're using a high res timer, make sure timestamp is not the old epoch-based value. - // http://updates.html5rocks.com/2012/05/requestAnimationFrame-API-now-with-sub-millisecond-precision - if (fixTs == null) { - fixTs = t > 1e12 !== getTime() > 1e12; - } - - if (fixTs) { - t = getTime(); - } - - // check if we're done - let delta = t - start; - if (delta > duration) { - fn(end); - resolve(); - } else { - // call frame callback after computing eased time and get the next frame - let proceed = fn(new Point( - begin.x + diffX * ease(delta / duration), - begin.y + diffY * ease(delta / duration) - )); - - if (proceed !== false && !canceled) { - raf_id = requestAnimationFrame(run); - } - } - }); - }) as CancelablePromise; - - promise.cancel = function () { - canceled = true; - cancelAnimationFrame(raf_id); - }; - - return promise; -} - -// easing functions -export function linearEasing(t) { - return t; -} - -export function easeOut(t) { - return Math.sin(t * Math.PI / 2); -} diff --git a/packages/@react-stately/virtualizer/src/types.ts b/packages/@react-stately/virtualizer/src/types.ts index 224c4199ce8..0fc290019ee 100644 --- a/packages/@react-stately/virtualizer/src/types.ts +++ b/packages/@react-stately/virtualizer/src/types.ts @@ -12,28 +12,19 @@ import {Collection, Key} from '@react-types/shared'; import {Layout} from './Layout'; -import {LayoutInfo} from './LayoutInfo'; -import {Rect, RectCorner} from './Rect'; +import {Rect} from './Rect'; import {ReusableView} from './ReusableView'; -import {Size} from './Size'; -import {Transaction} from './Transaction'; -export interface InvalidationContext { +export interface InvalidationContext { contentChanged?: boolean, offsetChanged?: boolean, sizeChanged?: boolean, - animated?: boolean, - beforeLayout?(): void, - afterLayout?(): void, - afterAnimation?(): void, - transaction?: Transaction + itemSizeChanged?: boolean, + layoutOptions?: O } export interface VirtualizerDelegate { - setVisibleViews(views: W[]): void, - setContentSize(size: Size): void, setVisibleRect(rect: Rect): void, - getType?(content: T): string, renderView(type: string, content: T): V, renderWrapper( parent: ReusableView | null, @@ -41,32 +32,19 @@ export interface VirtualizerDelegate { children: ReusableView[], renderChildren: (views: ReusableView[]) => W[] ): W, - beginAnimations(): void, - endAnimations(): void, - getScrollAnchor?(rect: Rect): Key + invalidate(ctx: InvalidationContext): void } -export interface ScrollAnchor { - key: Key, - layoutInfo: LayoutInfo, - corner: RectCorner, - offset: number +export interface VirtualizerRenderOptions { + layout: Layout, + collection: Collection, + persistedKeys?: Set, + visibleRect: Rect, + invalidationContext: InvalidationContext, + isScrolling: boolean, + layoutOptions?: O } -export interface ScrollToItemOptions { - duration?: number, - shouldScrollX?: boolean, - shouldScrollY?: boolean, - offsetX?: number, - offsetY?: number -} - -export interface VirtualizerOptions { - collection?: Collection, - layout?: Layout, - delegate?: VirtualizerDelegate, - transitionDuration?: number, - anchorScrollPosition?: boolean, - anchorScrollPositionAtTop?: boolean, - shouldOverscan?: boolean -} +export type Mutable = { + -readonly[P in keyof T]: T[P] +}; diff --git a/packages/@react-stately/virtualizer/src/useVirtualizerState.ts b/packages/@react-stately/virtualizer/src/useVirtualizerState.ts index 57f1cf493ae..226c85569a6 100644 --- a/packages/@react-stately/virtualizer/src/useVirtualizerState.ts +++ b/packages/@react-stately/virtualizer/src/useVirtualizerState.ts @@ -11,15 +11,16 @@ */ import {Collection, Key} from '@react-types/shared'; +import {InvalidationContext} from './types'; import {Layout} from './Layout'; import {Rect} from './Rect'; import {ReusableView} from './ReusableView'; import {Size} from './Size'; -import {useCallback, useEffect, useMemo, useState} from 'react'; +import {useCallback, useMemo, useRef, useState} from 'react'; import {useLayoutEffect} from '@react-aria/utils'; import {Virtualizer} from './Virtualizer'; -interface VirtualizerProps { +interface VirtualizerProps { renderView(type: string, content: T): V, renderWrapper( parent: ReusableView | null, @@ -30,74 +31,79 @@ interface VirtualizerProps { layout: Layout, collection: Collection, onVisibleRectChange(rect: Rect): void, - getScrollAnchor?(rect: Rect): Key, - transitionDuration?: number + persistedKeys?: Set, + layoutOptions?: O } export interface VirtualizerState { visibleViews: W[], setVisibleRect: (rect: Rect) => void, contentSize: Size, - isAnimating: boolean, virtualizer: Virtualizer, isScrolling: boolean, startScrolling: () => void, endScrolling: () => void } -export function useVirtualizerState(opts: VirtualizerProps): VirtualizerState { - let [visibleViews, setVisibleViews] = useState([]); - let [contentSize, setContentSize] = useState(new Size()); - let [isAnimating, setAnimating] = useState(false); +export function useVirtualizerState(opts: VirtualizerProps): VirtualizerState { + let [visibleRect, setVisibleRect] = useState(new Rect(0, 0, 0, 0)); let [isScrolling, setScrolling] = useState(false); - let virtualizer = useMemo(() => new Virtualizer(), []); - - virtualizer.delegate = { - setVisibleViews, + let [invalidationContext, setInvalidationContext] = useState({}); + let visibleRectChanged = useRef(false); + let [virtualizer] = useState(() => new Virtualizer({ setVisibleRect(rect) { - virtualizer.visibleRect = rect; - opts.onVisibleRectChange(rect); + setVisibleRect(rect); + visibleRectChanged.current = true; }, - setContentSize, + // TODO: should changing these invalidate the entire cache? renderView: opts.renderView, renderWrapper: opts.renderWrapper, - beginAnimations: () => setAnimating(true), - endAnimations: () => setAnimating(false), - getScrollAnchor: opts.getScrollAnchor - }; + invalidate: setInvalidationContext + })); + + // onVisibleRectChange must be called from an effect, not during render. + useLayoutEffect(() => { + if (visibleRectChanged.current) { + visibleRectChanged.current = false; + opts.onVisibleRectChange(visibleRect); + } + }); + + let mergedInvalidationContext = useMemo(() => { + if (opts.layoutOptions != null) { + return {...invalidationContext, layoutOptions: opts.layoutOptions}; + } + return invalidationContext; + }, [invalidationContext, opts.layoutOptions]); - virtualizer.layout = opts.layout; - virtualizer.collection = opts.collection; - virtualizer.transitionDuration = opts.transitionDuration; + let visibleViews = virtualizer.render({ + layout: opts.layout, + collection: opts.collection, + persistedKeys: opts.persistedKeys, + layoutOptions: opts.layoutOptions, + visibleRect, + invalidationContext: mergedInvalidationContext, + isScrolling + }); + + let contentSize = virtualizer.contentSize; useLayoutEffect(() => { virtualizer.afterRender(); }); - // eslint-disable-next-line arrow-body-style - useEffect(() => { - return () => virtualizer.willUnmount(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - let setVisibleRect = useCallback((rect) => { - virtualizer.visibleRect = rect; - }, [virtualizer]); let startScrolling = useCallback(() => { - virtualizer.startScrolling(); setScrolling(true); - }, [virtualizer]); + }, []); let endScrolling = useCallback(() => { - virtualizer.endScrolling(); setScrolling(false); - }, [virtualizer]); + }, []); let state = useMemo(() => ({ virtualizer, visibleViews, setVisibleRect, contentSize, - isAnimating, isScrolling, startScrolling, endScrolling @@ -106,7 +112,6 @@ export function useVirtualizerState(opts: VirtualizerPro visibleViews, setVisibleRect, contentSize, - isAnimating, isScrolling, startScrolling, endScrolling diff --git a/packages/@react-stately/virtualizer/src/utils.ts b/packages/@react-stately/virtualizer/src/utils.ts index fc79a9689e3..39d54398c89 100644 --- a/packages/@react-stately/virtualizer/src/utils.ts +++ b/packages/@react-stately/virtualizer/src/utils.ts @@ -10,58 +10,6 @@ * governing permissions and limitations under the License. */ -export function keyDiff(a: Map, b: Map): Set { - let res = new Set(); - - for (let key of a.keys()) { - if (!b.has(key)) { - res.add(key); - } - } - - return res; -} - -/** - * Returns the key difference between two maps. Returns a set of - * keys to add to and remove from a to make it equal to b. - * @private - */ -export function difference(a: Map, b: Map) { - let toRemove = keyDiff(a, b); - let toAdd = keyDiff(b, a); - let toUpdate = new Set; - for (let key of a.keys()) { - if (b.has(key)) { - toUpdate.add(key); - } - } - return {toRemove, toAdd, toUpdate}; -} - -/** - * Returns an iterator that yields the items in all of the given iterators. - * @private - */ -export function* concatIterators(...iterators: Iterable[]) { - for (let iterator of iterators) { - yield* iterator; - } -} - -/** - * Inverts the keys and values of an object. - * @private - */ -export function invert(object) { - let res = {}; - for (let key in object) { - res[object[key]] = key; - } - - return res; -} - /** Returns whether two sets are equal. */ export function isSetEqual(a: Set, b: Set): boolean { if (a === b) { diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index b73ce12a2c3..ba4281f66ab 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -287,8 +287,7 @@ const VirtualizedCollectionRenderer: CollectionRenderer = (collection, parent) = scrollDirection="vertical" layout={layout} style={{height: 'inherit'}} - collection={collection} - shouldUseVirtualFocus> + collection={collection}> {(type, item) => { switch (type) { case 'placeholder': From 9affd262041afc552ae817be3568021d1af9168c Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 28 May 2024 21:27:31 -0700 Subject: [PATCH 2/2] Additional performance improvements --- package.json | 1 + packages/@react-aria/utils/src/platform.ts | 50 +++-- .../card/test/CardView.test.js | 2 +- .../listbox/test/ListBox.test.js | 2 +- .../table/src/TableViewBase.tsx | 4 +- .../table/stories/Performance.tsx | 204 ++++++++++++++++++ .../table/stories/Table.stories.tsx | 2 + .../@react-spectrum/table/test/Table.test.js | 2 +- .../@react-stately/layout/src/ListLayout.ts | 8 + .../@react-stately/layout/src/TableLayout.ts | 10 +- .../virtualizer/src/OverscanManager.ts | 57 +---- .../virtualizer/src/ReusableView.ts | 20 +- .../virtualizer/src/Virtualizer.ts | 9 +- .../virtualizer/src/useVirtualizerState.ts | 4 - yarn.lock | 5 + 15 files changed, 295 insertions(+), 85 deletions(-) create mode 100644 packages/@react-spectrum/table/stories/Performance.tsx diff --git a/package.json b/package.json index aa8dcaf73b2..91af990185f 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@babel/preset-react": "^7.24.1", "@babel/preset-typescript": "^7.24.1", "@babel/register": "^7.23.7", + "@faker-js/faker": "^8.4.1", "@octokit/rest": "*", "@parcel/bundler-library": "2.11.1-dev.3224", "@parcel/optimizer-data-url": "2.0.0-dev.1601", diff --git a/packages/@react-aria/utils/src/platform.ts b/packages/@react-aria/utils/src/platform.ts index a490831000a..9550b8b63dd 100644 --- a/packages/@react-aria/utils/src/platform.ts +++ b/packages/@react-aria/utils/src/platform.ts @@ -26,40 +26,54 @@ function testPlatform(re: RegExp) { : false; } -export function isMac() { - return testPlatform(/^Mac/i); +function cached(fn: () => boolean) { + if (process.env.NODE_ENV === 'test') { + return fn; + } + + let res: boolean | null = null; + return () => { + if (res == null) { + res = fn(); + } + return res; + }; } -export function isIPhone() { +export const isMac = cached(function () { + return testPlatform(/^Mac/i); +}); + +export const isIPhone = cached(function () { return testPlatform(/^iPhone/i); -} +}); -export function isIPad() { +export const isIPad = cached(function () { return testPlatform(/^iPad/i) || // iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support. (isMac() && navigator.maxTouchPoints > 1); -} +}); -export function isIOS() { +export const isIOS = cached(function () { return isIPhone() || isIPad(); -} +}); -export function isAppleDevice() { +export const isAppleDevice = cached(function () { return isMac() || isIOS(); -} +}); -export function isWebKit() { +export const isWebKit = cached(function () { return testUserAgent(/AppleWebKit/i) && !isChrome(); -} +}); -export function isChrome() { +export const isChrome = cached(function () { return testUserAgent(/Chrome/i); -} +}); -export function isAndroid() { +export const isAndroid = cached(function () { return testUserAgent(/Android/i); -} +}); -export function isFirefox() { +export const isFirefox = cached(function () { return testUserAgent(/Firefox/i); -} +}); diff --git a/packages/@react-spectrum/card/test/CardView.test.js b/packages/@react-spectrum/card/test/CardView.test.js index 8b25796f8a3..689d5e888a7 100644 --- a/packages/@react-spectrum/card/test/CardView.test.js +++ b/packages/@react-spectrum/card/test/CardView.test.js @@ -1260,7 +1260,7 @@ describe('CardView', function () { let cards = tree.getAllByRole('gridcell'); expect(cards).toBeTruthy(); let grid = tree.getByRole('grid'); - await user.click(cards[cards.length - 1]); + await user.click(cards[4]); act(() => { jest.runAllTimers(); }); diff --git a/packages/@react-spectrum/listbox/test/ListBox.test.js b/packages/@react-spectrum/listbox/test/ListBox.test.js index 5dab2c29ebb..3e07bf2dffb 100644 --- a/packages/@react-spectrum/listbox/test/ListBox.test.js +++ b/packages/@react-spectrum/listbox/test/ListBox.test.js @@ -850,7 +850,7 @@ describe('ListBox', function () { let listbox = getByRole('listbox'); let options = within(listbox).getAllByRole('option'); - expect(options.length).toBe(5); // each row is 48px tall, listbox is 200px. 5 rows fit. + expect(options.length).toBe(6); // each row is 48px tall, listbox is 200px. 5 rows fit. + 1/3 overscan listbox.scrollTop = 250; fireEvent.scroll(listbox); diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 7deacc5de7e..fd0585da8ee 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -1375,7 +1375,7 @@ function TableCellWrapper({layoutInfo, virtualizer, parent, children}) { virtualizer={virtualizer} parent={parent?.layoutInfo} className={ - classNames( + useMemo(() => classNames( styles, 'spectrum-Table-cellWrapper', classNames( @@ -1385,7 +1385,7 @@ function TableCellWrapper({layoutInfo, virtualizer, parent, children}) { 'react-spectrum-Table-cellWrapper--dropTarget': isDropTarget || isRootDroptarget } ) - ) + ), [layoutInfo.estimatedSize, isDropTarget, isRootDroptarget]) }> {children} diff --git a/packages/@react-spectrum/table/stories/Performance.tsx b/packages/@react-spectrum/table/stories/Performance.tsx new file mode 100644 index 00000000000..8d49f629c70 --- /dev/null +++ b/packages/@react-spectrum/table/stories/Performance.tsx @@ -0,0 +1,204 @@ +import {ActionButton} from '@react-spectrum/button'; +import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../src'; +import {Checkbox} from '@react-spectrum/checkbox'; +import {faker} from '@faker-js/faker'; +import {Icon} from '@react-spectrum/icon'; +import {Item, TagGroup} from '@react-spectrum/tag'; +import {Link} from '@react-spectrum/link'; +import {ProgressBar} from '@react-spectrum/progress'; +import React from 'react'; +import {SpectrumStatusLightProps} from '@react-types/statuslight'; +import {Text} from '@react-spectrum/text'; + +export const DATA_SIZE = 10000; +export const COLUMN_SIZE = 50; +export const TABLE_HEIGHT = 600; + +const columnDefinitions = [ + {name: 'Airline', type: 'TEXT'}, + {name: 'Destinations', type: 'TAGS'}, + {name: 'Scheduled At', type: 'DATETIME'}, + {name: 'Status', type: 'STATUS'}, + {name: 'Rating', type: 'RATING'}, + {name: 'Progress', type: 'PROGRESS'}, + {name: 'URL', type: 'URL'}, + {name: 'Overbooked', type: 'CHECKBOX'}, + {name: 'Take Action', type: 'BUTTON'} +] as const; + +export const flightStatuses = { + ON_SCHEDULE: 'On Schedule', + DELAYED: 'Delayed', + CANCELLED: 'Cancelled', + BOARDING: 'Boarding' +}; + +export const flightStatusVariant: Record< + keyof typeof flightStatuses, + SpectrumStatusLightProps['variant'] +> = { + ON_SCHEDULE: 'positive', + DELAYED: 'notice', + CANCELLED: 'negative', + BOARDING: 'info' +}; + +const getData = (rowNumber: number, columnNumber: number) => { + const columns = columnDefinitions.concat([...Array(columnNumber - columnDefinitions.length)].map(() => + faker.helpers.arrayElement(columnDefinitions) + )); + return { + columns, + data: [...Array(rowNumber)].map(() => { + return columns.map((column) => { + switch (column.type) { + case 'TEXT': + return {rawValue: faker.airline.airline().name}; + case 'URL': { + const url = faker.internet.url(); + return {rawValue: url, url}; + } + case 'TAGS': { + const airports = faker.helpers + .multiple(faker.airline.airport, { + count: {min: 1, max: 7} + }) + .map((airport) => airport.iataCode); + return {rawValue: airports.join(', '), data: airports}; + } + case 'STATUS': { + const [flightKey, flightStatus] = + faker.helpers.objectEntry(flightStatuses); + return { + rawValue: flightStatus, + variant: flightStatusVariant[flightKey] + }; + } + case 'DATETIME': + return {rawValue: faker.date.future()}; + case 'RATING': { + const rating = faker.number.int({min: 0, max: 5}); + return {rawValue: rating, data: rating}; + } + case 'PROGRESS': { + const progress = faker.number.int({min: 0, max: 100}); + return {rawValue: progress, data: progress}; + } + case 'CHECKBOX': + return {rawValue: faker.datatype.boolean()}; + case 'BUTTON': + return {rawValue: 'View Details'}; + } + }); + }) + }; +}; + +export function Performance() { + const {data, columns} = getData(DATA_SIZE, COLUMN_SIZE); + return ( + + + {columns.map((col, i) => ( + {col.name} + ))} + + + {data.map((row, rowId) => ( + + {row.map((cell, colIndex) => { + const cellId = `row${rowId}-col${colIndex}`; + switch (columns[colIndex].type) { + case 'TEXT': + return {cell.rawValue as string}; + case 'DATETIME': + return ( + {cell.rawValue.toLocaleString()} + ); + case 'STATUS': + return ( + + + + + {cell.rawValue as string} + + + + ); + case 'URL': + return ( + + {cell.rawValue as string} + + ); + case 'BUTTON': + return ( + + {cell.rawValue as string} + + ); + case 'CHECKBOX': + return ( + + + + ); + case 'TAGS': + return ( + + + {(cell.data as string[]).map((tag, tagId) => ( + + {tag} + + ))} + + + ); + case 'RATING': + return ( + + {[...Array(5)].map((_, rate) => ( + (data as any).rate} /> + ))} + + ); + case 'PROGRESS': + return ( + + + + ); + default: + return ; + } + })} + + ))} + + + ); +} + +const CircleIcon = (props) => ( + + + + + +); + +const StarIcon = (props) => { + return ( + + + + + + ); +}; diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index 1702b2c109a..cdd34f603b4 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -2082,3 +2082,5 @@ function LoadingTable() { ); } + +export {Performance} from './Performance'; diff --git a/packages/@react-spectrum/table/test/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js index 290e2390900..43c45f4c8ac 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -4193,7 +4193,7 @@ export let tableTests = () => { let scrollView = body.parentNode.parentNode; let rows = within(body).getAllByRole('row'); - expect(rows).toHaveLength(25); // each row is 41px tall. table is 1000px tall. 25 rows fit. + expect(rows).toHaveLength(34); // each row is 41px tall. table is 1000px tall. 25 rows fit. + 1/3 overscan scrollView.scrollTop = 250; fireEvent.scroll(scrollView); diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 1e83941d067..f362c96acd9 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -110,6 +110,14 @@ export class ListLayout extends Layout, ListLayoutProps> implements K } getVisibleLayoutInfos(rect: Rect) { + // Adjust rect to keep number of visible rows consistent. + // (only if height > 1 for getDropTargetFromPoint) + if (rect.height > 1) { + let rowHeight = (this.rowHeight ?? this.estimatedRowHeight); + rect.y = Math.floor(rect.y / rowHeight) * rowHeight; + rect.height = Math.ceil(rect.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); diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 7019f5a4667..e85f7785423 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -404,6 +404,14 @@ export class TableLayout extends ListLayout { } getVisibleLayoutInfos(rect: Rect) { + // Adjust rect to keep number of visible rows consistent. + // (only if height > 1 for getDropTargetFromPoint) + if (rect.height > 1) { + let rowHeight = (this.rowHeight ?? this.estimatedRowHeight) + 1; // +1 for border + rect.y = Math.floor(rect.y / rowHeight) * rowHeight; + rect.height = Math.ceil(rect.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. if (!this.validRect.containsRect(rect) && this.lastCollection) { @@ -522,7 +530,7 @@ export class TableLayout extends ListLayout { let mid = (low + high) >> 1; let item = items[mid]; - if ((axis === 'x' && item.layoutInfo.rect.maxX < point.x) || (axis === 'y' && item.layoutInfo.rect.maxY < point.y)) { + if ((axis === 'x' && item.layoutInfo.rect.maxX <= point.x) || (axis === 'y' && item.layoutInfo.rect.maxY <= point.y)) { low = mid + 1; } else if ((axis === 'x' && item.layoutInfo.rect.x > point.x) || (axis === 'y' && item.layoutInfo.rect.y > point.y)) { high = mid - 1; diff --git a/packages/@react-stately/virtualizer/src/OverscanManager.ts b/packages/@react-stately/virtualizer/src/OverscanManager.ts index 22ec2316cb2..8e5bf126a2a 100644 --- a/packages/@react-stately/virtualizer/src/OverscanManager.ts +++ b/packages/@react-stately/virtualizer/src/OverscanManager.ts @@ -13,30 +13,14 @@ import {Point} from './Point'; import {Rect} from './Rect'; -class RollingAverage { - private count: number = 0; - value: number = 0; - - addSample(sample: number) { - this.count++; - this.value += (sample - this.value) / this.count; - } -} - export class OverscanManager { private startTime = 0; - private averagePerf = new RollingAverage(); - private averageTime = new RollingAverage(); - private velocity = new Point(5, 5); - private overscanX = new RollingAverage(); - private overscanY = new RollingAverage(); + private velocity = new Point(0, 0); private visibleRect = new Rect(); setVisibleRect(rect: Rect) { let time = performance.now() - this.startTime; if (time < 500) { - this.averageTime.addSample(time); - if (rect.x !== this.visibleRect.x && time > 0) { this.velocity.x = (rect.x - this.visibleRect.x) / time; } @@ -50,42 +34,21 @@ export class OverscanManager { this.visibleRect = rect; } - collectMetrics() { - let time = performance.now() - this.startTime; - if (time < 500) { - this.averagePerf.addSample(time); - } - - if (this.visibleRect.height > 0) { - let o = Math.abs(this.velocity.y * (this.averageTime.value + this.averagePerf.value)); - this.overscanY.addSample(o); - } - - if (this.visibleRect.width > 0) { - let o = Math.abs(this.velocity.x * (this.averageTime.value + this.averagePerf.value)); - this.overscanX.addSample(o); - } - } - getOverscannedRect() { let overscanned = this.visibleRect.copy(); - let overscanY = Math.round(Math.min(this.visibleRect.height * 2, this.overscanY.value) / 100) * 100; - if (this.velocity.y > 0) { - overscanned.y -= overscanY * 0.2; - overscanned.height += overscanY + overscanY * 0.2; - } else { + let overscanY = this.visibleRect.height / 3; + overscanned.height += overscanY; + if (this.velocity.y < 0) { overscanned.y -= overscanY; - overscanned.height += overscanY + overscanY * 0.2; } - let overscanX = Math.round(Math.min(this.visibleRect.width * 2, this.overscanX.value) / 100) * 100; - if (this.velocity.x > 0) { - overscanned.x -= overscanX * 0.2; - overscanned.width += overscanX + overscanX * 0.2; - } else { - overscanned.x -= overscanX; - overscanned.width += overscanX + overscanX * 0.2; + if (this.velocity.x !== 0) { + let overscanX = this.visibleRect.width / 3; + overscanned.width += overscanX; + if (this.velocity.x < 0) { + overscanned.x -= overscanX; + } } return overscanned; diff --git a/packages/@react-stately/virtualizer/src/ReusableView.ts b/packages/@react-stately/virtualizer/src/ReusableView.ts index 4a817da88e3..1b7b899a3b7 100644 --- a/packages/@react-stately/virtualizer/src/ReusableView.ts +++ b/packages/@react-stately/virtualizer/src/ReusableView.ts @@ -37,14 +37,14 @@ export class ReusableView { parent: ReusableView | null; children: Set>; - reusableViews: Record[]>; + reusableViews: Map[]>; constructor(virtualizer: Virtualizer) { this.virtualizer = virtualizer; this.key = ++KEY; this.parent = null; this.children = new Set(); - this.reusableViews = {}; + this.reusableViews = new Map(); } /** @@ -57,9 +57,13 @@ export class ReusableView { } getReusableView(reuseType: string) { - let reusable = this.reusableViews[reuseType]; + // Reusable view queue should be FIFO so that DOM order remains consistent during scrolling. + // For example, cells within a row should remain in the same order even if the row changes contents. + // The cells within a row are removed from their parent in order. If the row is reused, the cells + // should be reused in the new row in the same order they were before. + let reusable = this.reusableViews.get(reuseType); let view = reusable?.length > 0 - ? reusable.pop() + ? reusable.shift() : new ReusableView(this.virtualizer); view.viewType = reuseType; @@ -69,7 +73,11 @@ export class ReusableView { reuseChild(child: ReusableView) { child.prepareForReuse(); - this.reusableViews[child.viewType] ||= []; - this.reusableViews[child.viewType].push(child); + let reusable = this.reusableViews.get(child.viewType); + if (!reusable) { + reusable = []; + this.reusableViews.set(child.viewType, reusable); + } + reusable.push(child); } } diff --git a/packages/@react-stately/virtualizer/src/Virtualizer.ts b/packages/@react-stately/virtualizer/src/Virtualizer.ts index 2b801901a60..7940dcabbdb 100644 --- a/packages/@react-stately/virtualizer/src/Virtualizer.ts +++ b/packages/@react-stately/virtualizer/src/Virtualizer.ts @@ -219,8 +219,13 @@ export class Virtualizer { } } + // The remaining views in `removed` were not reused to render new items. + // They should be removed from the DOM. We also clear the reusable view queue + // here since there's no point holding onto views that have been removed. + // Doing so hurts performance in the future when reusing elements due to FIFO order. for (let view of removed) { view.parent.children.delete(view); + view.parent.reusableViews.clear(); } // Reordering DOM nodes is costly, so we defer this until scrolling stops. @@ -313,10 +318,6 @@ export class Virtualizer { return this.getChildren(null); } - afterRender() { - this._overscanManager.collectMetrics(); - } - getChildren(key: Key | null): W[] { let parent = key == null ? this._rootView : this._visibleViews.get(key); let renderChildren = (parent: ReusableView, views: ReusableView[]) => views.map(view => { diff --git a/packages/@react-stately/virtualizer/src/useVirtualizerState.ts b/packages/@react-stately/virtualizer/src/useVirtualizerState.ts index 226c85569a6..fa913bfffbb 100644 --- a/packages/@react-stately/virtualizer/src/useVirtualizerState.ts +++ b/packages/@react-stately/virtualizer/src/useVirtualizerState.ts @@ -88,10 +88,6 @@ export function useVirtualizerState(opts: Virtu let contentSize = virtualizer.contentSize; - useLayoutEffect(() => { - virtualizer.afterRender(); - }); - let startScrolling = useCallback(() => { setScrolling(true); }, []); diff --git a/yarn.lock b/yarn.lock index 5cfb8fb3506..9b0c20be77d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1621,6 +1621,11 @@ unique-filename "^1.1.1" which "^1.3.1" +"@faker-js/faker@^8.4.1": + version "8.4.1" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-8.4.1.tgz#5d5e8aee8fce48f5e189bf730ebd1f758f491451" + integrity sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg== + "@fal-works/esbuild-plugin-global-externals@^2.1.2": version "2.1.2" resolved "https://registry.yarnpkg.com/@fal-works/esbuild-plugin-global-externals/-/esbuild-plugin-global-externals-2.1.2.tgz#c05ed35ad82df8e6ac616c68b92c2282bd083ba4"