diff --git a/docs/src/pages/docs/api.md b/docs/src/pages/docs/api.md index 6f4e834be..e8228cd7f 100644 --- a/docs/src/pages/docs/api.md +++ b/docs/src/pages/docs/api.md @@ -52,7 +52,10 @@ const { - Optional - This function, if passed, is responsible for implementing the scrollTo logic for the parentRef which is used when methods like `scrollToOffset` and `scrollToIndex` are called. - Eg. You can use this function to implement smooth scrolling by using the supplied offset and the `defaultScrollToFn` as seen in the sandbox's **Smooth Scroll** example. -- `useObserver: Function(parentRef) => ({ width: number; height: number })` +- `initialRect: Object({ width: number; height: number })` + - Optional + - Defines initial rect size of list. Can be used for server-side rendering +- `useObserver: Function(parentRef: React.useRef(DOMElement), initialRect?: { width: number; height: number }) => ({ width: number; height: number })` - Optional - This hook, if passed, is responsible for getting `parentRef`'s dimensions - Eg. You can use this hook to replace [@reach/observe-rect](https://github.com/reach/observe-rect) that `react-virtual` uses by default with [ResizeObserver API](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) diff --git a/src/index.js b/src/index.js index d53a71290..7576a3f01 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,4 @@ import React from 'react' - import useRect from './useRect' import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect' @@ -36,6 +35,7 @@ export function useVirtual({ horizontal, scrollToFn, useObserver, + initialRect, onScrollElement, scrollOffsetFn, keyExtractor = defaultKeyExtractor, @@ -44,15 +44,19 @@ export function useVirtual({ }) { const sizeKey = horizontal ? 'width' : 'height' const scrollKey = horizontal ? 'scrollLeft' : 'scrollTop' + const latestRef = React.useRef({ scrollOffset: 0, measurements: [], }) + + const [scrollOffset, setScrollOffset] = React.useState(0) + latestRef.current.scrollOffset = scrollOffset + const useMeasureParent = useObserver || useRect - const { [sizeKey]: outerSize } = useMeasureParent(parentRef) || { - [sizeKey]: 0, - } + const { [sizeKey]: outerSize } = useMeasureParent(parentRef, initialRect) + latestRef.current.outerSize = outerSize const defaultScrollToFn = React.useCallback( @@ -105,49 +109,26 @@ export function useVirtual({ latestRef.current.measurements = measurements latestRef.current.totalSize = totalSize - const [range, setRange] = React.useState({ start: 0, end: 0 }) - const element = onScrollElement ? onScrollElement.current : parentRef.current const scrollOffsetFnRef = React.useRef(scrollOffsetFn) scrollOffsetFnRef.current = scrollOffsetFn - const rangeTimeoutIdRef = React.useRef(null) - - const cancelAsyncRange = React.useCallback(() => { - if (rangeTimeoutIdRef.current !== null) { - clearTimeout(rangeTimeoutIdRef.current) - rangeTimeoutIdRef.current = null - } - }, []) - - useIsomorphicLayoutEffect(() => { - rangeTimeoutIdRef.current = setTimeout(() => { - setRange(prevRange => calculateRange(latestRef.current, prevRange)) - }) - return () => cancelAsyncRange() - }, [measurements, outerSize, cancelAsyncRange]) - useIsomorphicLayoutEffect(() => { if (!element) { - setRange({ start: 0, end: 0 }) - latestRef.current.scrollOffset = 0 + setScrollOffset(0) return } const onScroll = event => { - const scrollOffset = scrollOffsetFnRef.current + const offset = scrollOffsetFnRef.current ? scrollOffsetFnRef.current(event) : element[scrollKey] - latestRef.current.scrollOffset = scrollOffset - - cancelAsyncRange() - setRange(prevRange => calculateRange(latestRef.current, prevRange)) + setScrollOffset(offset) } - // Determine initially visible range onScroll() element.addEventListener('scroll', onScroll, { @@ -158,19 +139,25 @@ export function useVirtual({ return () => { element.removeEventListener('scroll', onScroll) } - }, [element, scrollKey, cancelAsyncRange]) + }, [element, scrollKey]) + + const { start, end } = calculateRange(latestRef.current) + + const indexes = React.useMemo( + () => + rangeExtractor({ + start, + end, + overscan, + size: measurements.length, + }), + [start, end, overscan, measurements.length, rangeExtractor] + ) const measureSizeRef = React.useRef(measureSize) measureSizeRef.current = measureSize const virtualItems = React.useMemo(() => { - const indexes = rangeExtractor({ - start: range.start, - end: range.end, - overscan, - size: measurements.length, - }) - const virtualItems = [] for (let k = 0, len = indexes.length; k < len; k++) { @@ -205,21 +192,13 @@ export function useVirtual({ } return virtualItems - }, [ - defaultScrollToFn, - horizontal, - measurements, - overscan, - range.end, - range.start, - rangeExtractor, - ]) - - const mountedRef = React.useRef() + }, [indexes, defaultScrollToFn, horizontal, measurements]) + + const mountedRef = React.useRef(false) useIsomorphicLayoutEffect(() => { if (mountedRef.current) { - if (estimateSize) setMeasuredCache({}) + setMeasuredCache({}) } mountedRef.current = true }, [estimateSize]) @@ -326,7 +305,7 @@ const findNearestBinarySearch = (low, high, getCurrentValue, value) => { } } -function calculateRange({ measurements, outerSize, scrollOffset }, prevRange) { +function calculateRange({ measurements, outerSize, scrollOffset }) { const size = measurements.length - 1 const getOffset = index => measurements[index].start @@ -337,9 +316,5 @@ function calculateRange({ measurements, outerSize, scrollOffset }, prevRange) { end++ } - if (prevRange.start !== start || prevRange.end !== end) { - return { start, end } - } - - return prevRange + return { start, end } } diff --git a/src/tests/index.test.js b/src/tests/index.test.js index c19586e8a..4d93c5754 100644 --- a/src/tests/index.test.js +++ b/src/tests/index.test.js @@ -78,7 +78,7 @@ describe('useVirtual list', () => { expect(screen.queryByText('Row 4')).toBeInTheDocument() expect(screen.queryByText('Row 5')).not.toBeInTheDocument() - expect(useVirtual).toHaveBeenCalledTimes(3) + expect(useVirtual).toHaveBeenCalledTimes(1) }) it('should render with overscan', () => { render() @@ -87,7 +87,7 @@ describe('useVirtual list', () => { expect(screen.queryByText('Row 3')).toBeInTheDocument() expect(screen.queryByText('Row 4')).not.toBeInTheDocument() - expect(useVirtual).toHaveBeenCalledTimes(3) + expect(useVirtual).toHaveBeenCalledTimes(1) }) it('should render given dynamic size', async () => { const onRef = virtualRow => el => { @@ -103,7 +103,7 @@ describe('useVirtual list', () => { await waitFor(() => expect(screen.queryByText('Row 8')).toBeInTheDocument()) expect(screen.queryByText('Row 9')).not.toBeInTheDocument() - expect(useVirtual).toHaveBeenCalledTimes(8) + expect(useVirtual).toHaveBeenCalledTimes(4) }) it('should render given dynamic size after scroll', async () => { const onRef = virtualRow => el => { diff --git a/src/useRect.js b/src/useRect.js index 1b6796083..113b1fa96 100644 --- a/src/useRect.js +++ b/src/useRect.js @@ -1,12 +1,13 @@ import React from 'react' - import observeRect from '@reach/observe-rect' - import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect' -export default function useRect(nodeRef) { +export default function useRect( + nodeRef, + initialRect = { width: 0, height: 0 } +) { const [element, setElement] = React.useState(nodeRef.current) - const [rect, dispatch] = React.useReducer(rectReducer, null) + const [rect, dispatch] = React.useReducer(rectReducer, initialRect) const initialRectSet = React.useRef(false) useIsomorphicLayoutEffect(() => { @@ -18,8 +19,8 @@ export default function useRect(nodeRef) { useIsomorphicLayoutEffect(() => { if (element && !initialRectSet.current) { initialRectSet.current = true - const rect = element.getBoundingClientRect(); - dispatch({ rect }); + const rect = element.getBoundingClientRect() + dispatch({ rect }) } }, [element]) @@ -29,8 +30,8 @@ export default function useRect(nodeRef) { } const observer = observeRect(element, rect => { - dispatch({ rect }); - }); + dispatch({ rect }) + }) observer.observe() @@ -44,9 +45,8 @@ export default function useRect(nodeRef) { function rectReducer(state, action) { const rect = action.rect - if (!state || state.height !== rect.height || state.width !== rect.width) { + if (state.height !== rect.height || state.width !== rect.width) { return rect } return state } - diff --git a/types/index.d.ts b/types/index.d.ts index 41b176d4a..14f41bc6f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -27,7 +27,12 @@ export interface Range { declare function defaultRangeExtractor(range: Range): number[] -declare function useVirtual(options: { +interface Rect { + width: number + height: number +} + +export interface Options { size: number parentRef: React.RefObject estimateSize?: (index: number) => number @@ -39,18 +44,17 @@ declare function useVirtual(options: { ) => void paddingStart?: number paddingEnd?: number - useObserver?: ( - ref: React.RefObject - ) => { - width: number - height: number - [key: string]: any - } + useObserver?: (ref: React.RefObject, initialRect?: Rect) => Rect + initialRect?: Rect keyExtractor?: (index: number) => Key onScrollElement?: React.RefObject scrollOffsetFn?: (event?: Event) => number rangeExtractor?: (range: Range) => number[] -}): { +} + +declare function useVirtual( + options: Options +): { virtualItems: VirtualItem[] totalSize: number scrollToOffset: (index: number, options?: ScrollToOffsetOptions) => void