From 24857c4c816178dda162f3b9f998b09a9e95ded2 Mon Sep 17 00:00:00 2001 From: Victor Hallberg Date: Thu, 13 Aug 2020 19:57:30 +0200 Subject: [PATCH 1/5] perf: don't update on scroll unless virtualized range changes Also remove the unnecessary `reversedMeasurements` array and `scrollOffsetPlusOuterSize` variable. --- src/index.js | 128 +++++++++++++++++++++++++-------------------------- 1 file changed, 63 insertions(+), 65 deletions(-) diff --git a/src/index.js b/src/index.js index 7e480f7a..0726f40e 100644 --- a/src/index.js +++ b/src/index.js @@ -18,23 +18,15 @@ export function useVirtual({ }) { const sizeKey = horizontal ? 'width' : 'height' const scrollKey = horizontal ? 'scrollLeft' : 'scrollTop' + const latestRef = React.useRef({}) const { [sizeKey]: outerSize } = useRect(parentRef) || { [sizeKey]: 0, } - const [scrollOffset, _setScrollOffset] = React.useState(0) - - const scrollOffsetPlusOuterSize = scrollOffset + outerSize - - useScroll(parentRef, ({ [scrollKey]: newScrollOffset }) => { - _setScrollOffset(newScrollOffset) - }) - const defaultScrollToFn = React.useCallback( offset => { if (parentRef.current) { - _setScrollOffset(offset) parentRef.current[scrollKey] = offset } }, @@ -52,69 +44,56 @@ export function useVirtual({ const [measuredCache, setMeasuredCache] = React.useState({}) - const { measurements, reversedMeasurements } = React.useMemo(() => { + const measurements = React.useMemo(() => { const measurements = [] - const reversedMeasurements = [] - - for (let i = 0, j = size - 1; i < size; i++, j--) { + for (let i = 0; i < size; i++) { const measuredSize = measuredCache[i] const start = measurements[i - 1] ? measurements[i - 1].end : paddingStart const size = typeof measuredSize === 'number' ? measuredSize : estimateSize(i) const end = start + size - const bounds = { index: i, start, size, end } - measurements[i] = { - ...bounds, - } - reversedMeasurements[j] = { - ...bounds, - } + measurements[i] = { index: i, start, size, end } } - return { measurements, reversedMeasurements } + return measurements }, [estimateSize, measuredCache, paddingStart, size]) const totalSize = (measurements[size - 1]?.end || 0) + paddingEnd - let start = React.useMemo( - () => - reversedMeasurements.reduce( - (last, rowStat) => (rowStat.end >= scrollOffset ? rowStat : last), - reversedMeasurements[0] - ), - [reversedMeasurements, scrollOffset] - ) + Object.assign(latestRef.current, { + overscan, + measurements, + outerSize, + totalSize, + }) - let end = React.useMemo( - () => - measurements.reduce( - (last, rowStat) => - rowStat.start <= scrollOffsetPlusOuterSize ? rowStat : last, - measurements[0] - ), - [measurements, scrollOffsetPlusOuterSize] - ) + const [range, setRange] = React.useState({ start: 0, end: 0 }) - let startIndex = start ? start.index : 0 - let endIndex = end ? end.index : 0 + useIsomorphicLayoutEffect(() => { + const element = parentRef.current - // Always add at least one overscan item, so focus will work - startIndex = Math.max(startIndex - overscan, 0) - endIndex = Math.min(endIndex + overscan, size - 1) + const onScroll = () => { + const scrollOffset = element[scrollKey] + latestRef.current.scrollOffset = scrollOffset + setRange(prevRange => calculateRange(latestRef.current, prevRange)) + } - const latestRef = React.useRef({}) + // Determine initially visible range + onScroll() - latestRef.current = { - measurements, - outerSize, - scrollOffset, - scrollOffsetPlusOuterSize, - totalSize, - } + element.addEventListener('scroll', onScroll, { + capture: false, + passive: true, + }) + + return () => { + element.removeEventListener('scroll', onScroll) + } + }, [parentRef, scrollKey]) const virtualItems = React.useMemo(() => { const virtualItems = [] - for (let i = startIndex; i <= endIndex; i++) { + for (let i = range.start; i <= range.end; i++) { const measurement = measurements[i] const item = { @@ -143,7 +122,7 @@ export function useVirtual({ } return virtualItems - }, [startIndex, endIndex, measurements, sizeKey, defaultScrollToFn]) + }, [range.start, range.end, measurements, sizeKey, defaultScrollToFn]) const mountedRef = React.useRef() @@ -156,16 +135,12 @@ export function useVirtual({ const scrollToOffset = React.useCallback( (toOffset, { align = 'start' } = {}) => { - const { - outerSize, - scrollOffset, - scrollOffsetPlusOuterSize, - } = latestRef.current + const { scrollOffset, outerSize } = latestRef.current if (align === 'auto') { if (toOffset <= scrollOffset) { align = 'start' - } else if (scrollOffset >= scrollOffsetPlusOuterSize) { + } else if (scrollOffset >= scrollOffset + outerSize) { align = 'end' } else { align = 'start' @@ -185,11 +160,7 @@ export function useVirtual({ const tryScrollToIndex = React.useCallback( (index, { align = 'auto', ...rest } = {}) => { - const { - measurements, - scrollOffset, - scrollOffsetPlusOuterSize, - } = latestRef.current + const { measurements, scrollOffset, outerSize } = latestRef.current const measurement = measurements[Math.max(0, Math.min(index, size - 1))] @@ -198,7 +169,7 @@ export function useVirtual({ } if (align === 'auto') { - if (measurement.end >= scrollOffsetPlusOuterSize) { + if (measurement.end >= scrollOffset + outerSize) { align = 'end' } else if (measurement.start <= scrollOffset) { align = 'start' @@ -241,3 +212,30 @@ export function useVirtual({ scrollToIndex, } } + +function calculateRange({ + overscan, + measurements, + outerSize, + scrollOffset, +}, prevRange) { + const total = measurements.length + let start = total - 1 + while (start > 0 && measurements[start].end >= scrollOffset) { + start -= 1 + } + let end = 0 + while (end < total - 1 && measurements[end].start <= scrollOffset + outerSize) { + end += 1 + } + + // Always add at least one overscan item, so focus will work + start = Math.max(start - overscan, 0) + end = Math.min(end + overscan, total - 1) + + if (!prevRange || prevRange.start !== start || prevRange.end !== end) { + return { start, end } + } + + return prevRange +} From 96bcedc61991b18981c338191fb97ea5c6ec6f79 Mon Sep 17 00:00:00 2001 From: Victor Hallberg Date: Fri, 14 Aug 2020 10:37:30 +0200 Subject: [PATCH 2/5] fix: remove no longer used useScroll --- src/index.js | 1 - src/useScroll.js | 46 ---------------------------------------------- 2 files changed, 47 deletions(-) delete mode 100644 src/useScroll.js diff --git a/src/index.js b/src/index.js index 0726f40e..762551fd 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,5 @@ import React from 'react' -import useScroll from './useScroll' import useRect from './useRect' import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect' diff --git a/src/useScroll.js b/src/useScroll.js deleted file mode 100644 index 2a127b9b..00000000 --- a/src/useScroll.js +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react' - -import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect' - -export default function useScroll(nodeRef, onChange) { - const [element, setElement] = React.useState(nodeRef.current) - const onChangeRef = React.useRef() - onChangeRef.current = onChange - - useIsomorphicLayoutEffect(() => { - if (nodeRef.current !== element) { - setElement(nodeRef.current) - } - }) - - useIsomorphicLayoutEffect(() => { - if (element) { - onChangeRef.current({ - scrollLeft: element.scrollLeft, - scrollTop: element.scrollTop, - }) - } - }, [element]) - - React.useEffect(() => { - if (!element) { - return - } - - const handler = e => { - onChangeRef.current({ - scrollLeft: e.target.scrollLeft, - scrollTop: e.target.scrollTop, - }) - } - - element.addEventListener('scroll', handler, { - capture: false, - passive: true, - }) - - return () => { - element.removeEventListener('scroll', handler) - } - }, [element]) -} From 1457e5aca992d16c9f4b585b425281a55d5c4825 Mon Sep 17 00:00:00 2001 From: Victor Hallberg Date: Fri, 14 Aug 2020 10:33:41 +0200 Subject: [PATCH 3/5] style: remove semicolons --- src/useRect.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/useRect.js b/src/useRect.js index 24b34238..1b679608 100644 --- a/src/useRect.js +++ b/src/useRect.js @@ -6,7 +6,7 @@ import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect' export default function useRect(nodeRef) { const [element, setElement] = React.useState(nodeRef.current) - const [rect, dispatch] = React.useReducer(rectReducer, null); + const [rect, dispatch] = React.useReducer(rectReducer, null) const initialRectSet = React.useRef(false) useIsomorphicLayoutEffect(() => { @@ -43,10 +43,10 @@ export default function useRect(nodeRef) { } function rectReducer(state, action) { - const rect = action.rect; + const rect = action.rect if (!state || state.height !== rect.height || state.width !== rect.width) { - return rect; + return rect } - return state; + return state } From 053b624067795cb46f53484d18ff69ec32c16dde Mon Sep 17 00:00:00 2001 From: Victor Hallberg Date: Fri, 14 Aug 2020 17:24:02 +0200 Subject: [PATCH 4/5] fix: recalculate range when size prop changes --- src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index 762551fd..dcb63d1b 100644 --- a/src/index.js +++ b/src/index.js @@ -87,7 +87,7 @@ export function useVirtual({ return () => { element.removeEventListener('scroll', onScroll) } - }, [parentRef, scrollKey]) + }, [parentRef, scrollKey, size /* required */]) const virtualItems = React.useMemo(() => { const virtualItems = [] From 0cb45f06f7a640928f7bde61b6ffdcd1e703d31a Mon Sep 17 00:00:00 2001 From: Victor Hallberg Date: Tue, 18 Aug 2020 10:56:03 +0200 Subject: [PATCH 5/5] fix: Re-attach scroll handlers if parentRef changes Co-authored-by: coppa --- src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index dcb63d1b..4f1a9e06 100644 --- a/src/index.js +++ b/src/index.js @@ -87,7 +87,7 @@ export function useVirtual({ return () => { element.removeEventListener('scroll', onScroll) } - }, [parentRef, scrollKey, size /* required */]) + }, [parentRef.current, scrollKey, size /* required */]) const virtualItems = React.useMemo(() => { const virtualItems = []