Skip to content

[feature] Allow keeping already rendered items mounted #163

Closed

Description

@olee

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

  1. piecyk commented on Jul 12, 2021

    @piecyk
    Collaborator

    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

    const rowVirtualizer = useVirtual({
      size: rows.length,
      parentRef,
      rangeExtractor: range => ({
        start: 0, // naive case, always render from 0 
        end: Math.min(range.end + range.overscan, range.size)
      })
    });
  2. piecyk commented on Jul 12, 2021

    @piecyk
    Collaborator

    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 isVisible on item would give really easy optimisation by custom equal method to skip re-rendering items that are not in viewport

  3. olee commented on Jul 12, 2021

    @olee
    Author

    @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 ;-)

    export const defaultRangeExtractor = range => {
        const start = Math.max(range.start - range.overscan, 0);
        const exclusiveEnd = Math.min(range.end + range.overscan + 1, range.size);
        const length = exclusiveEnd - start;
        const indices = Array.from({ length });
        for (let i = 0; i < length; i++) {
            indices[i] = start + i;
        }
        return indices;
    };
  4. piecyk commented on Jul 13, 2021

    @piecyk
    Collaborator

    Would love to see a PR for this.

    Pushed a PR #164, let's see what @tannerlinsley will say.

    Also, I think for defaultRangeExtractor, this implementation might be a bit faster by initializing the array with the correct length

    hmm, would it really make it faster? Having in mind modern browsers i would expect that impact to be really marginal

  5. piecyk commented on Jul 13, 2021

    @piecyk
    Collaborator

    Having rangeExtractor would also allow us to support sticky elements, it's also a pretty common use case #115

    Something like this

    const rows = new Array(10000)
      .fill(true)
      .map(() => 25 + Math.round(Math.random() * 100));
    
    function StickyDemo() {
      const parentRef = React.useRef();
      const visibleStartIndexRef = React.useRef(0);
    
      const stickyRef = React.useRef([0, 10, 20, 30, 40, 50, 60]);
    
      const isSticky = index => stickyRef.current.includes(index);
    
      const isActiveSticky = index =>
        isSticky(index) && visibleStartIndexRef.current >= index;
    
      const rowVirtualizer = useVirtual({
        size: rows.length,
        parentRef,
        estimateSize: React.useCallback(() => 50, []),
        rangeExtractor: React.useCallback(range => {
          visibleStartIndexRef.current = range.start;
    
          const next = new Set([
            ...stickyRef.current,
            ...defaultRangeExtractor(range)
          ]);
    
          return [...next].sort((a, b) => a - b);
        }, [])
      });
    
      return (
        <>
          <div
            ref={parentRef}
            className="List"
            style={{
              height: `200px`,
              width: `400px`,
              overflow: "auto"
            }}
          >
            <div
              style={{
                height: `${rowVirtualizer.totalSize}px`,
                width: "100%",
                position: "relative"
              }}
            >
              {rowVirtualizer.virtualItems.map(virtualRow => (
                <div
                  key={virtualRow.index}
                  className={virtualRow.index % 2 ? "ListItemOdd" : "ListItemEven"}
                  style={{
                    ...(isSticky(virtualRow.index)
                      ? {
                          background: "red",
                          zIndex: 1
                        }
                      : {}),
                    ...(isActiveSticky(virtualRow.index)
                      ? {
                          position: "sticky"
                        }
                      : {
                          position: "absolute",
                          transform: `translateY(${virtualRow.start}px)`
                        }),
                    top: 0,
                    left: 0,
                    width: "100%",
                    height: `${virtualRow.size}px`
                  }}
                >
                  Row {virtualRow.index}
                </div>
              ))}
            </div>
          </div>
        </>
      );
    }
  6. olee commented on Jul 13, 2021

    @olee
    Author

    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?

  7. tannerlinsley commented on Jul 13, 2021

    @tannerlinsley
    Member

    🎉 This issue has been resolved in version 2.8.0 🎉

    The release is available on:

    Your semantic-release bot 📦🚀

  8. olee commented on Jul 15, 2021

    @olee
    Author

    Do you think it would make sense to add some of the common use-case range extractors to the project like useKeepMountedRangeExtractor or useStickyRangeExtractor?

    Also, I found a really simple reusable implementation for the keep-mounted rangeExtractor based on your last example:

    export default function useKeepMountedRangeExtractor() {
        return React.useState(() => {
            let renderedRows = new Set<number>();
            const re = Object.assign((range: Range) => {
                re.start = range.start;
                re.end = range.end;
                re.overscan = range.overscan;
                renderedRows = new Set([...renderedRows, ...defaultRangeExtractor(range)]);
                return Array.from(renderedRows).sort();
            }, {
                start: 0,
                end: -1,
                overscan: 0,
                isVisible: (index: number) => index >= re.start - re.overscan && index <= re.end + re.overscan,
            });
            return re;
        })[0];
    }
    
        const rangeExtractor = useKeepMountedRangeExtractor();
        const virtual = useVirtual({
            size: 1000,
            parentRef,
            overscan: 5,
            estimateSize: React.useCallback(() => 100, []),
            rangeExtractor,
        });

    It also provides utility data with rangeExtractor.start/end and rangeExtractor.isVisible

  9. piecyk commented on Jul 15, 2021

    @piecyk
    Collaborator

    Do you think it would make sense to add some of the common use-case range extractors to the project like useKeepMountedRangeExtractor or useStickyRangeExtractor?

    Yes, @tannerlinsley suggested the same thing.

    Also, I found a really simple reusable implementation for the keep-mounted rangeExtractor based on your last example:

    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/files

    Something like this

    const useKeepMountedRangeExtractor = () => {
      const renderedRef = React.useRef(new Set());
    
      const rangeExtractor = React.useCallback(range => {
        renderedRef.current = new Set([
          ...renderedRef.current,
          ...defaultRangeExtractor(range)
        ]);
        return Array.from(renderedRef.current);
      }, []);
    
      return rangeExtractor;
    };
    
    // 
    
    const rowVirtualizer = useVirtual({
      size: rows.length,
      parentRef,
      rangeExtractor: useKeepMountedRangeExtractor()
    });
  10. olee commented on Jul 15, 2021

    @olee
    Author

    @piecyk

    No need for useState initial state callback as we have build in useRef exact for this use cases

    Afaik it is recommended to use useState(...)[0] for constants in React and not useRef(...).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?`

  11. piecyk commented on Jul 15, 2021

    @piecyk
    Collaborator

    Afaik it is recommended to use useState(...)[0] for constants in React and not useRef(...).current

    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

    Essentially, useRef is like a “box” that can hold a mutable value in its .current property.

    That's why imho go with useRef.

    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?`

    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.

  12. byteab commented on Jul 17, 2023

    @byteab

    using useKeepMountedRangeExtractor all items render on initial load at once

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions