diff --git a/change/@fluentui-contrib-react-virtualizer-4b0744ff-7ae5-455f-bddb-716b33cae941.json b/change/@fluentui-contrib-react-virtualizer-4b0744ff-7ae5-455f-bddb-716b33cae941.json new file mode 100644 index 00000000..98d9f5e9 --- /dev/null +++ b/change/@fluentui-contrib-react-virtualizer-4b0744ff-7ae5-455f-bddb-716b33cae941.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat(react-virtualizer): Add improved scrollTo with iterative corrections for dynamic lists", + "packageName": "@fluentui-contrib/react-virtualizer", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-virtualizer/src/components/VirtualizerScrollViewDynamic/VirtualizerScrollViewDynamic.types.ts b/packages/react-virtualizer/src/components/VirtualizerScrollViewDynamic/VirtualizerScrollViewDynamic.types.ts index b0455947..b76911f0 100644 --- a/packages/react-virtualizer/src/components/VirtualizerScrollViewDynamic/VirtualizerScrollViewDynamic.types.ts +++ b/packages/react-virtualizer/src/components/VirtualizerScrollViewDynamic/VirtualizerScrollViewDynamic.types.ts @@ -71,6 +71,17 @@ export type VirtualizerScrollViewDynamicProps = ComponentProps< * Enables custom scroll anchor behavior */ enableScrollAnchor?: boolean; + /** + * Enables improved scrollTo behavior with iterative corrections for dynamic content. + * When disabled (default), uses the legacy `scrollToItemDynamic` implementation. + * When enabled, uses `useScrollToItemDynamic` hook which provides: + * - Smooth scrolling with automatic position correction after animation completes + * - Iterative corrections to handle dynamically sized items that resize during render + * - Temporarily disables useMeasureList scroll compensation during scrollTo operations + * to prevent conflicting scroll adjustments + * @default false + */ + enableScrollToCorrections?: boolean; }; export type VirtualizerScrollViewDynamicState = diff --git a/packages/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamic.tsx b/packages/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamic.tsx index 7bfdb729..14a3e7c4 100644 --- a/packages/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamic.tsx +++ b/packages/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamic.tsx @@ -14,6 +14,7 @@ import type { VirtualizerDataRef } from '../Virtualizer/Virtualizer.types'; import { useMeasureList } from '../../hooks/useMeasureList'; import type { IndexedResizeCallbackElement } from '../../hooks/useMeasureList'; import { useDynamicVirtualizerPagination } from '../../hooks/useDynamicPagination'; +import { useScrollToItemDynamic } from '../../hooks/useScrollToItemDynamic'; export function useVirtualizerScrollViewDynamic_unstable( props: VirtualizerScrollViewDynamicProps @@ -32,6 +33,7 @@ export function useVirtualizerScrollViewDynamic_unstable( bufferItems: _bufferItems, bufferSize: _bufferSize, enableScrollAnchor, + enableScrollToCorrections = false, gap = 0, } = props; @@ -102,73 +104,39 @@ export function useVirtualizerScrollViewDynamic_unstable( const scrollCallbackRef = React.useRef void)>( null ); + const handleMeasuredCallbackRef = React.useRef< + ((index: number, size: number, delta: number) => void) | null + >(null); + const handleRenderedCallbackRef = React.useRef< + ((index: number) => boolean) | null + >(null); + const measuredIndexSetRef = React.useRef>(new Set()); - React.useImperativeHandle( - imperativeRef, - () => { - return { - scrollToPosition( - position: number, - behavior: ScrollBehavior = 'auto', - index?: number, // So we can callback when index rendered - callback?: (index: number) => void - ) { - if (callback) { - scrollCallbackRef.current = callback ?? null; - } - - if (_imperativeVirtualizerRef.current) { - if (index !== undefined) { - _imperativeVirtualizerRef.current.setFlaggedIndex(index); - } - const positionOptions = - axis == 'vertical' ? { top: position } : { left: position }; - scrollViewRef.current?.scrollTo({ - behavior, - ...positionOptions, - }); - } - }, - scrollTo( - index: number, - behavior = 'auto', - callback: undefined | ((index: number) => void) - ) { - scrollCallbackRef.current = callback ?? null; - if (_imperativeVirtualizerRef.current) { - const progressiveSizes = - _imperativeVirtualizerRef.current.progressiveSizes.current; - const totalSize = - progressiveSizes && progressiveSizes?.length > 0 - ? progressiveSizes[Math.max(progressiveSizes.length - 1, 0)] - : 0; - - _imperativeVirtualizerRef.current.setFlaggedIndex(index); - scrollToItemDynamic({ - index, - getItemSize: props.getItemSize ?? getChildSizeAuto, - totalSize, - scrollViewRef: scrollViewRef as React.RefObject, - axis, - reversed, - behavior, - gap, - }); - } - }, - currentIndex: _imperativeVirtualizerRef.current?.currentIndex, - virtualizerLength: virtualizerLengthRef, - sizeTrackingArray, - }; + const handleItemMeasured = React.useCallback( + (index: number, size: number, delta: number) => { + measuredIndexSetRef.current.add(index); + handleMeasuredCallbackRef.current?.(index, size, delta); }, - [axis, scrollViewRef, reversed, _imperativeVirtualizerRef] + [] ); - const handleRenderedIndex = (index: number) => { - if (scrollCallbackRef.current) { - scrollCallbackRef.current(index); + const resolveScrollCallback = React.useCallback((index: number) => { + const callback = scrollCallbackRef.current; + if (callback) { + scrollCallbackRef.current = null; + callback(index); } - }; + }, []); + + const handleRenderedIndex = React.useCallback( + (index: number) => { + const handled = handleRenderedCallbackRef.current?.(index) ?? false; + if (!handled) { + resolveScrollCallback(index); + } + }, + [resolveScrollCallback] + ); const virtualizerState = useVirtualizer_unstable({ ...props, @@ -184,8 +152,18 @@ export function useVirtualizerScrollViewDynamic_unstable( updateScrollPosition, }); + // Track whether a scrollTo operation is active to disable scroll compensation + // Only used when enableScrollToCorrections is true + const isScrollToActiveRef = React.useRef(false); + const requestScrollBy = React.useCallback( (sizeChange: number) => { + // Skip scroll compensation when using enableScrollToCorrections and scrollTo is active + // The new scroll controller handles its own compensation + if (enableScrollToCorrections && isScrollToActiveRef.current) { + return; + } + // Handle any size changes so that scroll view doesn't jump around if (enableScrollAnchor) { localScrollRef.current?.scrollBy({ @@ -195,7 +173,7 @@ export function useVirtualizerScrollViewDynamic_unstable( }); } }, - [enableScrollAnchor, axis, localScrollRef] + [enableScrollAnchor, enableScrollToCorrections, axis, localScrollRef] ); const measureObject = useMeasureList({ @@ -206,8 +184,242 @@ export function useVirtualizerScrollViewDynamic_unstable( axis, virtualizerLength, requestScrollBy, + onItemMeasured: handleItemMeasured, + }); + + React.useEffect(() => { + const measuredSet = measuredIndexSetRef.current; + measuredSet.forEach((value) => { + if (value >= props.numItems) { + measuredSet.delete(value); + } + }); + }, [props.numItems]); + + const setFlaggedIndex = React.useCallback( + (flaggedIndex: number | null) => { + _imperativeVirtualizerRef.current?.setFlaggedIndex(flaggedIndex); + }, + [_imperativeVirtualizerRef] + ); + + const getTotalSize = React.useCallback(() => { + const progressiveSizes = + _imperativeVirtualizerRef.current?.progressiveSizes.current; + if (progressiveSizes && progressiveSizes.length > 0) { + return progressiveSizes[Math.max(progressiveSizes.length - 1, 0)]; + } + return 0; + }, [_imperativeVirtualizerRef]); + + const getOffsetForIndex = React.useCallback( + (index: number) => { + if (index <= 0) { + return 0; + } + + const progressiveSizes = + _imperativeVirtualizerRef.current?.progressiveSizes.current; + + if ( + progressiveSizes && + progressiveSizes.length > 0 && + index - 1 < progressiveSizes.length + ) { + const value = progressiveSizes[index - 1]; + if (Number.isFinite(value)) { + return value; + } + } + + let total = 0; + const limit = Math.min(index, sizeTrackingArray.current.length); + for (let i = 0; i < limit; i++) { + const size = + sizeTrackingArray.current[i] > 0 + ? sizeTrackingArray.current[i] + : props.itemSize; + total += size; + if (gap && i < index - 1) { + total += gap; + } + } + return total; + }, + [_imperativeVirtualizerRef, gap, props.itemSize, sizeTrackingArray] + ); + + const getScrollItemSize = React.useCallback( + (index: number) => { + if (measuredIndexSetRef.current.has(index)) { + return sizeTrackingArray.current[index] ?? props.itemSize; + } + + if (props.getItemSize) { + return props.getItemSize(index); + } + + return getChildSizeAuto(index); + }, + [props.getItemSize, props.itemSize, getChildSizeAuto] + ); + + // ----- New scroll-to corrections implementation (opt-in via enableScrollToCorrections) ----- + const handleOperationComplete = React.useCallback( + (index: number) => { + // Re-enable useMeasureList scroll compensation now that the operation is complete + isScrollToActiveRef.current = false; + resolveScrollCallback(index); + }, + [resolveScrollCallback] + ); + + const handleOperationCancel = React.useCallback( + (_index: number, _reason: 'user' | 'cancelled') => { + void _index; + void _reason; + isScrollToActiveRef.current = false; + scrollCallbackRef.current = null; + }, + [] + ); + + const { + start: startScrollToWithCorrections, + handleItemMeasured: controllerHandleItemMeasured, + handleRendered: controllerHandleRendered, + cancel: cancelScrollToOperation, + } = useScrollToItemDynamic({ + axis, + reversed, + gap, + scrollViewRef: localScrollRef, + getItemSize: getScrollItemSize, + getTotalSize, + getOffsetForIndex, + measureRefObject: measureObject.refObject, + setFlaggedIndex, + onOperationComplete: handleOperationComplete, + onOperationCancel: handleOperationCancel, }); + // Only wire up the controller callbacks when using the new implementation + React.useEffect(() => { + if (enableScrollToCorrections) { + handleMeasuredCallbackRef.current = controllerHandleItemMeasured; + handleRenderedCallbackRef.current = controllerHandleRendered; + return () => { + if ( + handleMeasuredCallbackRef.current === controllerHandleItemMeasured + ) { + handleMeasuredCallbackRef.current = null; + } + if (handleRenderedCallbackRef.current === controllerHandleRendered) { + handleRenderedCallbackRef.current = null; + } + }; + } + return undefined; + }, [ + enableScrollToCorrections, + controllerHandleItemMeasured, + controllerHandleRendered, + ]); + + // ----- Legacy scroll-to implementation (default) ----- + const legacyScrollTo = React.useCallback( + ( + index: number, + behavior: ScrollBehavior = 'auto', + callback?: (index: number) => void + ) => { + if (callback) { + scrollCallbackRef.current = callback; + } + setFlaggedIndex(index); + scrollToItemDynamic({ + index, + scrollViewRef: localScrollRef, + axis, + reversed, + behavior, + getItemSize: getScrollItemSize, + totalSize: getTotalSize(), + gap, + }); + }, + [ + axis, + reversed, + gap, + getScrollItemSize, + getTotalSize, + setFlaggedIndex, + localScrollRef, + ] + ); + + React.useImperativeHandle( + imperativeRef, + () => ({ + scrollToPosition( + position: number, + behavior: ScrollBehavior = 'auto', + index?: number, + callback?: (index: number) => void + ) { + if (callback) { + scrollCallbackRef.current = callback ?? null; + } + + if (enableScrollToCorrections) { + cancelScrollToOperation(); + } + + if (_imperativeVirtualizerRef.current) { + if (index !== undefined) { + setFlaggedIndex(index); + } + const positionOptions = + axis === 'vertical' ? { top: position } : { left: position }; + scrollViewRef.current?.scrollTo({ + behavior, + ...positionOptions, + }); + } + }, + scrollTo( + index: number, + behavior: ScrollBehavior = 'auto', + callback?: (index: number) => void + ) { + if (enableScrollToCorrections) { + // Use new implementation with iterative corrections + scrollCallbackRef.current = callback ?? null; + isScrollToActiveRef.current = true; + startScrollToWithCorrections(index, behavior, callback); + } else { + // Use legacy implementation + legacyScrollTo(index, behavior, callback); + } + }, + currentIndex: _imperativeVirtualizerRef.current?.currentIndex, + virtualizerLength: virtualizerLengthRef, + sizeTrackingArray, + }), + [ + axis, + scrollViewRef, + _imperativeVirtualizerRef, + startScrollToWithCorrections, + cancelScrollToOperation, + setFlaggedIndex, + reversed, + enableScrollToCorrections, + legacyScrollTo, + ] + ); + // Enables auto-measuring and tracking post render sizes externally React.Children.map(virtualizerState.virtualizedChildren, (child, index) => { if (React.isValidElement(child)) { diff --git a/packages/react-virtualizer/src/hooks/useMeasureList.ts b/packages/react-virtualizer/src/hooks/useMeasureList.ts index 0501edae..811e453a 100644 --- a/packages/react-virtualizer/src/hooks/useMeasureList.ts +++ b/packages/react-virtualizer/src/hooks/useMeasureList.ts @@ -25,6 +25,13 @@ export function useMeasureList< sizeTrackingArray: React.MutableRefObject; axis: 'horizontal' | 'vertical'; requestScrollBy?: (sizeChange: number) => void; + /** + * Callback invoked when an item's size is measured or changes. + * @param index - The index of the measured item + * @param size - The new measured size + * @param delta - The size difference from previous measurement + */ + onItemMeasured?: (index: number, size: number, delta: number) => void; }): { createIndexedRef: (index: number) => (el: TElement) => void; refObject: React.MutableRefObject<{ @@ -39,6 +46,7 @@ export function useMeasureList< axis, requestScrollBy, virtualizerLength, + onItemMeasured, } = measureParams; const { targetDocument } = useFluent(); @@ -62,6 +70,8 @@ export function useMeasureList< : boundClientRect?.width) ?? defaultItemSize; const sizeDifference = containerSize - sizeTrackingArray.current[index]; + onItemMeasured?.(index, containerSize, sizeDifference); + // Todo: Handle reverse setup // This requests a scrollBy to offset the new change if (sizeDifference !== 0) { @@ -76,7 +86,7 @@ export function useMeasureList< // Update size tracking array which gets exposed if teams need it sizeTrackingArray.current[index] = containerSize; }, - [defaultItemSize, requestScrollBy, axis, sizeTrackingArray] + [defaultItemSize, requestScrollBy, axis, sizeTrackingArray, onItemMeasured] ); const handleElementResizeCallback = (entries: ResizeObserverEntry[]) => { diff --git a/packages/react-virtualizer/src/hooks/useScrollToItemDynamic.ts b/packages/react-virtualizer/src/hooks/useScrollToItemDynamic.ts new file mode 100644 index 00000000..deb85a6f --- /dev/null +++ b/packages/react-virtualizer/src/hooks/useScrollToItemDynamic.ts @@ -0,0 +1,767 @@ +import * as React from 'react'; +import { useTimeout, useAnimationFrame } from '@fluentui/react-utilities'; + +import { scrollToItemDynamic } from '../Utilities'; +import type { ScrollToItemDynamicParams } from '../Utilities'; + +/** + * Reference object for measured elements, keyed by index string. + */ +type MeasureRefObject = React.MutableRefObject<{ + [key: string]: (HTMLElement & { handleResize?: () => void }) | null; +}>; + +/** + * Parameters for configuring the useScrollToItemDynamic hook. + */ +type UseScrollToItemDynamicParams = { + /** Scroll axis direction */ + axis: 'horizontal' | 'vertical'; + /** Whether the scroll direction is reversed */ + reversed?: boolean; + /** Gap between items in pixels */ + gap: number; + /** Reference to the scrollable container element */ + scrollViewRef: React.RefObject; + /** Function to get the size of an item at a given index */ + getItemSize: (index: number) => number; + /** Function to get the total size of all items */ + getTotalSize: () => number; + /** Optional function to get the offset for a specific index */ + getOffsetForIndex?: (index: number) => number | null | undefined; + /** Reference object containing measured elements */ + measureRefObject: MeasureRefObject; + /** Maximum number of correction attempts */ + maxCorrections?: number; + /** Timeout in milliseconds between stability checks */ + stabilityTimeout?: number; + /** Callback to set a flagged index for rendering */ + setFlaggedIndex?: (index: number | null) => void; + /** Callback invoked when the scroll operation completes successfully */ + onOperationComplete?: (index: number) => void; + /** Callback invoked when the scroll operation is cancelled */ + onOperationCancel?: (index: number, reason: 'user' | 'cancelled') => void; +}; + +/** + * Return type for the useScrollToItemDynamic hook. + */ +type UseScrollToItemDynamicReturn = { + /** Initiates a scroll operation to the specified index */ + start: ( + index: number, + behavior: ScrollBehavior, + callback?: (index: number) => void + ) => void; + /** Handles when an item's size is measured or changes */ + handleItemMeasured: (index: number, size: number, delta: number) => void; + /** Handles when the target item is rendered in the DOM */ + handleRendered: (index: number) => boolean; + /** Cancels the current scroll operation */ + cancel: () => void; + /** Returns true if a scroll operation is currently active */ + isActive: () => boolean; +}; + +/** Handle for timeout operations */ +type TimeoutHandle = number; + +/** + * Internal state for an active scroll-to operation. + */ +type ScrollOperation = { + /** Unique identifier for this operation */ + id: number; + /** Target item index to scroll to */ + targetIndex: number; + /** Scroll behavior (smooth, instant, etc.) */ + behavior: ScrollBehavior; + /** Optional callback to invoke when operation completes */ + callback?: (index: number) => void; + /** Number of correction attempts remaining */ + correctionsRemaining: number; + /** Whether a correction is currently pending */ + pendingCorrection: boolean; + /** Timeout ID for stability checks */ + stabilityTimeoutId: TimeoutHandle | null; + /** Animation frame ID for scheduled corrections */ + scheduleFrameId: number | null; + /** Current status of the operation */ + status: 'initial' | 'correcting' | 'stable'; + /** Whether the current scroll is programmatic (not user-initiated) */ + isProgrammaticScroll: boolean; + /** Timestamp of the last measurement */ + lastMeasurementTimestamp: number; + /** Whether the target item has been measured */ + hasMeasuredTarget: boolean; + /** Number of stable iterations remaining before finalization */ + stableIterations: number; + /** Whether initial alignment has been performed */ + initialAlignmentPerformed: boolean; + /** Whether we're waiting for smooth scroll animation to complete */ + awaitingSmoothScroll: boolean; +}; + +/** Default maximum number of correction attempts */ +const DEFAULT_MAX_CORRECTIONS = 10; +/** Default timeout in milliseconds between stability checks */ +const DEFAULT_STABILITY_TIMEOUT = 150; +/** Pixel tolerance for viewport alignment */ +const VIEWPORT_TOLERANCE = 1; +/** Number of stable iterations required before finalization */ +const DEFAULT_CORRECTION_SETTLE = 2; +/** Delay to wait for smooth scroll animation to complete before corrections */ +const SMOOTH_SCROLL_DELAY = 500; + +/** + * Hook for managing scroll-to operations in dynamic virtualized lists. + * Handles iterative corrections and stability checks when items have + * dynamic heights that change after rendering. + * + * @returns Controller with methods to start, handle measurements, and cancel scroll operations + */ +export function useScrollToItemDynamic({ + axis, + reversed, + gap, + scrollViewRef, + getItemSize, + getTotalSize, + getOffsetForIndex, + measureRefObject, + maxCorrections = DEFAULT_MAX_CORRECTIONS, + stabilityTimeout = DEFAULT_STABILITY_TIMEOUT, + setFlaggedIndex, + onOperationComplete, + onOperationCancel, +}: UseScrollToItemDynamicParams): UseScrollToItemDynamicReturn { + const operationRef = React.useRef(null); + const scrollListenerRef = React.useRef<((event: Event) => void) | null>(null); + const operationIdRef = React.useRef(0); + const scheduleCorrectionRef = React.useRef< + ((operation: ScrollOperation) => void) | null + >(null); + const scheduleStabilityCheckRef = React.useRef< + ((operation: ScrollOperation) => void) | null + >(null); + + const scheduleCorrection = React.useCallback((operation: ScrollOperation) => { + scheduleCorrectionRef.current?.(operation); + }, []); + + const scheduleStabilityCheck = React.useCallback( + (operation: ScrollOperation) => { + scheduleStabilityCheckRef.current?.(operation); + }, + [] + ); + + const [setStabilityTimeout, clearStabilityTimeoutFn] = useTimeout(); + const [requestProgrammaticFrame] = useAnimationFrame(); + const [requestCorrectionFrame, cancelCorrectionFrame] = useAnimationFrame(); + + const clearStabilityTimeout = React.useCallback( + (operation: ScrollOperation) => { + if (operation.stabilityTimeoutId === null) { + return; + } + + clearStabilityTimeoutFn(); + operation.stabilityTimeoutId = null; + }, + [clearStabilityTimeoutFn] + ); + + const cancelScheduledFrame = React.useCallback( + (operation: ScrollOperation) => { + if (operation.scheduleFrameId === null) { + return; + } + + cancelCorrectionFrame(); + operation.scheduleFrameId = null; + }, + [cancelCorrectionFrame] + ); + + const detachScrollListener = React.useCallback(() => { + const listener = scrollListenerRef.current; + const scrollView = scrollViewRef.current; + + if (listener && scrollView) { + scrollView.removeEventListener('scroll', listener); + } + scrollListenerRef.current = null; + }, [scrollViewRef]); + + /** + * Clears the current operation, cancelling all timers and cleaning up state. + */ + const clearOperation = React.useCallback( + (reason?: 'user' | 'cancelled') => { + const active = operationRef.current; + if (!active) { + return; + } + + clearStabilityTimeout(active); + cancelScheduledFrame(active); + + detachScrollListener(); + operationRef.current = null; + setFlaggedIndex?.(null); + + if (reason && onOperationCancel) { + onOperationCancel(active.targetIndex, reason); + } + }, + [ + cancelScheduledFrame, + clearStabilityTimeout, + detachScrollListener, + onOperationCancel, + setFlaggedIndex, + ] + ); + + /** + * Gets the DOM element for a given index from the measure ref object. + */ + const getTargetElement = React.useCallback( + (index: number) => { + const element = measureRefObject.current[index.toString()]; + return element ?? null; + }, + [measureRefObject] + ); + + const ensureScrollListener = React.useCallback(() => { + if (scrollListenerRef.current || !scrollViewRef.current) { + return; + } + + const listener = () => { + const active = operationRef.current; + if (!active) { + return; + } + + // Don't cancel during programmatic scroll or smooth scroll animation + if (active.isProgrammaticScroll || active.awaitingSmoothScroll) { + return; + } + + // User-initiated scroll cancels the operation (including pinning) + clearOperation('user'); + }; + + scrollViewRef.current.addEventListener('scroll', listener, { + passive: true, + }); + scrollListenerRef.current = listener; + }, [clearOperation, scrollViewRef]); + + /** + * Evaluates whether the target element is properly aligned within the viewport. + * Returns alignment status, deltas, and overflow information. + */ + const evaluateTargetAlignment = React.useCallback( + (index: number) => { + const scrollView = scrollViewRef.current; + const element = getTargetElement(index); + + if (!scrollView || !element) { + return { + elementExists: Boolean(element), + aligned: false, + startDelta: 0, + endOverflow: 0, + }; + } + + const elementRect = element.getBoundingClientRect(); + const containerRect = scrollView.getBoundingClientRect(); + + const elementStart = + axis === 'vertical' ? elementRect.top : elementRect.left; + const containerStart = + axis === 'vertical' ? containerRect.top : containerRect.left; + const elementEnd = + axis === 'vertical' ? elementRect.bottom : elementRect.right; + const containerEnd = + axis === 'vertical' ? containerRect.bottom : containerRect.right; + + const startDelta = elementStart - containerStart; + const endOverflow = Math.max(0, elementEnd - containerEnd); + + const aligned = + Math.abs(startDelta) <= VIEWPORT_TOLERANCE && + endOverflow <= VIEWPORT_TOLERANCE; + + return { + elementExists: true, + aligned, + startDelta, + endOverflow, + }; + }, + [axis, getTargetElement, scrollViewRef] + ); + + const scheduleProgrammaticScrollReset = React.useCallback( + (operation: ScrollOperation) => { + requestProgrammaticFrame(() => { + const active = operationRef.current; + if (!active || active.id !== operation.id) { + return; + } + + active.isProgrammaticScroll = false; + }); + }, + [operationRef, requestProgrammaticFrame] + ); + + /** + * Applies an instant scroll adjustment by the specified delta. + * Uses direct property manipulation for synchronous behavior to avoid + * timing issues with browser reflow. + */ + const applyScrollByDelta = React.useCallback( + (operation: ScrollOperation, delta: number) => { + const scrollView = scrollViewRef.current; + if (!scrollView || delta === 0) { + return; + } + + const adjustedDelta = reversed ? -delta : delta; + operation.isProgrammaticScroll = true; + + scrollView.scrollBy({ + [axis === 'vertical' ? 'top' : 'left']: adjustedDelta, + behavior: 'instant', + }); + + scheduleProgrammaticScrollReset(operation); + }, + [axis, reversed, scheduleProgrammaticScrollReset, scrollViewRef] + ); + + /** + * Performs the actual scroll operation to the target index. + * Uses optimized offset calculation when available, otherwise falls back to scrollToItemDynamic. + */ + const performScroll = React.useCallback( + (operation: ScrollOperation, behavior: ScrollBehavior) => { + if (!reversed && axis === 'vertical' && getOffsetForIndex) { + const offset = getOffsetForIndex(operation.targetIndex); + if (offset !== undefined && offset !== null && isFinite(offset)) { + const scrollView = scrollViewRef.current; + if (scrollView) { + operation.isProgrammaticScroll = true; + scrollView.scrollTo({ + top: offset, + behavior, + }); + scheduleProgrammaticScrollReset(operation); + return; + } + } + } + + const params: ScrollToItemDynamicParams = { + index: operation.targetIndex, + getItemSize, + totalSize: getTotalSize(), + scrollViewRef, + axis, + reversed, + behavior, + gap, + }; + + scrollToItemDynamic(params); + }, + [ + axis, + gap, + getItemSize, + getTotalSize, + reversed, + scrollViewRef, + getOffsetForIndex, + scheduleProgrammaticScrollReset, + ] + ); + + /** + * Finalizes a scroll operation, marking it as stable. + * Invokes completion callbacks and cleans up timers. + */ + const finalizeOperation = React.useCallback( + (operation: ScrollOperation) => { + if (operation.status === 'stable') { + return; + } + operation.status = 'stable'; + clearStabilityTimeout(operation); + cancelScheduledFrame(operation); + if (operation.callback) { + operation.callback(operation.targetIndex); + } + + onOperationComplete?.(operation.targetIndex); + setFlaggedIndex?.(null); + operationRef.current = null; + detachScrollListener(); + }, + [ + cancelScheduledFrame, + clearStabilityTimeout, + detachScrollListener, + onOperationComplete, + setFlaggedIndex, + ] + ); + + scheduleStabilityCheckRef.current = (operation: ScrollOperation) => { + if (!operation) { + return; + } + + clearStabilityTimeout(operation); + + const runCheck = () => { + const active = operationRef.current; + if (!active || active.id !== operation.id) { + return; + } + + active.stabilityTimeoutId = null; + + // After smooth scroll animation completes, apply instant correction + const wasAwaitingSmoothScroll = active.awaitingSmoothScroll; + active.awaitingSmoothScroll = false; + + if (wasAwaitingSmoothScroll) { + // Force an instant correction to fix position after smooth scroll + performScroll(active, 'instant'); + active.correctionsRemaining -= 1; + active.stableIterations = DEFAULT_CORRECTION_SETTLE; + scheduleStabilityCheck(active); + return; + } + + const alignment = evaluateTargetAlignment(active.targetIndex); + + if (!alignment.elementExists || !active.hasMeasuredTarget) { + if (active.correctionsRemaining > 0) { + scheduleCorrection(active); + } + scheduleStabilityCheck(active); + return; + } + + if (!alignment.aligned) { + let correctionApplied = false; + + if (Math.abs(alignment.startDelta) > VIEWPORT_TOLERANCE) { + applyScrollByDelta(active, alignment.startDelta); + correctionApplied = true; + } else if (alignment.endOverflow > VIEWPORT_TOLERANCE) { + applyScrollByDelta(active, alignment.endOverflow); + correctionApplied = true; + } + + if (!correctionApplied && active.correctionsRemaining > 0) { + performScroll(active, 'instant'); + correctionApplied = true; + active.correctionsRemaining -= 1; + } else if (correctionApplied && active.correctionsRemaining > 0) { + active.correctionsRemaining -= 1; + } + + if (correctionApplied) { + active.stableIterations = DEFAULT_CORRECTION_SETTLE; + scheduleStabilityCheck(active); + return; + } + + if (active.correctionsRemaining <= 0) { + finalizeOperation(active); + return; + } + + scheduleStabilityCheck(active); + return; + } + + if (active.pendingCorrection) { + scheduleStabilityCheck(active); + return; + } + + if (active.stableIterations > 0) { + active.stableIterations -= 1; + scheduleStabilityCheck(active); + return; + } + + finalizeOperation(active); + }; + + // Use longer delay for smooth scroll to let animation complete + const delay = operation.awaitingSmoothScroll + ? SMOOTH_SCROLL_DELAY + : stabilityTimeout; + + // Mark as having a pending timeout (use 1 as a sentinel value since useTimeout + // doesn't return actual timeout IDs) + operation.stabilityTimeoutId = 1; + setStabilityTimeout(runCheck, delay); + }; + + scheduleCorrectionRef.current = (operation: ScrollOperation) => { + if (!operation) { + return; + } + + if (operation.correctionsRemaining <= 0 || operation.pendingCorrection) { + return; + } + + operation.pendingCorrection = true; + + const executeCorrection = () => { + const active = operationRef.current; + if (!active || active.id !== operation.id) { + return; + } + + let correctionApplied = false; + const alignment = evaluateTargetAlignment(active.targetIndex); + + if (alignment.elementExists) { + if (Math.abs(alignment.startDelta) > VIEWPORT_TOLERANCE) { + applyScrollByDelta(active, alignment.startDelta); + correctionApplied = true; + } else if (alignment.endOverflow > VIEWPORT_TOLERANCE) { + applyScrollByDelta(active, alignment.endOverflow); + correctionApplied = true; + } + } + + if (!correctionApplied) { + performScroll(active, 'instant'); + correctionApplied = true; + } + + if (correctionApplied) { + active.correctionsRemaining -= 1; + } + active.pendingCorrection = false; + active.scheduleFrameId = null; + + if (alignment.elementExists && alignment.aligned) { + finalizeOperation(active); + return; + } + + if (active.correctionsRemaining > 0) { + scheduleCorrection(active); + return; + } + + scheduleStabilityCheck(active); + }; + + // Use requestAnimationFrame via useAnimationFrame hook (SSR-safe) + operation.scheduleFrameId = requestCorrectionFrame(() => { + executeCorrection(); + }); + }; + + /** + * Handles when an item's size is measured or changes. + * Triggers corrections and stability checks as needed. + */ + const handleItemMeasured = React.useCallback( + (index: number, _size: number, delta: number) => { + const active = operationRef.current; + if (!active) { + return; + } + + // Ignore measurements after operation is stable + if (active.status === 'stable') { + return; + } + + // Ignore measurements for items after the target + if (index > active.targetIndex) { + return; + } + + active.lastMeasurementTimestamp = Date.now(); + + if (index < active.targetIndex && delta !== 0) { + // For smooth scrolling, skip compensations while animation is in progress + // The stability check will handle corrections after the animation completes + if (active.awaitingSmoothScroll) { + return; + } + + // Schedule a correction when items before target change size + if (active.correctionsRemaining > 0) { + scheduleCorrection(active); + } + active.stableIterations = DEFAULT_CORRECTION_SETTLE; + return; + } + + if (index === active.targetIndex) { + active.hasMeasuredTarget = true; + if (!active.initialAlignmentPerformed) { + // Use the original scroll behavior for initial alignment + // This preserves smooth scrolling if the user requested it + performScroll(active, active.behavior); + active.initialAlignmentPerformed = true; + } + // For smooth scrolling, don't immediately schedule corrections + // while animation is in progress + if ( + !active.awaitingSmoothScroll && + Math.abs(delta) >= VIEWPORT_TOLERANCE && + active.correctionsRemaining > 0 + ) { + scheduleCorrection(active); + active.stableIterations = DEFAULT_CORRECTION_SETTLE; + } + return; + } + + // For items not matching any condition above (delta=0, not target), + // don't do anything - let existing stability check complete + }, + [ + applyScrollByDelta, + scheduleCorrection, + evaluateTargetAlignment, + scheduleStabilityCheck, + performScroll, + ] + ); + + /** + * Handles when the target item is rendered in the DOM. + * Schedules corrections and stability checks to ensure proper alignment. + */ + const handleRendered = React.useCallback( + (index: number) => { + const active = operationRef.current; + if (!active || index !== active.targetIndex) { + return false; + } + + if (active.status === 'stable') { + return true; + } + + scheduleCorrection(active); + scheduleStabilityCheck(active); + return true; + }, + [scheduleCorrection, scheduleStabilityCheck] + ); + + /** + * Initiates a scroll operation to the specified index. + * Cancels any existing operation and starts a new one with corrections and stability checks. + */ + const start = React.useCallback( + ( + index: number, + behavior: ScrollBehavior, + callback?: (index: number) => void + ) => { + cancelScheduledFrame( + operationRef.current ?? + ({ + scheduleFrameId: null, + } as ScrollOperation) + ); + clearOperation(); + + const operationId = ++operationIdRef.current; + const operation: ScrollOperation = { + id: operationId, + targetIndex: index, + behavior, + callback, + correctionsRemaining: maxCorrections, + pendingCorrection: false, + stabilityTimeoutId: null, + scheduleFrameId: null, + status: 'initial', + isProgrammaticScroll: false, + stableIterations: DEFAULT_CORRECTION_SETTLE, + lastMeasurementTimestamp: Date.now(), + hasMeasuredTarget: false, + initialAlignmentPerformed: false, + awaitingSmoothScroll: behavior === 'smooth', + }; + + operationRef.current = operation; + setFlaggedIndex?.(index); + ensureScrollListener(); + + performScroll(operation, behavior); + // For smooth scrolling, mark initial alignment as done and don't schedule immediate correction + // Let the smooth animation play out, then stability checks will trigger corrections if needed + if (behavior === 'smooth') { + operation.initialAlignmentPerformed = true; + } else { + scheduleCorrection(operation); + } + scheduleStabilityCheck(operation); + }, + [ + cancelScheduledFrame, + clearOperation, + ensureScrollListener, + maxCorrections, + scheduleCorrection, + performScroll, + scheduleStabilityCheck, + setFlaggedIndex, + ] + ); + + /** + * Cancels the current scroll operation and cleans up all timers and listeners. + */ + const cancel = React.useCallback(() => { + clearOperation('cancelled'); + }, [clearOperation]); + + /** + * Returns true if a scroll operation is currently active (not finalized). + */ + const isActive = React.useCallback(() => { + return operationRef.current !== null; + }, []); + + React.useEffect(() => { + return () => { + clearOperation(); + }; + }, [clearOperation]); + + return { + start, + handleItemMeasured, + handleRendered, + cancel, + isActive, + }; +} diff --git a/packages/react-virtualizer/stories/VirtualizerScrollViewDynamic/ScrollTo.stories.tsx b/packages/react-virtualizer/stories/VirtualizerScrollViewDynamic/ScrollTo.stories.tsx index 13fba5a6..cb80f384 100644 --- a/packages/react-virtualizer/stories/VirtualizerScrollViewDynamic/ScrollTo.stories.tsx +++ b/packages/react-virtualizer/stories/VirtualizerScrollViewDynamic/ScrollTo.stories.tsx @@ -76,6 +76,7 @@ export const ScrollTo = (): JSXElement => { getItemSize={getItemSizeCallback} imperativeRef={scrollRef} imperativeVirtualizerRef={sizeRef} + enableScrollToCorrections container={{ role: 'list', 'aria-label': `Virtualized list with ${childLength} children`,