Repository navigation
Description
It would be nice to have an option to keep already mounted items in the list.
This could largely improve scroll performance for components which are expensive to render by not needlessly unmounting already rendered instances.
Of course this only works if the rendered items are properly optimized by eg. React.memo.
Here is a sample CodeSandbox
It works by keeping previously rendered virtualItems in the array and updating them when new items arrive.
However, there is an issue if items change their size which could potentially cause to render overlapping items.
I did not test yet, if this can actually become an issue, but if so, it could only be solved if this feature is integrated into react-virtual because then we would have access to the measurement cache and could prevent outdated item positions.
What do you think of such a feature?
PS: This is the typescript code of the implementation:
import React from 'react';
import { useVirtual, VirtualItem } from 'react-virtual';
/**
* Merges two arrays while preserving their order.
* Ordering is determined by the passed getIndex function.
* If two items have the same index, the item from the first array (primary) is used so duplicates are eliminated.
*
* @example
* merge([1, 2, 5, 6, 8, 9], [3, 4, 5, 6, 8, 9], x => x);
* // returns [1, 2, 3, 4, 5, 6, 8, 9];
*/
export function mergeArraysWithOrder<T>(primary: T[], secondary: T[], getIndex: (item: T) => number): T[] {
let pi = 0;
let si = 0;
const result: T[] = [];
while (true) {
if (pi >= primary.length) {
result.push(...secondary.slice(si));
break;
} else if (si >= secondary.length) {
result.push(...primary.slice(pi));
break;
}
const pIndex = getIndex(primary[pi]);
const sIndex = getIndex(secondary[si]);
if (pIndex < sIndex) {
result.push(primary[pi]);
pi++;
} else if (pIndex > sIndex) {
result.push(secondary[si]);
si++;
} else {
result.push(primary[pi]);
pi++;
si++;
}
}
return result;
}
/**
* Same as useVirtual, but keeps once rendered items mounted
*/
export function useVirtualKeepMounted(options: Parameters<typeof useVirtual>[0]) {
const result = useVirtual(options);
const totalCount = options.size;
const lastVirtualItemsRef = React.useRef<VirtualItem[]>([]);
const virtualItems = React.useMemo(() => {
let lastItems = lastVirtualItemsRef.current;
if (lastItems.length > 0 && lastItems[lastItems.length - 1].index >= totalCount) {
lastItems = lastItems.filter(x => x.index < totalCount);
}
const vItems = mergeArraysWithOrder(
result.virtualItems, lastItems, x => x.index
);
lastVirtualItemsRef.current = vItems;
return vItems;
}, [totalCount, result.virtualItems]);
return { ...result, virtualItems };
}
Activity
piecyk commentedon Jul 12, 2021
I think it's a valid use case, as react-virtual is low level hook one option is to extract range calculation to method. Then rangeExtractor will control what rows are rendered, for example
piecyk commentedon Jul 12, 2021
Even better if rangeExtractor would return array of indexes to render, then keeping indexes already rendered would be really easy. Something like this master...piecyk:feat/range-extractor
Having also
isVisibleon item would give really easy optimisation by custom equal method to skip re-rendering items that are not in viewportolee commentedon Jul 12, 2021
@piecyk ohhhh!!!
This looks like an awesome addition and would indeed solve this problem perfectly!
Especially the sample you have in your branch there does exactly what I imagined without all the issues my wrapper implementation had.
Would love to see a PR for this.
Also, I think for defaultRangeExtractor, this implementation might be a bit faster by initializing the array with the correct length ;-)
piecyk commentedon Jul 13, 2021
Pushed a PR #164, let's see what @tannerlinsley will say.
hmm, would it really make it faster? Having in mind modern browsers i would expect that impact to be really marginal
piecyk commentedon Jul 13, 2021
Having
rangeExtractorwould also allow us to support sticky elements, it's also a pretty common use case #115Something like this
olee commentedon Jul 13, 2021
Wow I never imagined this would open up soo many possibilities!
Can't wait for that PR to get accepted and a new release to be published.
I guess it would make sense to include such an example for sticky behavior in the examples as well as perhaps one for load-more (no-unmount no-scroll-container-height) behavior?
tannerlinsley commentedon Jul 13, 2021
🎉 This issue has been resolved in version 2.8.0 🎉
The release is available on:
Your semantic-release bot 📦🚀
olee commentedon Jul 15, 2021
Do you think it would make sense to add some of the common use-case range extractors to the project like
useKeepMountedRangeExtractororuseStickyRangeExtractor?Also, I found a really simple reusable implementation for the keep-mounted rangeExtractor based on your last example:
It also provides utility data with
rangeExtractor.start/endandrangeExtractor.isVisiblepiecyk commentedon Jul 15, 2021
Yes, @tannerlinsley suggested the same thing.
hmm, i would keep it simple. No need for useState initial state callback as we have build in useRef exact for this use cases.
In context of keeping mounted, there is not really need to sort the indexes as all of them are position absolute. But if there would be a need remember about the compare function to sort numbers
Array.from(renderedRef.current).sort((a, b) => a - b);About
isVisible, imho there we don't want to count overscan as we want to know what actual rows are in viewport. Create a PR for it https://github.com/tannerlinsley/react-virtual/pull/128/filesSomething like this
olee commentedon Jul 15, 2021
@piecyk
Afaik it is recommended to use
useState(...)[0]for constants in React and notuseRef(...).current, but both should be fine.Still I think the way I proposed returning the result with the state being part of the constant would be better and it wouldn't require 2 base hooks, wouldn't it?`
piecyk commentedon Jul 15, 2021
hmm, I would say the exact opposite. Mutating this value should not tigger re-render etc, imho the useState here is a bit of hack that would rise more questions.
If you look on react docs about use ref https://reactjs.org/docs/hooks-reference.html#useref
That's why imho go with useRef.
That is correct, but having this two hooks are more readable than one useSate, for me at least. From performance perspective it will work exactly the same. Here we don't do anything expensive just calling methods, and browser is really fast calling them.
In context of hooks, components will re-render, but that's not a problem, problem is when re-renders start to be slow. Then we can optimise.
byteab commentedon Jul 17, 2023
using
useKeepMountedRangeExtractorall items render on initial load at once