From 2ed7cd9a4b7d6006f9cd903b94c3b6e7cb7ddf41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Thu, 30 Oct 2025 01:38:57 +0100 Subject: [PATCH 1/6] feat: generic scroll observation --- .gitignore | 1 + .../@react-aria/utils/src/getOffsetType.ts | 61 ++++ .../@react-aria/utils/src/getScrollOffset.ts | 69 ++++ packages/@react-aria/utils/src/index.ts | 6 + packages/@react-aria/utils/src/platform.ts | 8 + .../utils/src/useResizeObserver.ts | 2 +- .../utils/src/useScrollObserver.ts | 186 ++++++++++ .../@react-aria/utils/src/useScrollView.ts | 87 +++++ packages/@react-aria/virtualizer/package.json | 1 + .../virtualizer/src/ScrollView.tsx | 317 +++++------------- .../virtualizer/src/Virtualizer.tsx | 10 +- packages/@react-aria/virtualizer/src/utils.ts | 106 ++---- packages/@react-stately/utils/package.json | 1 + packages/@react-stately/utils/src/index.ts | 2 + packages/@react-stately/utils/src/layout.ts | 305 +++++++++++++++++ .../@react-stately/virtualizer/package.json | 1 + .../@react-stately/virtualizer/src/Layout.ts | 3 +- .../virtualizer/src/LayoutInfo.ts | 2 +- .../virtualizer/src/OverscanManager.ts | 3 +- .../@react-stately/virtualizer/src/Point.ts | 45 --- .../@react-stately/virtualizer/src/Rect.ts | 193 ----------- .../@react-stately/virtualizer/src/Size.ts | 43 --- .../virtualizer/src/Virtualizer.ts | 6 +- .../@react-stately/virtualizer/src/index.ts | 11 +- .../@react-stately/virtualizer/src/types.ts | 2 +- .../virtualizer/src/useVirtualizerState.ts | 3 +- .../@react-types/shared/src/collections.d.ts | 7 + 27 files changed, 855 insertions(+), 626 deletions(-) create mode 100644 packages/@react-aria/utils/src/getOffsetType.ts create mode 100644 packages/@react-aria/utils/src/getScrollOffset.ts create mode 100644 packages/@react-aria/utils/src/useScrollObserver.ts create mode 100644 packages/@react-aria/utils/src/useScrollView.ts create mode 100644 packages/@react-stately/utils/src/layout.ts delete mode 100644 packages/@react-stately/virtualizer/src/Point.ts delete mode 100644 packages/@react-stately/virtualizer/src/Rect.ts delete mode 100644 packages/@react-stately/virtualizer/src/Size.ts diff --git a/.gitignore b/.gitignore index 561bd2bb4b2..8037805eeff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store .cache .idea +.vscode .package-lock.json .parcel-cache build-storybook.log diff --git a/packages/@react-aria/utils/src/getOffsetType.ts b/packages/@react-aria/utils/src/getOffsetType.ts new file mode 100644 index 00000000000..11545a1fd1b --- /dev/null +++ b/packages/@react-aria/utils/src/getOffsetType.ts @@ -0,0 +1,61 @@ +/* + * 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. + */ + +const cache = new WeakMap>(); + +// Original licensing for the following methods can be found in the +// NOTICE file in the root directory of this source tree. +// See https://github.com/bvaughn/react-window/blob/master/lib/utils/getRTLOffsetType.ts + +// According to the spec, scrollLeft should be negative for RTL aligned elements. +// Chrome <= 85 does not seem to adhere; its scrollLeft values are positive (measured relative to the left). +// Safari's elastic bounce makes detecting this even more complicated with potential false positives. +// The safest way to check this is to intentionally set a negative offset, +// and then verify that the subsequent "scroll" event matches the negative offset. +// If it does not match, then we can assume a non-standard RTL scroll implementation. +export function getOffsetType(element: Element, recalculate: boolean = false): 'negative' | 'positive-descending' | 'positive-ascending' { + let offsetType = cache.get(element); + + if (!offsetType || recalculate) { + let {direction, flexDirection} = getComputedStyle(element); + + let axis = flexDirection.startsWith('row') ? 'scrollLeft' : 'scrollTop'; + + let container = document.createElement('div'); + container.style.width = '50px'; + container.style.height = '50px'; + container.style.display = 'flex'; + container.style.overflow = 'scroll'; + container.style.direction = direction; + container.style.flexDirection = flexDirection; + + let child = document.createElement('div'); + child.style.width = '100px'; + child.style.height = '100px'; + child.style.flexShrink = '0'; + + container.appendChild(child); + document.body.appendChild(container); + + if (container[axis] > 0) { + offsetType = 'positive-descending'; + } else { + container[axis] = 1; + offsetType = container[axis] > 0 ? 'positive-ascending' : 'negative'; + } + + cache.set(element, offsetType); + document.body.removeChild(container); + } + + return offsetType; +} diff --git a/packages/@react-aria/utils/src/getScrollOffset.ts b/packages/@react-aria/utils/src/getScrollOffset.ts new file mode 100644 index 00000000000..5216121d9a2 --- /dev/null +++ b/packages/@react-aria/utils/src/getScrollOffset.ts @@ -0,0 +1,69 @@ +/* + * 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 {getOffsetType} from './getOffsetType'; + +function getScrollOffset(element: Element, axis: 'left' | 'top', allowOverscroll = true): number { + let size = element[axis === 'left' ? 'scrollWidth' : 'scrollHeight']; + let rect = element[axis === 'left' ? 'clientWidth' : 'clientHeight']; + let offset = element[axis === 'left' ? 'scrollLeft' : 'scrollTop']; + + switch (getOffsetType(element)) { + case 'negative': + offset = Math.abs(allowOverscroll ? offset : Math.min(0, offset)); + break; + case 'positive-ascending': + offset = allowOverscroll ? offset : Math.max(0, offset); + break; + case 'positive-descending': + offset = size - rect - offset; + offset = allowOverscroll ? offset : Math.max(0, offset); + break; + } + + return allowOverscroll ? offset : Math.min(size - rect, offset); +} + +function setScrollOffset(element: Element, axis: 'left' | 'top', offset: number): void { + let scrollWidth = element[axis === 'left' ? 'scrollWidth' : 'scrollHeight']; + let clientWidth = element[axis === 'left' ? 'clientWidth' : 'clientHeight']; + + switch (getOffsetType(element)) { + case 'negative': + offset = Math.abs(offset) * -1; + break; + case 'positive-ascending': + offset = Math.abs(offset); + break; + case 'positive-descending': + offset = scrollWidth - clientWidth - Math.abs(offset); + break; + } + + element.scrollLeft = offset; +} + +export function getScrollLeft(element: Element, allowOverscroll = true): number { + return getScrollOffset(element, 'left', allowOverscroll); +} + +export function setScrollLeft(element: Element, scrollLeft: number): void { + return setScrollOffset(element, 'left', scrollLeft); +} + +export function getScrollTop(element: Element, allowOverscroll = true): number { + return getScrollOffset(element, 'top', allowOverscroll); +} + +export function setScrollTop(element: Element, scrollTop: number): void { + return setScrollOffset(element, 'top', scrollTop); +} diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index 9da3461dd5b..e26a7148a6c 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -51,5 +51,11 @@ export {CLEAR_FOCUS_EVENT, FOCUS_EVENT} from './constants'; export {isCtrlKeyPressed, willOpenKeyboard} from './keyboard'; export {useEnterAnimation, useExitAnimation} from './animation'; export {isFocusable, isTabbable} from './isFocusable'; +export {getOffsetType} from './getOffsetType'; +export {getScrollLeft, getScrollTop, setScrollLeft, setScrollTop} from './getScrollOffset'; +export {useScrollObserver} from './useScrollObserver'; +export {useScrollView} from './useScrollView'; +export type {ScrollObserverProps} from './useScrollObserver'; +export type {ScrollViewProps, ScrollViewAria} from './useScrollView'; export type {LoadMoreSentinelProps} from './useLoadMoreSentinel'; diff --git a/packages/@react-aria/utils/src/platform.ts b/packages/@react-aria/utils/src/platform.ts index 0bcea3fe990..ac8a856bc73 100644 --- a/packages/@react-aria/utils/src/platform.ts +++ b/packages/@react-aria/utils/src/platform.ts @@ -76,3 +76,11 @@ export const isAndroid: () => boolean = cached(function () { export const isFirefox: () => boolean = cached(function () { return testUserAgent(/Firefox/i); }); + +export const isReactAct: () => boolean = cached(function () { + // IS_REACT_ACT_ENVIRONMENT is used by React 18. Previous versions checked for the `jest` global. + // https://github.com/reactwg/react-18/discussions/102 + return typeof global.IS_REACT_ACT_ENVIRONMENT === 'boolean' + ? global.IS_REACT_ACT_ENVIRONMENT + : typeof jest !== 'undefined'; +}); diff --git a/packages/@react-aria/utils/src/useResizeObserver.ts b/packages/@react-aria/utils/src/useResizeObserver.ts index ab0a1d5ae3c..b38a47b5dbb 100644 --- a/packages/@react-aria/utils/src/useResizeObserver.ts +++ b/packages/@react-aria/utils/src/useResizeObserver.ts @@ -17,7 +17,7 @@ export function useResizeObserver(options: useResizeObserverO // Only call onResize from inside the effect, otherwise we'll void our assumption that // useEffectEvents are safe to pass in. const {ref, box, onResize} = options; - let onResizeEvent = useEffectEvent(onResize); + let onResizeEvent = useEffectEvent(() => onResize()); useEffect(() => { let element = ref?.current; diff --git a/packages/@react-aria/utils/src/useScrollObserver.ts b/packages/@react-aria/utils/src/useScrollObserver.ts new file mode 100644 index 00000000000..eb119db5406 --- /dev/null +++ b/packages/@react-aria/utils/src/useScrollObserver.ts @@ -0,0 +1,186 @@ +/* + * 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 {flushSync} from 'react-dom'; +import {getScrollLeft, getScrollTop} from './getScrollOffset'; +import {isReactAct} from './platform'; +import {Rect, Size} from '@react-stately/utils'; +import {RefObject, useCallback, useRef, useState} from 'react'; +import {useEffectEvent} from './useEffectEvent'; +import {useEvent} from './useEvent'; +import {useLayoutEffect} from './useLayoutEffect'; +import {useResizeObserver} from './useResizeObserver'; + +export interface ScrollObserverProps { + /** Handler that is called when the scroll port changes. */ + onScrollPortChange?: (rect: Rect) => void, + /** Handler that is called when the visible rect changes. */ + onVisibleRectChange?: (rect: Rect) => void, + /** Handler that is called when the content size changes. */ + onContentSizeChange?: (size: Size) => void, + /** Handler that is called when the scroll snap target changes. */ + onScrollSnapChange?: (e: Event) => void, + /** Handler that is called when a scroll snap change is pending. */ + onScrollSnapChanging?: (e: Event) => void, + /** Handler that is called when the element is scrolled. */ + onScroll?: (e: Event) => void, + /** Handler that is called when scrolling starts. */ + onScrollStart?: () => void, + /** Handler that is called when scrolling ends. */ + onScrollEnd?: () => void, + /** + * Whether the visible rect can overscroll the content size. + * @default false + */ + allowOverscroll?: boolean, + /** + * The box model to use for the size observer. + * @default 'content-box' + */ + box?: ResizeObserverBoxOptions +} + +export function useScrollObserver(props: ScrollObserverProps, ref: RefObject): { updateSize: () => void } { + let {box = 'content-box', allowOverscroll = false, onScrollEnd, onScrollStart, onScroll, onVisibleRectChange, onScrollPortChange, onContentSizeChange} = props; + + let isUpdating = useRef(false); + let [update, setUpdate] = useState({}); + + let state = useRef({ + y: 0, + x: 0, + width: 0, + height: 0, + scrollWidth: 0, + scrollHeight: 0, + scrollEndTime: 0, + scrollPaddingTop: 0, + scrollPaddingBottom: 0, + scrollPaddingLeft: 0, + scrollPaddingRight: 0, + scrollTimeout: undefined as ReturnType | undefined, + isScrolling: false + }).current; + + let handleSizeChange = useCallback(() => { + onContentSizeChange?.(new Size(state.scrollWidth, state.scrollHeight)); + }, [state, onContentSizeChange]); + + let handleRectChange = useCallback(() => { + onVisibleRectChange?.(new Rect(state.x, state.y, state.width, state.height)); + + let x = state.x + state.scrollPaddingLeft; + let y = state.y + state.scrollPaddingTop; + let width = state.width - state.scrollPaddingLeft - state.scrollPaddingRight; + let height = state.height - state.scrollPaddingTop - state.scrollPaddingBottom; + + onScrollPortChange?.(new Rect(x, y, width, height)); + }, [state, onVisibleRectChange, onScrollPortChange]); + + let handleScrollEnd = useCallback(() => flushSync(() => { + state.isScrolling = false; + clearTimeout(state.scrollTimeout); + state.scrollTimeout = undefined; + onScrollEnd?.(); + }), [state, onScrollEnd]); + + let handleScroll = useCallback((e: Event) => flushSync(() => { + if (!e.target || e.target !== e.currentTarget) { return; } + + if (!state.isScrolling) { + state.isScrolling = true; + onScrollStart?.(); + } + + // Pass ref.current to allow for mock proxy in tests. + state.y = getScrollTop(ref.current!, allowOverscroll); + state.x = getScrollLeft(ref.current!, allowOverscroll); + + onScroll?.(e); + handleRectChange(); + + // So we don't constantly call clearTimeout and setTimeout, + // keep track of the current timeout time and only reschedule + // the timer when it is getting close. + if (state.scrollEndTime <= e.timeStamp + 50) { + state.scrollEndTime = e.timeStamp + 300; + clearTimeout(state.scrollTimeout); + state.scrollTimeout = setTimeout(handleScrollEnd, 300); + } + }), [ref, state, allowOverscroll, handleRectChange, handleScrollEnd, onScroll, onScrollStart]); + + let updateSize = useCallback((flush = flushSync) => { + if (!ref.current || isUpdating.current) { return; } + + isUpdating.current = true; + + let target = ref.current; + let style = getComputedStyle(target); + + state.scrollPaddingTop = parseFloat(style.scrollPaddingTop) || 0; + state.scrollPaddingBottom = parseFloat(style.scrollPaddingBottom) || 0; + state.scrollPaddingLeft = parseFloat(style.scrollPaddingLeft) || 0; + state.scrollPaddingRight = parseFloat(style.scrollPaddingRight) || 0; + + if (target.scrollWidth !== state.scrollWidth || target.scrollHeight !== state.scrollHeight) { + state.scrollWidth = target.scrollWidth; + state.scrollHeight = target.scrollHeight; + flush(handleSizeChange); + } + + // If the clientWidth or clientHeight changed, scrollbars appeared or disappeared as + // a result of the layout update. In this case, re-layout again to account for the + // adjusted space. In very specific cases this might result in the scrollbars disappearing + // again, resulting in extra padding. We stop after a maximum of two layout passes to avoid + // an infinite loop. This matches how browsers behave with native CSS grid layout. + if (target.clientWidth !== state.width || target.clientHeight !== state.height) { + state.width = target.clientWidth; + state.height = target.clientHeight; + flush(handleRectChange); + + if (target.clientWidth !== state.width || target.clientHeight !== state.height) { + state.width = target.clientWidth; + state.height = target.clientHeight; + flush(handleRectChange); + } + } + + isUpdating.current = false; + }, [ref, state, handleSizeChange, handleRectChange]); + + // Attach events directly to ref so props won't need to be sent upward. + useEvent(ref, 'scroll', handleScroll); + useEvent(ref, 'scrollsnapchange', props.onScrollSnapChange); + useEvent(ref, 'scrollsnapchanging', props.onScrollSnapChanging); + useResizeObserver({ref, box, onResize: updateSize}); + + // React doesn't allow flushSync inside effects, so queue a microtask. + // We also need to wait until all refs are set (e.g. when passing a ref down from a parent). + // In `act` environments, update immediately so we don't need to mock timers in tests. + // We currently need to do this in a seperate render, but within the same act. + // https://github.com/adobe/react-spectrum/pull/7938#discussion_r2078228393 + let queueUpdate = useCallback(() => { + if (isUpdating.current) { return; } + + return isReactAct() ? setUpdate({}) : queueMicrotask(updateSize); + }, [updateSize]); + + // Will only run in tests, needs to be in separate effect so it is properly run in the next render in strict mode. + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => updateSize(fn => fn()), [update]); + + // Fire scrollend on unmount to run cleanups + let onScrollEndEvent = useEffectEvent(onScrollEnd); + useLayoutEffect(() => () => queueMicrotask(onScrollEndEvent), []); + + return {updateSize: queueUpdate}; +} diff --git a/packages/@react-aria/utils/src/useScrollView.ts b/packages/@react-aria/utils/src/useScrollView.ts new file mode 100644 index 00000000000..b7199d4e217 --- /dev/null +++ b/packages/@react-aria/utils/src/useScrollView.ts @@ -0,0 +1,87 @@ +/* + * 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 {CSSProperties, HTMLAttributes, ReactNode, RefObject, useState} from 'react'; +import {mergeProps} from './mergeProps'; +import {Orientation, Size} from '@react-types/shared'; +import {ScrollObserverProps, useScrollObserver} from './useScrollObserver'; +import {useEffectEvent} from './useEffectEvent'; +import {useLayoutEffect} from './useLayoutEffect'; + +export interface ScrollViewProps extends Omit { + style?: CSSProperties, + children?: ReactNode, + contentSize?: Size, + contentStyle?: CSSProperties, + orientation?: Orientation | 'both' +} + +export interface ScrollViewAria { + isScrolling: boolean, + scrollViewProps: HTMLAttributes, + contentProps: HTMLAttributes +} + +export function useScrollView(props: ScrollViewProps, ref: RefObject): ScrollViewAria { + let {contentSize, orientation = 'both'} = props; + + let [isScrolling, setIsScrolling] = useState(false); + + // When content size is controlled, watch border-box instead of content-box so that + // we don't go into an infinite loop when scrollbars appear or disappear. + let box: ResizeObserverBoxOptions = contentSize ? 'border-box' : 'content-box'; + + let observer = useScrollObserver(mergeProps(props, { + onScrollStart: () => setIsScrolling(true), + onScrollEnd: () => setIsScrolling(false), + box + }), ref); + + // In controlled scenarios, we need to watch for content size changes ourselves. + // We also need to resize before the first paint to avoid a flash of missing content. + let updateContentSize = useEffectEvent(observer.updateSize); + useLayoutEffect(() => updateContentSize(), [contentSize?.width, contentSize?.height]); + + let style: CSSProperties = { + ...props.style + }; + + let contentStyle: CSSProperties = { + width: contentSize?.width, + height: contentSize?.height, + display: contentSize ? undefined : 'contents', + pointerEvents: isScrolling ? 'none' : 'auto', + ...props.contentStyle + }; + + // TODO: Add back overflow-x: hidden ? I can't reproduce this anymore. + if (orientation === 'horizontal') { + style.overflowX = 'auto'; + style.overflowY = 'hidden'; + } else if (orientation === 'vertical') { + style.overflowY = 'auto'; + style.overflowX = 'hidden'; + } else { + style.overflow = 'auto'; + } + + return { + isScrolling, + scrollViewProps: { + style + }, + contentProps: { + role: 'presentation', + style: contentStyle + } + }; +} diff --git a/packages/@react-aria/virtualizer/package.json b/packages/@react-aria/virtualizer/package.json index 58fb6cf24ce..9ef189c926c 100644 --- a/packages/@react-aria/virtualizer/package.json +++ b/packages/@react-aria/virtualizer/package.json @@ -29,6 +29,7 @@ "@react-aria/i18n": "^3.12.13", "@react-aria/interactions": "^3.25.6", "@react-aria/utils": "^3.31.0", + "@react-stately/utils": "^3.10.8", "@react-stately/virtualizer": "^4.4.4", "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index b57efc4f19e..15c3baed363 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -10,273 +10,112 @@ * governing permissions and limitations under the License. */ -// @ts-ignore -import {flushSync} from 'react-dom'; -import {getScrollLeft} from './utils'; -import React, { - CSSProperties, - ForwardedRef, - HTMLAttributes, - ReactNode, - RefObject, - useCallback, - useEffect, - useRef, - useState -} from 'react'; -import {Rect, Size} from '@react-stately/virtualizer'; -import {useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; +import {ScrollViewProps as AriaScrollViewProps, mergeProps, ScrollViewAria, useScrollView as useAriaScrollView, useObjectRef} from '@react-aria/utils'; +import {forwardRefType, Orientation, Size} from '@react-types/shared'; +import React, {CSSProperties, ForwardedRef, forwardRef, HTMLAttributes, ReactNode, RefObject, useMemo} from 'react'; import {useLocale} from '@react-aria/i18n'; -interface ScrollViewProps extends HTMLAttributes { +interface ScrollViewProps extends Omit, Omit, 'onScrollEnd'> { contentSize: Size, - onVisibleRectChange: (rect: Rect) => void, children?: ReactNode, + /** @deprecated Use 'contentStyle' instead. */ innerStyle?: CSSProperties, - onScrollStart?: () => void, - onScrollEnd?: () => void, - scrollDirection?: 'horizontal' | 'vertical' | 'both' + /** @deprecated Use 'orientation' instead. */ + scrollDirection?: Orientation | 'both' } -function ScrollView(props: ScrollViewProps, ref: ForwardedRef) { +export const ScrollView = /*#__PURE__*/ (forwardRef as forwardRefType)(function ScrollView(props: ScrollViewProps, ref: ForwardedRef) { ref = useObjectRef(ref); - let {scrollViewProps, contentProps} = useScrollView(props, ref); + let {isScrolling, scrollViewProps, contentProps} = useScrollView(props, ref); return ( -
+
{props.children}
); -} - -const ScrollViewForwardRef: - React.ForwardRefExoticComponent> = -React.forwardRef(ScrollView); -export {ScrollViewForwardRef as ScrollView}; - -interface ScrollViewAria { - isScrolling: boolean, - scrollViewProps: HTMLAttributes, - contentProps: HTMLAttributes -} +}); export function useScrollView(props: ScrollViewProps, ref: RefObject): ScrollViewAria { - let { - contentSize, - onVisibleRectChange, - innerStyle, - onScrollStart, - onScrollEnd, - scrollDirection = 'both', - ...otherProps - } = props; - - let state = useRef({ - scrollTop: 0, - scrollLeft: 0, - scrollEndTime: 0, - scrollTimeout: null as ReturnType | null, - width: 0, - height: 0, - isScrolling: false - }).current; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let {contentSize, onScrollStart, onScrollEnd, onScrollPortChange, onScrollSnapChange, onScrollSnapChanging, onVisibleRectChange, ...otherProps} = props; + let {style, innerStyle, contentStyle = innerStyle, scrollDirection = 'both', orientation = scrollDirection, ...domProps} = otherProps; let {direction} = useLocale(); - let [isScrolling, setScrolling] = useState(false); - - let onScroll = useCallback((e) => { - if (e.target !== e.currentTarget) { - return; - } - - if (props.onScroll) { - props.onScroll(e); - } - - flushSync(() => { - let scrollTop = e.currentTarget.scrollTop; - let scrollLeft = getScrollLeft(e.currentTarget, direction); - - // Prevent rubber band scrolling from shaking when scrolling out of bounds - state.scrollTop = Math.max(0, Math.min(scrollTop, contentSize.height - state.height)); - state.scrollLeft = Math.max(0, Math.min(scrollLeft, contentSize.width - state.width)); - onVisibleRectChange(new Rect(state.scrollLeft, state.scrollTop, state.width, state.height)); - - if (!state.isScrolling) { - state.isScrolling = true; - setScrolling(true); - - // Pause typekit MutationObserver during scrolling. - window.dispatchEvent(new Event('tk.disconnect-observer')); - if (onScrollStart) { - onScrollStart(); - } - } - - // So we don't constantly call clearTimeout and setTimeout, - // keep track of the current timeout time and only reschedule - // the timer when it is getting close. - let now = Date.now(); - if (state.scrollEndTime <= now + 50) { - state.scrollEndTime = now + 300; - - if (state.scrollTimeout != null) { - clearTimeout(state.scrollTimeout); - } - - state.scrollTimeout = setTimeout(() => { - state.isScrolling = false; - setScrolling(false); - state.scrollTimeout = null; - - window.dispatchEvent(new Event('tk.connect-observer')); - if (onScrollEnd) { - onScrollEnd(); - } - }, 300); - } - }); - }, [props, direction, state, contentSize, onVisibleRectChange, onScrollStart, onScrollEnd]); - - // Attach event directly to ref so RAC Virtualizer doesn't need to send props upward. - useEvent(ref, 'scroll', onScroll); - - useEffect(() => { - return () => { - if (state.scrollTimeout != null) { - clearTimeout(state.scrollTimeout); - } - - if (state.isScrolling) { - window.dispatchEvent(new Event('tk.connect-observer')); - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - let isUpdatingSize = useRef(false); - let updateSize = useCallback((flush: typeof flushSync) => { - let dom = ref.current; - if (!dom || isUpdatingSize.current) { - return; - } - - // Prevent reentrancy when resize observer fires, triggers re-layout that results in - // content size update, causing below layout effect to fire. This avoids infinite loops. - isUpdatingSize.current = true; - - let isTestEnv = process.env.NODE_ENV === 'test' && !process.env.VIRT_ON; - let isClientWidthMocked = Object.getOwnPropertyNames(window.HTMLElement.prototype).includes('clientWidth'); - let isClientHeightMocked = Object.getOwnPropertyNames(window.HTMLElement.prototype).includes('clientHeight'); - let clientWidth = dom.clientWidth; - let clientHeight = dom.clientHeight; - let w = isTestEnv && !isClientWidthMocked ? Infinity : clientWidth; - let h = isTestEnv && !isClientHeightMocked ? Infinity : clientHeight; - - if (state.width !== w || state.height !== h) { - state.width = w; - state.height = h; - flush(() => { - onVisibleRectChange(new Rect(state.scrollLeft, state.scrollTop, w, h)); - }); - - // If the clientWidth or clientHeight changed, scrollbars appeared or disappeared as - // a result of the layout update. In this case, re-layout again to account for the - // adjusted space. In very specific cases this might result in the scrollbars disappearing - // again, resulting in extra padding. We stop after a maximum of two layout passes to avoid - // an infinite loop. This matches how browsers behavior with native CSS grid layout. - if (!isTestEnv && clientWidth !== dom.clientWidth || clientHeight !== dom.clientHeight) { - state.width = dom.clientWidth; - state.height = dom.clientHeight; - flush(() => { - onVisibleRectChange(new Rect(state.scrollLeft, state.scrollTop, state.width, state.height)); - }); - } - } - - isUpdatingSize.current = false; - }, [ref, state, onVisibleRectChange]); - let updateSizeEvent = useEffectEvent(updateSize); - - // Update visible rect when the content size changes, in case scrollbars need to appear or disappear. - let lastContentSize = useRef(null); - let [update, setUpdate] = useState({}); - // We only contain a call to setState in here for testing environments. - // eslint-disable-next-line react-hooks/exhaustive-deps - useLayoutEffect(() => { - if (!isUpdatingSize.current && (lastContentSize.current == null || !contentSize.equals(lastContentSize.current))) { - // React doesn't allow flushSync inside effects, so queue a microtask. - // We also need to wait until all refs are set (e.g. when passing a ref down from a parent). - // If we are in an `act` environment, update immediately without a microtask so you don't need - // to mock timers in tests. In this case, the update is synchronous already. - // IS_REACT_ACT_ENVIRONMENT is used by React 18. Previous versions checked for the `jest` global. - // https://github.com/reactwg/react-18/discussions/102 - // @ts-ignore - if (typeof IS_REACT_ACT_ENVIRONMENT === 'boolean' ? IS_REACT_ACT_ENVIRONMENT : typeof jest !== 'undefined') { - // This is so we update size in a separate render but within the same act. Needs to be setState instead of refs - // due to strict mode. - setUpdate({}); - lastContentSize.current = contentSize; - return; - } else { - queueMicrotask(() => updateSizeEvent(flushSync)); - } - } - - lastContentSize.current = contentSize; - }); - - // Will only run in tests, needs to be in separate effect so it is properly run in the next render in strict mode. - useLayoutEffect(() => { - updateSizeEvent(fn => fn()); - }, [update]); - - let onResize = useCallback(() => { - updateSize(flushSync); - }, [updateSize]); - - // Watch border-box instead of of content-box so that we don't go into - // an infinite loop when scrollbars appear or disappear. - useResizeObserver({ref, box: 'border-box', onResize}); - - let style: React.CSSProperties = { - // Reset padding so that relative positioning works correctly. Padding will be done in JS layout. + // Reset padding so that relative positioning works correctly. Padding will be done in JS layout. + let virtualizerStyle: CSSProperties = { padding: 0, - ...otherProps.style + ...style }; - if (scrollDirection === 'horizontal') { - style.overflowX = 'auto'; - style.overflowY = 'hidden'; - } else if (scrollDirection === 'vertical' || contentSize.width === state.width) { - // Set overflow-x: hidden if content size is equal to the width of the scroll view. - // This prevents horizontal scrollbars from flickering during resizing due to resize observer - // firing slower than the frame rate, which may cause an infinite re-render loop. - style.overflowY = 'auto'; - style.overflowX = 'hidden'; - } else { - style.overflow = 'auto'; - } - - innerStyle = { - width: Number.isFinite(contentSize.width) ? contentSize.width : undefined, - height: Number.isFinite(contentSize.height) ? contentSize.height : undefined, - pointerEvents: isScrolling ? 'none' : 'auto', + let layoutStyle: CSSProperties = { position: 'relative', - ...innerStyle + ...contentStyle }; + // Skip native event listener, since we forward props for backwards compatibility :( + let viewProps = {...props, onScroll: undefined}; + let viewRef = useScrollViewRef(viewProps, ref); + + // Pause typekit MutationObserver during scrolling. + let {isScrolling, scrollViewProps, contentProps} = useAriaScrollView(mergeProps(viewProps, { + onScrollStart: () => window.dispatchEvent(new Event('tk.disconnect-observer')), + onScrollEnd: () => window.dispatchEvent(new Event('tk.connect-observer')), + style: virtualizerStyle, + contentStyle: layoutStyle, + orientation, + direction + }), viewRef); + return { isScrolling, + contentProps, scrollViewProps: { - ...otherProps, - style - }, - contentProps: { - role: 'presentation', - style: innerStyle + ...domProps, + ...scrollViewProps } }; } + +// In test environments, we auto-stub the dimensions of the visible rect to the entire contentSize, so that +// consumers don't need to setup dimension stubs to test a virtualized component. Unfortunately, both scrollWidth +// and scrollHeight are used to stub item dimensions, although our observer relies on scrollWidth >= clientWidth. +// This intercepts reads with a Proxy to provide reliable dimensions while avoiding leaks from Virtualizer's scope. +// See https://github.com/adobe/react-spectrum/pull/4835#discussion_r1284897591 +function useScrollViewRef(props: ScrollViewProps, ref: RefObject): RefObject { + let {contentSize} = props; + return useMemo(() => { + if (process.env.NODE_ENV !== 'test') { return ref; } + + let cache: HTMLElement | null = null; + let proto = Object.getOwnPropertyNames(window.HTMLElement.prototype); + + return { + get current() { + if (!ref.current) { return null; } + + return cache ??= new Proxy(ref.current, { + get(target, key, receiver) { + switch (key) { + case 'clientWidth': + return process.env.VIRT_ON || proto.includes(key) ? ref.current![key] : contentSize.width; + case 'clientHeight': + return process.env.VIRT_ON || proto.includes(key) ? ref.current![key] : contentSize.height; + case 'scrollWidth': + return contentSize.width; + case 'scrollHeight': + return contentSize.height; + } + + let value = Reflect.get(target, key, receiver); + return typeof value === 'function' ? value.bind(target) : value; + } + }); + }, + set current(v: HTMLElement | null) { + ref.current = v; + } + }; + }, [ref, contentSize.width, contentSize.height]); +} diff --git a/packages/@react-aria/virtualizer/src/Virtualizer.tsx b/packages/@react-aria/virtualizer/src/Virtualizer.tsx index 0bbd0e56e90..7f5722df6ca 100644 --- a/packages/@react-aria/virtualizer/src/Virtualizer.tsx +++ b/packages/@react-aria/virtualizer/src/Virtualizer.tsx @@ -10,10 +10,10 @@ * governing permissions and limitations under the License. */ -import {Collection, Key, RefObject} from '@react-types/shared'; +import {Collection, forwardRefType, Key} from '@react-types/shared'; import {Layout, Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; import {mergeProps, useLoadMore, useObjectRef} from '@react-aria/utils'; -import React, {ForwardedRef, HTMLAttributes, ReactElement, ReactNode, useCallback} from 'react'; +import React, {ForwardedRef, forwardRef, HTMLAttributes, ReactElement, ReactNode, useCallback} from 'react'; import {ScrollView} from './ScrollView'; import {VirtualizerItem} from './VirtualizerItem'; @@ -36,9 +36,7 @@ interface VirtualizerProps extends Omit(props: VirtualizerProps, forwardedRef: ForwardedRef) { +export const Virtualizer = /*#__PURE__*/ (forwardRef as forwardRefType)(function Virtualizer(props: VirtualizerProps, forwardedRef: ForwardedRef) { let { children: renderView, renderWrapper, @@ -84,7 +82,7 @@ export const Virtualizer = React.forwardRef(function Virtualizer ); -}) as (props: VirtualizerProps & {ref?: RefObject}) => ReactElement; +}); function renderChildren(parent: ReusableView | null, views: ReusableView[], renderWrapper: RenderWrapper) { return views.map(view => { diff --git a/packages/@react-aria/virtualizer/src/utils.ts b/packages/@react-aria/virtualizer/src/utils.ts index 30cf5047515..44c6ae9fb29 100644 --- a/packages/@react-aria/virtualizer/src/utils.ts +++ b/packages/@react-aria/virtualizer/src/utils.ts @@ -11,97 +11,41 @@ */ import {Direction} from '@react-types/shared'; +import {getScrollLeft as get, getOffsetType, setScrollLeft as set} from '@react-aria/utils'; -export type RTLOffsetType = - | 'negative' - | 'positive-descending' - | 'positive-ascending'; +let cache: ReturnType | null = null; -let cachedRTLResult: RTLOffsetType | null = null; - - -// Original licensing for the following methods can be found in the -// NOTICE file in the root directory of this source tree. -// See https://github.com/bvaughn/react-window/blob/master/src/createGridComponent.js - -// According to the spec, scrollLeft should be negative for RTL aligned elements. -// Chrome does not seem to adhere; its scrollLeft values are positive (measured relative to the left). -// Safari's elastic bounce makes detecting this even more complicated wrt potential false positives. -// The safest way to check this is to intentionally set a negative offset, -// and then verify that the subsequent "scroll" event matches the negative offset. -// If it does not match, then we can assume a non-standard RTL scroll implementation. -export function getRTLOffsetType(recalculate: boolean = false): RTLOffsetType { - if (cachedRTLResult === null || recalculate) { - const outerDiv = document.createElement('div'); - const outerStyle = outerDiv.style; - outerStyle.width = '50px'; - outerStyle.height = '50px'; - outerStyle.overflow = 'scroll'; - outerStyle.direction = 'rtl'; - - const innerDiv = document.createElement('div'); - const innerStyle = innerDiv.style; - innerStyle.width = '100px'; - innerStyle.height = '100px'; - - outerDiv.appendChild(innerDiv); - - document.body.appendChild(outerDiv); - - if (outerDiv.scrollLeft > 0) { - cachedRTLResult = 'positive-descending'; - } else { - outerDiv.scrollLeft = 1; - if (outerDiv.scrollLeft === 0) { - cachedRTLResult = 'negative'; - } else { - cachedRTLResult = 'positive-ascending'; - } - } +/** + * @deprecated + */ +export type RTLOffsetType = ReturnType; - document.body.removeChild(outerDiv); +/** + * @deprecated Use `getOffsetType` from `@react-aria/utils` instead. + */ +// TODO: Just return 'negative' here instead? Browsers aligned on RTL since Chrome 85+. +export function getRTLOffsetType(recalculate: boolean = false): ReturnType { + if (!cache || recalculate) { + let el = document.createElement('div'); + el.dir = 'rtl'; - return cachedRTLResult; + cache = getOffsetType(el, recalculate); } - return cachedRTLResult; + return cache; } +/** + * @deprecated Use `getScrollLeft` from `@react-aria/utils` instead. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars export function getScrollLeft(node: Element, direction: Direction): number { - let {scrollLeft} = node; - - // scrollLeft in rtl locales differs across browsers, so normalize. - // See comment by getRTLOffsetType below for details. - if (direction === 'rtl') { - let {scrollWidth, clientWidth} = node; - switch (getRTLOffsetType()) { - case 'negative': - scrollLeft = -scrollLeft; - break; - case 'positive-descending': - scrollLeft = scrollWidth - clientWidth - scrollLeft; - break; - } - } - - return scrollLeft; + return get(node); } +/** + * @deprecated Use `setScrollLeft` from `@react-aria/utils` instead. + */ export function setScrollLeft(node: Element, direction: Direction, scrollLeft: number): void { - if (direction === 'rtl') { - switch (getRTLOffsetType()) { - case 'negative': - scrollLeft = -scrollLeft; - break; - case 'positive-ascending': - break; - default: { - const {clientWidth, scrollWidth} = node; - scrollLeft = scrollWidth - clientWidth - scrollLeft; - break; - } - } - } - - node.scrollLeft = scrollLeft; + return set(node, scrollLeft); } diff --git a/packages/@react-stately/utils/package.json b/packages/@react-stately/utils/package.json index 0bb15826a97..ade0f58dfdf 100644 --- a/packages/@react-stately/utils/package.json +++ b/packages/@react-stately/utils/package.json @@ -29,6 +29,7 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" }, "dependencies": { + "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" }, "publishConfig": { diff --git a/packages/@react-stately/utils/src/index.ts b/packages/@react-stately/utils/src/index.ts index ff98cc62647..05d85787b39 100644 --- a/packages/@react-stately/utils/src/index.ts +++ b/packages/@react-stately/utils/src/index.ts @@ -9,5 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ + +export {Rect, Size, Point} from './layout'; export {useControlledState} from './useControlledState'; export {clamp, snapValueToStep, toFixedNumber} from './number'; diff --git a/packages/@react-stately/utils/src/layout.ts b/packages/@react-stately/utils/src/layout.ts new file mode 100644 index 00000000000..a989dd4141d --- /dev/null +++ b/packages/@react-stately/utils/src/layout.ts @@ -0,0 +1,305 @@ +/* + * 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 {Corner, Point as IPoint, Rect as IRect, Size as ISize} from '@react-types/shared'; + +/** + * Represents a point. + */ +export class Point implements IPoint { + /** The x-coordinate of the point. */ + x: number; + + /** The y-coordinate of the point. */ + y: number; + + constructor(x = 0, y = 0) { + this.x = x; + this.y = y; + } + + /** + * Returns a copy of this point. + */ + copy(): Point { + return new Point(this.x, this.y); + } + + /** + * Checks if two points are equal. + */ + equals(point: Point): boolean { + return this.x === point.x && this.y === point.y; + } + + /** + * Returns the distance between two points. + */ + distance(point: Point): number { + return Math.abs(Math.hypot(this.x - point.x, this.y - point.y)); + } + + /** + * Returns true if this point is the origin. + */ + isOrigin(): boolean { + return this.x === 0 && this.y === 0; + } +} + +/** + * Represents a size. + */ +export class Size implements ISize { + width: number; + height: number; + + constructor(width = 0, height = 0) { + this.width = Math.min(Math.max(width, 0), Number.MAX_SAFE_INTEGER) || 0; + this.height = Math.min(Math.max(height, 0), Number.MAX_SAFE_INTEGER) || 0; + } + + /** + * Returns a copy of this size. + */ + copy(): Size { + return new Size(this.width, this.height); + } + + /** + * Returns whether this size is smaller than another one. + */ + smaller(other: ISize): boolean { + return this.width < other.width + || this.height < other.height; + } + + /** + * Returns whether this size is equal to another one. + */ + equals(other: ISize): boolean { + return this.width === other.width + && this.height === other.height; + } + + /** + * Returns whether this size is larger than another one. + */ + larger(other: ISize): boolean { + return this.width > other.width + || this.height > other.height; + } + + /** + * The total area of the Size. + */ + get area(): number { + return this.width * this.height; + } +} + +/** + * Represents a rectangle. + */ +export class Rect implements IRect { + /** The x-coordinate of the rectangle. */ + x: number; + + /** The y-coordinate of the rectangle. */ + y: number; + + /** The width of the rectangle. */ + width: number; + + /** The height of the rectangle. */ + height: number; + + constructor(x = 0, y = 0, width = 0, height = 0) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + /** + * The size of the rectangle. + */ + get size(): Size { + return new Size(this.width, this.height); + } + + /** + * The maximum x-coordinate in the rectangle. + */ + get maxX(): number { + return this.x + this.width; + } + + /** + * The maximum y-coordinate in the rectangle. + */ + get maxY(): number { + return this.y + this.height; + } + + /** + * The area of the rectangle. + */ + get area(): number { + return this.width * this.height; + } + + /** + * The center of the rectangle. + */ + get center(): Point { + return new Point(this.maxX / 2, this.maxY / 2); + } + + /** + * The top left corner of the rectangle. + */ + get topLeft(): Point { + return new Point(this.x, this.y); + } + + /** + * The top right corner of the rectangle. + */ + get topRight(): Point { + return new Point(this.maxX, this.y); + } + + /** + * The bottom left corner of the rectangle. + */ + get bottomLeft(): Point { + return new Point(this.x, this.maxY); + } + + /** + * The bottom right corner of the rectangle. + */ + get bottomRight(): Point { + return new Point(this.maxX, this.maxY); + } + + /** + * Returns whether this rectangle intersects another rectangle. + * @param rect - The rectangle to check. + */ + intersects(rect: IRect): boolean { + return (this.area > 0 && (rect.width * rect.height) > 0) + && this.x <= rect.x + rect.width + && rect.x <= this.x + this.width + && this.y <= rect.y + rect.height + && rect.y <= this.y + this.height; + } + + /** + * Returns whether this rectangle fully contains another rectangle. + * @param rect - The rectangle to check. + */ + containsRect(rect: IRect): boolean { + return this.x <= rect.x + && this.y <= rect.y + && this.maxX >= rect.x + rect.width + && this.maxY >= rect.y + rect.height; + } + + /** + * Returns whether the rectangle contains the given point. + * @param point - The point to check. + */ + containsPoint(point: IPoint): boolean { + return this.x <= point.x + && this.y <= point.y + && this.maxX >= point.x + && this.maxY >= point.y; + } + + /** + * Returns the first corner of this rectangle (from top to bottom, left to right) + * that is contained in the given rectangle, or null of the rectangles do not intersect. + * @param rect - The rectangle to check. + */ + getCornerInRect(rect: IRect): Corner | null { + let other = new Rect(rect.x, rect.y, rect.width, rect.height); + for (let key of ['topLeft', 'topRight', 'bottomLeft', 'bottomRight']) { + if (other.containsPoint(this[key])) { + return key as Corner; + } + } + + return null; + } + + /** + * Returns whether this rectangle is equal to another rectangle. + */ + equals(rect: IRect): boolean { + return rect.x === this.x + && rect.y === this.y + && rect.width === this.width + && rect.height === this.height; + } + + /** + * Returns whether this rectangle is equal to a point. + */ + pointEquals(point: IPoint | IRect): boolean { + return this.x === point.x + && this.y === point.y; + } + + /** + * Returns whether this rectangle is equal to a size. + */ + sizeEquals(size: ISize | IRect): boolean { + return this.width === size.width + && this.height === size.height; + } + + /** + * Returns the union of this Rect and another. + */ + union(other: IRect): Rect { + let x = Math.min(this.x, other.x); + let y = Math.min(this.y, other.y); + let width = Math.max(this.maxX, other.x + other.width) - x; + let height = Math.max(this.maxY, other.y + other.height) - y; + return new Rect(x, y, width, height); + } + + /** + * Returns the intersection of this Rect with another. + * If the rectangles do not intersect, an all zero Rect is returned. + */ + intersection(other: IRect): Rect { + if (!this.intersects(other)) { + return new Rect(0, 0, 0, 0); + } + + let x = Math.max(this.x, other.x); + let y = Math.max(this.y, other.y); + let width = Math.min(this.maxX, other.x + other.width) - x; + let height = Math.min(this.maxY, other.y + other.height) - y; + return new Rect(x, y, width, height); + } + + /** + * Returns a copy of this rectangle. + */ + copy(): Rect { + return new Rect(this.x, this.y, this.width, this.height); + } +} diff --git a/packages/@react-stately/virtualizer/package.json b/packages/@react-stately/virtualizer/package.json index ddb2a432a12..0bea428b894 100644 --- a/packages/@react-stately/virtualizer/package.json +++ b/packages/@react-stately/virtualizer/package.json @@ -26,6 +26,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-stately/utils": "^3.10.8", "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" }, diff --git a/packages/@react-stately/virtualizer/src/Layout.ts b/packages/@react-stately/virtualizer/src/Layout.ts index bc40f16bb99..fa2c6511fb6 100644 --- a/packages/@react-stately/virtualizer/src/Layout.ts +++ b/packages/@react-stately/virtualizer/src/Layout.ts @@ -13,8 +13,7 @@ import {InvalidationContext} from './types'; import {ItemDropTarget, Key, LayoutDelegate, Node} from '@react-types/shared'; import {LayoutInfo} from './LayoutInfo'; -import {Rect} from './Rect'; -import {Size} from './Size'; +import {Rect, Size} from '@react-stately/utils'; import {Virtualizer} from './Virtualizer'; /** diff --git a/packages/@react-stately/virtualizer/src/LayoutInfo.ts b/packages/@react-stately/virtualizer/src/LayoutInfo.ts index 279a1cac632..8d08cd1e270 100644 --- a/packages/@react-stately/virtualizer/src/LayoutInfo.ts +++ b/packages/@react-stately/virtualizer/src/LayoutInfo.ts @@ -11,7 +11,7 @@ */ import {Key} from '@react-types/shared'; -import {Rect} from './Rect'; +import {Rect} from '@react-stately/utils'; /** * Instances of this lightweight class are created by `Layout` subclasses diff --git a/packages/@react-stately/virtualizer/src/OverscanManager.ts b/packages/@react-stately/virtualizer/src/OverscanManager.ts index 9794da1592d..583c016ea90 100644 --- a/packages/@react-stately/virtualizer/src/OverscanManager.ts +++ b/packages/@react-stately/virtualizer/src/OverscanManager.ts @@ -10,8 +10,7 @@ * governing permissions and limitations under the License. */ -import {Point} from './Point'; -import {Rect} from './Rect'; +import {Point, Rect} from '@react-stately/utils'; export class OverscanManager { private startTime = 0; diff --git a/packages/@react-stately/virtualizer/src/Point.ts b/packages/@react-stately/virtualizer/src/Point.ts deleted file mode 100644 index d2c4345ca3c..00000000000 --- a/packages/@react-stately/virtualizer/src/Point.ts +++ /dev/null @@ -1,45 +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. - */ - -export class Point { - /** The x-coordinate of the point. */ - x: number; - - /** The y-coordinate of the point. */ - y: number; - - constructor(x = 0, y = 0) { - this.x = x; - this.y = y; - } - - /** - * Returns a copy of this point. - */ - copy(): Point { - return new Point(this.x, this.y); - } - - /** - * Checks if two points are equal. - */ - equals(point: Point): boolean { - return this.x === point.x && this.y === point.y; - } - - /** - * Returns true if this point is the origin. - */ - isOrigin(): boolean { - return this.x === 0 && this.y === 0; - } -} diff --git a/packages/@react-stately/virtualizer/src/Rect.ts b/packages/@react-stately/virtualizer/src/Rect.ts deleted file mode 100644 index dda388f943b..00000000000 --- a/packages/@react-stately/virtualizer/src/Rect.ts +++ /dev/null @@ -1,193 +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'; -import {Size} from './Size'; - -export type RectCorner = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; - -/** - * Represents a rectangle. - */ -export class Rect { - /** The x-coordinate of the rectangle. */ - x: number; - - /** The y-coordinate of the rectangle. */ - y: number; - - /** The width of the rectangle. */ - width: number; - - /** The height of the rectangle. */ - height: number; - - constructor(x = 0, y = 0, width = 0, height = 0) { - this.x = x; - this.y = y; - this.width = width; - this.height = height; - } - - /** - * The maximum x-coordinate in the rectangle. - */ - get maxX(): number { - return this.x + this.width; - } - - /** - * The maximum y-coordinate in the rectangle. - */ - get maxY(): number { - return this.y + this.height; - } - - /** - * The area of the rectangle. - */ - get area(): number { - return this.width * this.height; - } - - /** - * The top left corner of the rectangle. - */ - get topLeft(): Point { - return new Point(this.x, this.y); - } - - /** - * The top right corner of the rectangle. - */ - get topRight(): Point { - return new Point(this.maxX, this.y); - } - - /** - * The bottom left corner of the rectangle. - */ - get bottomLeft(): Point { - return new Point(this.x, this.maxY); - } - - /** - * The bottom right corner of the rectangle. - */ - get bottomRight(): Point { - return new Point(this.maxX, this.maxY); - } - - /** - * Returns whether this rectangle intersects another rectangle. - * @param rect - The rectangle to check. - */ - intersects(rect: Rect): boolean { - let isTestEnv = process.env.NODE_ENV === 'test' && !process.env.VIRT_ON; - return (isTestEnv || this.area > 0 && rect.area > 0) - && this.x <= rect.x + rect.width - && rect.x <= this.x + this.width - && this.y <= rect.y + rect.height - && rect.y <= this.y + this.height; - } - - /** - * Returns whether this rectangle fully contains another rectangle. - * @param rect - The rectangle to check. - */ - containsRect(rect: Rect): boolean { - return this.x <= rect.x - && this.y <= rect.y - && this.maxX >= rect.maxX - && this.maxY >= rect.maxY; - } - - /** - * Returns whether the rectangle contains the given point. - * @param point - The point to check. - */ - containsPoint(point: Point): boolean { - return this.x <= point.x - && this.y <= point.y - && this.maxX >= point.x - && this.maxY >= point.y; - } - - /** - * Returns the first corner of this rectangle (from top to bottom, left to right) - * that is contained in the given rectangle, or null of the rectangles do not intersect. - * @param rect - The rectangle to check. - */ - getCornerInRect(rect: Rect): RectCorner | null { - for (let key of ['topLeft', 'topRight', 'bottomLeft', 'bottomRight']) { - if (rect.containsPoint(this[key])) { - return key as RectCorner; - } - } - - return null; - } - - equals(rect: Rect): boolean { - return rect.x === this.x - && rect.y === this.y - && rect.width === this.width - && rect.height === this.height; - } - - pointEquals(point: Point | Rect): boolean { - return this.x === point.x - && this.y === point.y; - } - - sizeEquals(size: Size | Rect): boolean { - return this.width === size.width - && this.height === size.height; - } - - /** - * Returns the union of this Rect and another. - */ - union(other: Rect): Rect { - let x = Math.min(this.x, other.x); - let y = Math.min(this.y, other.y); - let width = Math.max(this.maxX, other.maxX) - x; - let height = Math.max(this.maxY, other.maxY) - y; - return new Rect(x, y, width, height); - } - - /** - * Returns the intersection of this Rect with another. - * If the rectangles do not intersect, an all zero Rect is returned. - */ - intersection(other: Rect): Rect { - if (!this.intersects(other)) { - return new Rect(0, 0, 0, 0); - } - - let x = Math.max(this.x, other.x); - let y = Math.max(this.y, other.y); - return new Rect( - x, - y, - Math.min(this.maxX, other.maxX) - x, - Math.min(this.maxY, other.maxY) - y - ); - } - - /** - * Returns a copy of this rectangle. - */ - copy(): Rect { - return new Rect(this.x, this.y, this.width, this.height); - } -} diff --git a/packages/@react-stately/virtualizer/src/Size.ts b/packages/@react-stately/virtualizer/src/Size.ts deleted file mode 100644 index 918bf40f9d1..00000000000 --- a/packages/@react-stately/virtualizer/src/Size.ts +++ /dev/null @@ -1,43 +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. - */ - -export class Size { - width: number; - height: number; - - constructor(width = 0, height = 0) { - this.width = Math.max(width, 0); - this.height = Math.max(height, 0); - } - - /** - * Returns a copy of this size. - */ - copy(): Size { - return new Size(this.width, this.height); - } - - /** - * Returns whether this size is equal to another one. - */ - equals(other: Size): boolean { - return this.width === other.width - && this.height === other.height; - } - - /** - * The total area of the Size. - */ - get area(): number { - return this.width * this.height; - } -} diff --git a/packages/@react-stately/virtualizer/src/Virtualizer.ts b/packages/@react-stately/virtualizer/src/Virtualizer.ts index f768f344e01..e4cced9142d 100644 --- a/packages/@react-stately/virtualizer/src/Virtualizer.ts +++ b/packages/@react-stately/virtualizer/src/Virtualizer.ts @@ -17,9 +17,7 @@ import {isSetEqual} from './utils'; import {Layout} from './Layout'; import {LayoutInfo} from './LayoutInfo'; import {OverscanManager} from './OverscanManager'; -import {Point} from './Point'; -import {Rect} from './Rect'; -import {Size} from './Size'; +import {Point, Rect, Size} from '@react-stately/utils'; interface VirtualizerOptions { delegate: VirtualizerDelegate, @@ -193,7 +191,7 @@ export class Virtualizer { } else { rect = this._overscanManager.getOverscannedRect(); } - let layoutInfos = this.layout.getVisibleLayoutInfos(rect); + let layoutInfos = rect.area === 0 ? [] : this.layout.getVisibleLayoutInfos(rect); let map = new Map; for (let layoutInfo of layoutInfos) { map.set(layoutInfo.key, layoutInfo); diff --git a/packages/@react-stately/virtualizer/src/index.ts b/packages/@react-stately/virtualizer/src/index.ts index ca0097e88f5..9a746f91cb9 100644 --- a/packages/@react-stately/virtualizer/src/index.ts +++ b/packages/@react-stately/virtualizer/src/index.ts @@ -10,14 +10,13 @@ * governing permissions and limitations under the License. */ -export type {InvalidationContext} from './types'; -export type {VirtualizerState} from './useVirtualizerState'; -export type {RectCorner} from './Rect'; +export {Point, Rect, Size} from '@react-stately/utils'; export {Layout} from './Layout'; export {LayoutInfo} from './LayoutInfo'; -export {Point} from './Point'; -export {Rect} from './Rect'; -export {Size} from './Size'; export {ReusableView} from './ReusableView'; export {useVirtualizerState} from './useVirtualizerState'; + +export type {InvalidationContext} from './types'; +export type {VirtualizerState} from './useVirtualizerState'; +export type {RectCorner} from './Rect'; diff --git a/packages/@react-stately/virtualizer/src/types.ts b/packages/@react-stately/virtualizer/src/types.ts index 6615b582531..817ba37dd65 100644 --- a/packages/@react-stately/virtualizer/src/types.ts +++ b/packages/@react-stately/virtualizer/src/types.ts @@ -12,7 +12,7 @@ import {Collection, Key} from '@react-types/shared'; import {Layout} from './Layout'; -import {Rect} from './Rect'; +import {Rect} from '@react-stately/utils'; export interface InvalidationContext { contentChanged?: boolean, diff --git a/packages/@react-stately/virtualizer/src/useVirtualizerState.ts b/packages/@react-stately/virtualizer/src/useVirtualizerState.ts index dfa5181991e..586ade32bc3 100644 --- a/packages/@react-stately/virtualizer/src/useVirtualizerState.ts +++ b/packages/@react-stately/virtualizer/src/useVirtualizerState.ts @@ -14,9 +14,8 @@ import {Collection, Key} from '@react-types/shared'; import {InvalidationContext} from './types'; import {Layout} from './Layout'; import React, {useCallback, useMemo, useRef, useState} from 'react'; -import {Rect} from './Rect'; +import {Rect, Size} from '@react-stately/utils'; import {ReusableView} from './ReusableView'; -import {Size} from './Size'; import {Virtualizer} from './Virtualizer'; // During SSR, React emits a warning when calling useLayoutEffect. diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index a653dfc9768..72e24cf944a 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -123,6 +123,8 @@ export interface KeyboardDelegate { getKeyForSearch?(search: string, fromKey?: Key | null): Key | null } +export type Corner = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; + export interface Rect { x: number, y: number, @@ -135,6 +137,11 @@ export interface Size { height: number } +export interface Point { + x: number, + y: number +} + /** A LayoutDelegate provides layout information for collection items. */ export interface LayoutDelegate { /** Returns a rectangle for the item with the given key. */ From 22d5729e18613106ec0ad862f71eb5b9fd117cd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Thu, 30 Oct 2025 02:10:33 +0100 Subject: [PATCH 2/6] chore: update yarn.lock --- yarn.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/yarn.lock b/yarn.lock index dbf1c445a80..eae80864f28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6375,6 +6375,7 @@ __metadata: "@react-aria/i18n": "npm:^3.12.13" "@react-aria/interactions": "npm:^3.25.6" "@react-aria/utils": "npm:^3.31.0" + "@react-stately/utils": "npm:^3.10.8" "@react-stately/virtualizer": "npm:^4.4.4" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" @@ -8447,6 +8448,7 @@ __metadata: version: 0.0.0-use.local resolution: "@react-stately/utils@workspace:packages/@react-stately/utils" dependencies: + "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -8457,6 +8459,7 @@ __metadata: version: 0.0.0-use.local resolution: "@react-stately/virtualizer@workspace:packages/@react-stately/virtualizer" dependencies: + "@react-stately/utils": "npm:^3.10.8" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: From ce5cbe5676f74772f77157ab2a32954b0494409c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Thu, 30 Oct 2025 02:43:51 +0100 Subject: [PATCH 3/6] fix: state update on unmount --- packages/@react-aria/utils/src/useScrollObserver.ts | 5 ----- packages/@react-aria/virtualizer/src/ScrollView.tsx | 7 ++++++- packages/@react-stately/virtualizer/src/index.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/@react-aria/utils/src/useScrollObserver.ts b/packages/@react-aria/utils/src/useScrollObserver.ts index eb119db5406..5d62db64a94 100644 --- a/packages/@react-aria/utils/src/useScrollObserver.ts +++ b/packages/@react-aria/utils/src/useScrollObserver.ts @@ -15,7 +15,6 @@ import {getScrollLeft, getScrollTop} from './getScrollOffset'; import {isReactAct} from './platform'; import {Rect, Size} from '@react-stately/utils'; import {RefObject, useCallback, useRef, useState} from 'react'; -import {useEffectEvent} from './useEffectEvent'; import {useEvent} from './useEvent'; import {useLayoutEffect} from './useLayoutEffect'; import {useResizeObserver} from './useResizeObserver'; @@ -178,9 +177,5 @@ export function useScrollObserver(props: ScrollObserverProps, ref: RefObject updateSize(fn => fn()), [update]); - // Fire scrollend on unmount to run cleanups - let onScrollEndEvent = useEffectEvent(onScrollEnd); - useLayoutEffect(() => () => queueMicrotask(onScrollEndEvent), []); - return {updateSize: queueUpdate}; } diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 15c3baed363..764ecf95be2 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {ScrollViewProps as AriaScrollViewProps, mergeProps, ScrollViewAria, useScrollView as useAriaScrollView, useObjectRef} from '@react-aria/utils'; +import {ScrollViewProps as AriaScrollViewProps, mergeProps, ScrollViewAria, useScrollView as useAriaScrollView, useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {forwardRefType, Orientation, Size} from '@react-types/shared'; import React, {CSSProperties, ForwardedRef, forwardRef, HTMLAttributes, ReactNode, RefObject, useMemo} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -68,6 +68,11 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject () => { + window.dispatchEvent(new Event('tk.connect-observer')); + }, []); + return { isScrolling, contentProps, diff --git a/packages/@react-stately/virtualizer/src/index.ts b/packages/@react-stately/virtualizer/src/index.ts index 9a746f91cb9..cded43df448 100644 --- a/packages/@react-stately/virtualizer/src/index.ts +++ b/packages/@react-stately/virtualizer/src/index.ts @@ -11,6 +11,7 @@ */ export {Point, Rect, Size} from '@react-stately/utils'; +export type {Corner as RectCorner} from '@react-types/shared'; export {Layout} from './Layout'; export {LayoutInfo} from './LayoutInfo'; @@ -19,4 +20,3 @@ export {useVirtualizerState} from './useVirtualizerState'; export type {InvalidationContext} from './types'; export type {VirtualizerState} from './useVirtualizerState'; -export type {RectCorner} from './Rect'; From d6c122bbce3ed4b2085a6100b3383f1556440cad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Thu, 30 Oct 2025 02:54:48 +0100 Subject: [PATCH 4/6] fix: ssr support --- packages/@react-aria/virtualizer/src/ScrollView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 764ecf95be2..2d963b3c443 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -94,7 +94,7 @@ function useScrollViewRef(props: ScrollViewProps, ref: RefObject Date: Thu, 30 Oct 2025 16:45:57 +0100 Subject: [PATCH 5/6] fix: strict mode tests in react 18+ --- packages/@react-aria/utils/src/useScrollObserver.ts | 1 - packages/@react-spectrum/s2/test/EditableTableView.test.tsx | 2 +- scripts/setupTests.js | 3 ++- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/utils/src/useScrollObserver.ts b/packages/@react-aria/utils/src/useScrollObserver.ts index 5d62db64a94..de94038ce7a 100644 --- a/packages/@react-aria/utils/src/useScrollObserver.ts +++ b/packages/@react-aria/utils/src/useScrollObserver.ts @@ -87,7 +87,6 @@ export function useScrollObserver(props: ScrollObserverProps, ref: RefObject flushSync(() => { state.isScrolling = false; - clearTimeout(state.scrollTimeout); state.scrollTimeout = undefined; onScrollEnd?.(); }), [state, onScrollEnd]); diff --git a/packages/@react-spectrum/s2/test/EditableTableView.test.tsx b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx index e102ac19515..08d38718ac5 100644 --- a/packages/@react-spectrum/s2/test/EditableTableView.test.tsx +++ b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx @@ -277,7 +277,7 @@ describe('TableView', () => { // TODO: also weird that it is dialog.dialog? expect(dialog).toBeVisible(); - let selectTester = testUtilUser.createTester('Select', {root: dialog!}); + let selectTester = testUtilUser.createTester('Select', {root: dialog!, interactionType: 'keyboard'}); expect(selectTester.trigger).toHaveFocus(); await selectTester.selectOption({option: 'Steven'}); act(() => {jest.runAllTimers();}); diff --git a/scripts/setupTests.js b/scripts/setupTests.js index 9565be18aec..a6a070d45a2 100644 --- a/scripts/setupTests.js +++ b/scripts/setupTests.js @@ -35,7 +35,8 @@ if (!process.env.LISTENING_TO_UNHANDLED_REJECTION) { const ERROR_PATTERNS_WE_SHOULD_FIX_BUT_ALLOW = [ 'ReactDOM.render is no longer supported in React 18', 'ReactDOM.render has not been supported since React 18', - '`ReactDOMTestUtils.act` is deprecated in favor of `React.act`' + '`ReactDOMTestUtils.act` is deprecated in favor of `React.act`', + 'Warning: unmountComponentAtNode is deprecated and will be removed in the next major release. Switch to the createRoot API. Learn more: https://reactjs.org/link/switch-to-createroot' ]; const WARNING_PATTERNS_WE_SHOULD_FIX_BUT_ALLOW = [ From e18f85ef96b5fafdcbf91fd3f9e27741285ff9dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Thu, 30 Oct 2025 17:10:19 +0100 Subject: [PATCH 6/6] chore: remove virt_on code --- .../@react-stately/virtualizer/src/Virtualizer.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/@react-stately/virtualizer/src/Virtualizer.ts b/packages/@react-stately/virtualizer/src/Virtualizer.ts index e4cced9142d..3524bc22f3f 100644 --- a/packages/@react-stately/virtualizer/src/Virtualizer.ts +++ b/packages/@react-stately/virtualizer/src/Virtualizer.ts @@ -181,16 +181,7 @@ export class Virtualizer { } getVisibleLayoutInfos(): Map { - 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 rect: Rect; - if (isTestEnv && !(isClientWidthMocked && isClientHeightMocked)) { - rect = new Rect(0, 0, this.contentSize.width, this.contentSize.height); - } else { - rect = this._overscanManager.getOverscannedRect(); - } + let rect = this._overscanManager.getOverscannedRect(); let layoutInfos = rect.area === 0 ? [] : this.layout.getVisibleLayoutInfos(rect); let map = new Map; for (let layoutInfo of layoutInfos) {