From 14e10f57d854fae05f721d840a9c63110bb0fa5c Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Thu, 1 Aug 2024 10:23:37 +0300 Subject: [PATCH] feat: optional flag to skip animation frame delay in resize observer --- src/TableVirtuoso.tsx | 28 +++++++++++++++++++++++----- src/Virtuoso.tsx | 24 +++++++++++++++++++----- src/VirtuosoGrid.tsx | 22 +++++++++++++++++----- src/component-interfaces/Virtuoso.ts | 7 +++++++ src/domIOSystem.ts | 2 ++ src/hooks/useChangedChildSizes.ts | 9 +++++---- src/hooks/useSize.ts | 11 ++++++----- src/hooks/useWindowViewportRect.ts | 8 ++++++-- 8 files changed, 85 insertions(+), 26 deletions(-) diff --git a/src/TableVirtuoso.tsx b/src/TableVirtuoso.tsx index 2da9e4137..6c19b54b2 100644 --- a/src/TableVirtuoso.tsx +++ b/src/TableVirtuoso.tsx @@ -100,7 +100,9 @@ const Items = /*#__PURE__*/ React.memo(function VirtuosoItems() { scrollContainerStateCallback, log, undefined, - customScrollParent + customScrollParent, + false, + useEmitterValue('skipAnimationFrameInResizeObserver') ) const [deviation, setDeviation] = React.useState(0) @@ -172,7 +174,11 @@ const Viewport: React.FC> = ({ children }) => { const ctx = React.useContext(VirtuosoMockContext) const viewportHeight = usePublisher('viewportHeight') const fixedItemHeight = usePublisher('fixedItemHeight') - const viewportRef = useSize(React.useMemo(() => u.compose(viewportHeight, (el) => correctItemSize(el, 'height')), [viewportHeight])) + const viewportRef = useSize( + React.useMemo(() => u.compose(viewportHeight, (el) => correctItemSize(el, 'height')), [viewportHeight]), + true, + useEmitterValue('skipAnimationFrameInResizeObserver') + ) React.useEffect(() => { if (ctx) { @@ -193,7 +199,11 @@ const WindowViewport: React.FC> = ({ children } const windowViewportRect = usePublisher('windowViewportRect') const fixedItemHeight = usePublisher('fixedItemHeight') const customScrollParent = useEmitterValue('customScrollParent') - const viewportRef = useWindowViewportRectRef(windowViewportRect, customScrollParent) + const viewportRef = useWindowViewportRectRef( + windowViewportRect, + customScrollParent, + useEmitterValue('skipAnimationFrameInResizeObserver') + ) React.useEffect(() => { if (ctx) { @@ -217,8 +227,16 @@ const TableRoot: React.FC = /*#__PURE__*/ React.memo(function Ta const fixedHeaderContent = useEmitterValue('fixedHeaderContent') const fixedFooterContent = useEmitterValue('fixedFooterContent') const context = useEmitterValue('context') - const theadRef = useSize(React.useMemo(() => u.compose(fixedHeaderHeight, (el) => correctItemSize(el, 'height')), [fixedHeaderHeight])) - const tfootRef = useSize(React.useMemo(() => u.compose(fixedFooterHeight, (el) => correctItemSize(el, 'height')), [fixedFooterHeight])) + const theadRef = useSize( + React.useMemo(() => u.compose(fixedHeaderHeight, (el) => correctItemSize(el, 'height')), [fixedHeaderHeight]), + true, + useEmitterValue('skipAnimationFrameInResizeObserver') + ) + const tfootRef = useSize( + React.useMemo(() => u.compose(fixedFooterHeight, (el) => correctItemSize(el, 'height')), [fixedFooterHeight]), + true, + useEmitterValue('skipAnimationFrameInResizeObserver') + ) const TheScroller = customScrollParent || useWindowScroll ? WindowScroller : Scroller const TheViewport = customScrollParent || useWindowScroll ? WindowViewport : Viewport const TheTable = useEmitterValue('TableComponent') diff --git a/src/Virtuoso.tsx b/src/Virtuoso.tsx index ebc9776ea..84c8a504d 100644 --- a/src/Virtuoso.tsx +++ b/src/Virtuoso.tsx @@ -95,7 +95,8 @@ const Items = /*#__PURE__*/ React.memo(function VirtuosoItems({ showTopList = fa log, listGap, customScrollParent, - horizontalDirection + horizontalDirection, + useEmitterValue('skipAnimationFrameInResizeObserver') ) const [deviation, setDeviation] = React.useState(0) @@ -244,7 +245,11 @@ const Header: React.FC = /*#__PURE__*/ React.memo(function VirtuosoHeader() { const Header = useEmitterValue('HeaderComponent') const headerHeight = usePublisher('headerHeight') const headerFooterTag = useEmitterValue('headerFooterTag') - const ref = useSize(React.useMemo(() => (el) => headerHeight(correctItemSize(el, 'height')), [headerHeight])) + const ref = useSize( + React.useMemo(() => (el) => headerHeight(correctItemSize(el, 'height')), [headerHeight]), + true, + useEmitterValue('skipAnimationFrameInResizeObserver') + ) const context = useEmitterValue('context') return Header ? React.createElement(headerFooterTag, { ref }, React.createElement(Header, contextPropIfNotDomElement(Header, context))) @@ -255,7 +260,11 @@ const Footer: React.FC = /*#__PURE__*/ React.memo(function VirtuosoFooter() { const Footer = useEmitterValue('FooterComponent') const footerHeight = usePublisher('footerHeight') const headerFooterTag = useEmitterValue('headerFooterTag') - const ref = useSize(React.useMemo(() => (el) => footerHeight(correctItemSize(el, 'height')), [footerHeight])) + const ref = useSize( + React.useMemo(() => (el) => footerHeight(correctItemSize(el, 'height')), [footerHeight]), + true, + useEmitterValue('skipAnimationFrameInResizeObserver') + ) const context = useEmitterValue('context') return Footer ? React.createElement(headerFooterTag, { ref }, React.createElement(Footer, contextPropIfNotDomElement(Footer, context))) @@ -357,7 +366,7 @@ const Viewport: React.FC> = ({ children }) => { () => u.compose(viewportHeight, (el: HTMLElement) => correctItemSize(el, horizontalDirection ? 'width' : 'height')), [viewportHeight, horizontalDirection] ) - const viewportRef = useSize(viewportSizeCallbackMemo) + const viewportRef = useSize(viewportSizeCallbackMemo, true, useEmitterValue('skipAnimationFrameInResizeObserver')) React.useEffect(() => { if (ctx) { @@ -378,7 +387,11 @@ const WindowViewport: React.FC> = ({ children } const windowViewportRect = usePublisher('windowViewportRect') const fixedItemHeight = usePublisher('fixedItemHeight') const customScrollParent = useEmitterValue('customScrollParent') - const viewportRef = useWindowViewportRectRef(windowViewportRect, customScrollParent) + const viewportRef = useWindowViewportRectRef( + windowViewportRect, + customScrollParent, + useEmitterValue('skipAnimationFrameInResizeObserver') + ) const alignToBottom = useEmitterValue('alignToBottom') React.useEffect(() => { @@ -465,6 +478,7 @@ export const { scrollerRef: 'scrollerRef', logLevel: 'logLevel', horizontalDirection: 'horizontalDirection', + skipAnimationFrameInResizeObserver: 'skipAnimationFrameInResizeObserver', }, methods: { scrollToIndex: 'scrollToIndex', diff --git a/src/VirtuosoGrid.tsx b/src/VirtuosoGrid.tsx index 9984c3217..59c363868 100644 --- a/src/VirtuosoGrid.tsx +++ b/src/VirtuosoGrid.tsx @@ -88,7 +88,9 @@ const GridItems: React.FC = /*#__PURE__*/ React.memo(function GridItems() { }) }, [scrollHeightCallback, itemDimensions, gridGap, log] - ) + ), + true, + false ) if (stateRestoreInProgress) { @@ -129,7 +131,11 @@ const Header: React.FC = React.memo(function VirtuosoHeader() { const Header = useEmitterValue('HeaderComponent') const headerHeight = usePublisher('headerHeight') const headerFooterTag = useEmitterValue('headerFooterTag') - const ref = useSize(React.useMemo(() => (el) => headerHeight(correctItemSize(el, 'height')), [headerHeight])) + const ref = useSize( + React.useMemo(() => (el) => headerHeight(correctItemSize(el, 'height')), [headerHeight]), + true, + false + ) const context = useEmitterValue('context') return Header ? React.createElement(headerFooterTag, { ref }, React.createElement(Header, contextPropIfNotDomElement(Header, context))) @@ -140,7 +146,11 @@ const Footer: React.FC = React.memo(function VirtuosoGridFooter() { const Footer = useEmitterValue('FooterComponent') const footerHeight = usePublisher('footerHeight') const headerFooterTag = useEmitterValue('headerFooterTag') - const ref = useSize(React.useMemo(() => (el) => footerHeight(correctItemSize(el, 'height')), [footerHeight])) + const ref = useSize( + React.useMemo(() => (el) => footerHeight(correctItemSize(el, 'height')), [footerHeight]), + true, + false + ) const context = useEmitterValue('context') return Footer ? React.createElement(headerFooterTag, { ref }, React.createElement(Footer, contextPropIfNotDomElement(Footer, context))) @@ -158,7 +168,9 @@ const Viewport: React.FC> = ({ children }) => { viewportDimensions(el.getBoundingClientRect()) }, [viewportDimensions] - ) + ), + true, + false ) React.useEffect(() => { @@ -180,7 +192,7 @@ const WindowViewport: React.FC> = ({ children } const windowViewportRect = usePublisher('windowViewportRect') const itemDimensions = usePublisher('itemDimensions') const customScrollParent = useEmitterValue('customScrollParent') - const viewportRef = useWindowViewportRectRef(windowViewportRect, customScrollParent) + const viewportRef = useWindowViewportRectRef(windowViewportRect, customScrollParent, false) React.useEffect(() => { if (ctx) { diff --git a/src/component-interfaces/Virtuoso.ts b/src/component-interfaces/Virtuoso.ts index 54a02c12f..0c1e07f4c 100644 --- a/src/component-interfaces/Virtuoso.ts +++ b/src/component-interfaces/Virtuoso.ts @@ -257,6 +257,13 @@ export interface VirtuosoProps extends ListRootProps { * When set, turns the scroller into a horizontal list. The items are positioned with `inline-block`. */ horizontalDirection?: boolean + + /** + * When set, the resize observer used to measure the items will not use `requestAnimationFrame` to report the size changes. + * Setting this to true will improve performance and reduce flickering, but will cause benign errors to be reported in the console if the size of the items changes while they are being measured. + * See https://github.com/petyosi/react-virtuoso/issues/1049 for more information. + */ + skipAnimationFrameInResizeObserver?: boolean } export interface GroupedVirtuosoProps extends Omit, 'totalCount' | 'itemContent'> { diff --git a/src/domIOSystem.ts b/src/domIOSystem.ts index dd5f49d7d..ec669224a 100644 --- a/src/domIOSystem.ts +++ b/src/domIOSystem.ts @@ -18,6 +18,7 @@ export const domIOSystem = u.system( const scrollBy = u.stream() const scrollingInProgress = u.statefulStream(false) const horizontalDirection = u.statefulStream(false) + const skipAnimationFrameInResizeObserver = u.statefulStream(false) u.connect( u.pipe( @@ -49,6 +50,7 @@ export const domIOSystem = u.system( scrollHeight, smoothScrollTargetReached, horizontalDirection, + skipAnimationFrameInResizeObserver, // signals scrollTo, diff --git a/src/hooks/useChangedChildSizes.ts b/src/hooks/useChangedChildSizes.ts index c932f165e..5a9a02ccf 100644 --- a/src/hooks/useChangedChildSizes.ts +++ b/src/hooks/useChangedChildSizes.ts @@ -8,9 +8,10 @@ export default function useChangedListContentsSizes( enabled: boolean, scrollContainerStateCallback: (state: ScrollContainerState) => void, log: Log, - gap?: (gap: number) => void, - customScrollParent?: HTMLElement, - horizontalDirection?: boolean + gap: ((gap: number) => void) | undefined, + customScrollParent: HTMLElement | undefined, + horizontalDirection: boolean, + skipAnimationFrame: boolean ) { const memoedCallback = React.useCallback( (el: HTMLElement) => { @@ -79,7 +80,7 @@ export default function useChangedListContentsSizes( [callback, itemSize, log, gap, customScrollParent, scrollContainerStateCallback] ) - return useSizeWithElRef(memoedCallback, enabled) + return useSizeWithElRef(memoedCallback, enabled, skipAnimationFrame) } function getChangedChildSizes(children: HTMLCollection, itemSize: SizeFunction, field: 'offsetHeight' | 'offsetWidth', log: Log) { diff --git a/src/hooks/useSize.ts b/src/hooks/useSize.ts index d1686b9f7..0daed864a 100644 --- a/src/hooks/useSize.ts +++ b/src/hooks/useSize.ts @@ -2,7 +2,7 @@ import React from 'react' export type CallbackRefParam = HTMLElement | null -export function useSizeWithElRef(callback: (e: HTMLElement) => void, enabled = true) { +export function useSizeWithElRef(callback: (e: HTMLElement) => void, enabled: boolean, skipAnimationFrame: boolean) { const ref = React.useRef(null) let callbackRef = (_el: CallbackRefParam) => { @@ -12,12 +12,13 @@ export function useSizeWithElRef(callback: (e: HTMLElement) => void, enabled = t if (typeof ResizeObserver !== 'undefined') { const observer = React.useMemo(() => { return new ResizeObserver((entries: ResizeObserverEntry[]) => { - requestAnimationFrame(() => { + const code = () => { const element = entries[0].target as HTMLElement if (element.offsetParent !== null) { callback(element) } - }) + } + skipAnimationFrame ? code() : requestAnimationFrame(code) }) }, [callback]) @@ -37,6 +38,6 @@ export function useSizeWithElRef(callback: (e: HTMLElement) => void, enabled = t return { ref, callbackRef } } -export default function useSize(callback: (e: HTMLElement) => void, enabled = true) { - return useSizeWithElRef(callback, enabled).callbackRef +export default function useSize(callback: (e: HTMLElement) => void, enabled: boolean, skipAnimationFrame: boolean) { + return useSizeWithElRef(callback, enabled, skipAnimationFrame).callbackRef } diff --git a/src/hooks/useWindowViewportRect.ts b/src/hooks/useWindowViewportRect.ts index 084aa11c2..f1971aaee 100644 --- a/src/hooks/useWindowViewportRect.ts +++ b/src/hooks/useWindowViewportRect.ts @@ -2,7 +2,11 @@ import React from 'react' import { useSizeWithElRef } from './useSize' import { WindowViewportInfo } from '../interfaces' -export default function useWindowViewportRectRef(callback: (info: WindowViewportInfo) => void, customScrollParent?: HTMLElement) { +export default function useWindowViewportRectRef( + callback: (info: WindowViewportInfo) => void, + customScrollParent: HTMLElement | undefined, + skipAnimationFrame: boolean +) { const viewportInfo = React.useRef(null) const calculateInfo = React.useCallback( @@ -36,7 +40,7 @@ export default function useWindowViewportRectRef(callback: (info: WindowViewport [callback, customScrollParent] ) - const { callbackRef, ref } = useSizeWithElRef(calculateInfo) + const { callbackRef, ref } = useSizeWithElRef(calculateInfo, true, skipAnimationFrame) const scrollAndResizeEventHandler = React.useCallback(() => { calculateInfo(ref.current)