reactjstypescriptmaterial-ui

React MUI Virtualized Autocomplete scrolls back to top when selecting an option


I'm using reactMUI autocomplete with virtualization as the listbox is expected to have thousands of items. Virtualization works but if i add an onchange function the listbox scrolls back to top when selecting an item (multiple selection is allowed) i've tried following the advice from this similar question but wrapping the listboxcomponent in useCallback did not solve the problem for me. this is my component's code:

import { Autocomplete, Chip, TextField, Typography } from "@mui/material";
import { ProductGroup, Product, NewGruppo } from "../code/Types";
import React from "react";
import Grid from "@mui/material/Unstable_Grid2"; // Grid version 2
import { ListChildComponentProps, VariableSizeList } from "react-window";

interface Props {
  selectedGroup: ProductGroup | undefined;
  onSelectedProductsChanged?: (products: Product[]) => void;
  selectedProducts: Product[];
}

export const ProductSelector: React.FC<Props> = ({
  selectedGroup,
  onSelectedProductsChanged,
  selectedProducts,
}) => {
  const [isLoadingProdotti, setIsLoadingProdotti] = React.useState(false);
  const [prodotti, setProdotti] = React.useState<Product[]>([]);
  const [selectedProductsCount, setSelectedProductsCount] = React.useState(0);

  React.useEffect(() => {
    const retrieveProducts = async () => {
      // retrieve all products for the selected group using xrm webapi
      const xrm = window.parent.Xrm;
      try {
        setSelectedProductsCount(0);
        setIsLoadingProdotti(true);

        const result: NewGruppo = await xrm.WebApi.retrieveRecord(
          "new_gruppo",
          selectedGroup?.id ?? "",
          "?$select=new_gruppoid&$expand=new_new_gruppo_new_prodotto($select=new_prodottoid,new_idprodotto,new_name,_nk_lineaprodotto_value;$filter=statecode eq 0;$orderby=new_name asc)"
        );

        setProdotti((result.new_new_gruppo_new_prodotto as Product[]) ?? []);
      } catch (error) {
        console.log(error);
      } finally {
        setIsLoadingProdotti(false);
      }
    };

    if (!selectedGroup) {
      setProdotti([]);
    } else {
      retrieveProducts();
    }
  }, [selectedGroup]);

  /**
   * Handles the click event for a product chip.
   * @param productId The ID of the product associated with the clicked chip.
   */
  const handleChipClick = (productId: string) => {
    //open product form
    const xrm = window.parent.Xrm;
    xrm.Navigation.openForm({
      entityName: "new_prodotto",
      entityId: productId,
      openInNewWindow: true,
    });
  };

  const renderRow = (props: ListChildComponentProps) => {
    const { data, index, style } = props;
    const dataSet = data[index];
    const inlineStyle = {
      ...style,
      top: (style.top as number) + LISTBOX_PADDING,
    };
    const option = dataSet[1] as Product;

    return (
      <li {...dataSet[0]} style={inlineStyle}>
        <Grid container spacing={1}>
          <Grid xs={12}>
            <Typography variant="subtitle2">{option.new_idprodotto}</Typography>
          </Grid>
          <Grid xs={12}>
            <Typography variant="body2">
              {option.new_name} -{" "}
              {
                option[
                  "_nk_lineaprodotto_value@OData.Community.Display.V1.FormattedValue"
                ]
              }
            </Typography>
          </Grid>
        </Grid>
      </li>
    );
  };

  function useResetCache(data: number) {
    const ref = React.useRef<VariableSizeList>(null);
    React.useEffect(() => {
      if (ref.current != null) {
        ref.current.resetAfterIndex(0, true);
      }
    }, [data]);
    return ref;
  }

  const OuterElementContext = React.createContext({});

  const OuterElementType = React.forwardRef<HTMLDivElement>((props, ref) => {
    const outerProps = React.useContext(OuterElementContext);
    return <div ref={ref} {...props} {...outerProps} />;
  });

  // Adapter for react-window
  const ListboxComponent = React.useCallback<
    React.ForwardRefRenderFunction<
      HTMLDivElement,
      React.HTMLAttributes<HTMLElement>
    >
  >(
    function ListboxComponent(props, ref) {
      const { children, ...other } = props;
      const itemData: React.ReactNode[] = [];
      (children as React.ReactElement[]).forEach(
        (item: React.ReactElement & { children?: React.ReactElement[] }) => {
          itemData.push(item);
          itemData.push(...(item.children || []));
        }
      );

      const itemCount = itemData.length;
      const itemSize = 55;

      const getChildSize = () => {
        return itemSize;
      };

      const getHeight = () => {
        if (itemCount > 8) {
          return 8 * itemSize;
        }
        return itemData.map(getChildSize).reduce((a, b) => a + b, 0);
      };

      const gridRef = useResetCache(itemCount);

      return (
        <div ref={ref}>
          <OuterElementContext.Provider value={other}>
            <VariableSizeList
              itemData={itemData}
              height={getHeight() + 2 * LISTBOX_PADDING}
              width="100%"
              ref={gridRef}
              outerElementType={OuterElementType}
              innerElementType="ul"
              itemSize={() => itemSize}
              overscanCount={5}
              itemCount={itemCount}
            >
              {renderRow}
            </VariableSizeList>
          </OuterElementContext.Provider>
        </div>
      );
    },
    []
  );

  const LISTBOX_PADDING = 8; // px

  const handleSelectedProductsChanged = (products: Product[]) => {
    if (onSelectedProductsChanged) {
      onSelectedProductsChanged(products);
    }
    setSelectedProductsCount(products?.length ?? 0);
  };

  return (
    <Autocomplete
      fullWidth
      disableListWrap
      disableCloseOnSelect
      multiple
      disabled={isLoadingProdotti}
      id="products-standard-unique-id"
      options={prodotti}
      getOptionDisabled={() => (selectedProductsCount ?? 0) >= 25}
      onChange={(_, values) => handleSelectedProductsChanged(values)}
      filterOptions={(options, state) => {
        return options.filter((o) => {
          const lowerInputValue = state.inputValue.toLowerCase();
          return (
            o.new_idprodotto.toLowerCase().includes(lowerInputValue) ||
            o.new_name.toLowerCase().includes(lowerInputValue)
          );
        });
      }}
      renderInput={(params) => (
        <TextField
          {...params}
          variant="standard"
          label={`Prodotti (${prodotti?.length})`}
          placeholder="Ricerca"
        />
      )}
      renderOption={(props, option, state) =>
        [props, option, state.index] as React.ReactNode
      }
      renderTags={(value, getTagProps) =>
        value.map((option, index) => (
          <Chip
            label={option.new_idprodotto}
            color="info"
            {...getTagProps({ index })}
            onClick={() => handleChipClick(option.new_prodottoid)}
          />
        ))
      }
      ListboxComponent={ListboxComponent}
      value={selectedProducts}
    />
  );
};


Solution

  • The answer in the above comment is correct, if anyone ever stumbles upon this, thanks Oktay Moving all the render related funcions just outside of the ProductSelector component, but in the same file, resolved the problem, without having to use a callback hook or anything like this. this is the complete working component code, it could be more beautiful, but for now it works:

    import {
      Autocomplete,
      Chip,
      TextField,
      Typography,
      autocompleteClasses,
      styled,
    } from "@mui/material";
    import { ProductGroup, Product, NewGruppo } from "../code/Types";
    import React from "react";
    import Grid from "@mui/material/Unstable_Grid2"; // Grid version 2
    import { ListChildComponentProps, VariableSizeList } from "react-window";
    import Popper from "@mui/material/Popper";
    
    interface Props {
      selectedGroup: ProductGroup | undefined;
      onSelectedProductsChanged?: (products: Product[]) => void;
      selectedProducts: Product[];
    }
    
    const renderRow = (props: ListChildComponentProps) => {
      const { data, index, style } = props;
      const dataSet = data[index];
      const inlineStyle = {
        ...style,
        top: (style.top as number) + LISTBOX_PADDING,
      };
      const option = dataSet[1] as Product;
    
      return (
        <li {...dataSet[0]} style={inlineStyle}>
          <Grid container spacing={1}>
            <Grid xs={12}>
              <Typography variant="subtitle2">{option.new_idprodotto}</Typography>
            </Grid>
            <Grid xs={12}>
              <Typography variant="body2">
                {option.new_name} -{" "}
                {
                  option[
                    "_nk_lineaprodotto_value@OData.Community.Display.V1.FormattedValue"
                  ]
                }
              </Typography>
            </Grid>
          </Grid>
        </li>
      );
    };
    
    function useResetCache(data: number) {
      const ref = React.useRef<VariableSizeList>(null);
      React.useEffect(() => {
        if (ref.current != null) {
          ref.current.resetAfterIndex(0, true);
        }
      }, [data]);
      return ref;
    }
    
    const OuterElementContext = React.createContext({});
    
    const OuterElementType = React.forwardRef<HTMLDivElement>((props, ref) => {
      const outerProps = React.useContext(OuterElementContext);
      return <div ref={ref} {...props} {...outerProps} />;
    });
    
    // Adapter for react-window
    const ListboxComponent = React.forwardRef<
      HTMLDivElement,
      React.HTMLAttributes<HTMLElement>
    >(function ListboxComponent(props, ref) {
      const { children, ...other } = props;
      const itemData: React.ReactNode[] = [];
      (children as React.ReactElement[]).forEach(
        (item: React.ReactElement & { children?: React.ReactElement[] }) => {
          itemData.push(item);
          itemData.push(...(item.children || []));
        }
      );
    
      const itemCount = itemData.length;
      const itemSize = 55;
    
      const getChildSize = () => {
        return itemSize;
      };
    
      const getHeight = () => {
        if (itemCount > 8) {
          return 8 * itemSize;
        }
        return itemData.map(getChildSize).reduce((a, b) => a + b, 0);
      };
    
      const gridRef = useResetCache(itemCount);
    
      return (
        <div ref={ref}>
          <OuterElementContext.Provider value={other}>
            <VariableSizeList
              itemData={itemData}
              height={getHeight() + 2 * LISTBOX_PADDING}
              width="100%"
              ref={gridRef}
              outerElementType={OuterElementType}
              innerElementType="ul"
              itemSize={() => itemSize}
              overscanCount={5}
              itemCount={itemCount}
            >
              {renderRow}
            </VariableSizeList>
          </OuterElementContext.Provider>
        </div>
      );
    });
    
    const LISTBOX_PADDING = 8; // px
    
    const StyledPopper = styled(Popper)({
      [`& .${autocompleteClasses.listbox}`]: {
        boxSizing: "border-box",
        "& ul": {
          padding: 0,
          margin: 0,
        },
      },
    });
    
    export const ProductSelector: React.FC<Props> = ({
      selectedGroup,
      onSelectedProductsChanged,
      selectedProducts,
    }) => {
      const [isLoadingProdotti, setIsLoadingProdotti] = React.useState(false);
      const [prodotti, setProdotti] = React.useState<Product[]>([]);
      const [selectedProductsCount, setSelectedProductsCount] = React.useState(0);
    
      React.useEffect(() => {
        const retrieveProducts = async () => {
          // retrieve all products for the selected group using xrm webapi
          const xrm = window.parent.Xrm;
          try {
            setSelectedProductsCount(0);
            setIsLoadingProdotti(true);
    
            const result: NewGruppo = await xrm.WebApi.retrieveRecord(
              "new_gruppo",
              selectedGroup?.id ?? "",
              "?$select=new_gruppoid&$expand=new_new_gruppo_new_prodotto($select=new_prodottoid,new_idprodotto,new_name,_nk_lineaprodotto_value;$filter=statecode eq 0;$orderby=new_name asc)"
            );
    
            setProdotti((result.new_new_gruppo_new_prodotto as Product[]) ?? []);
          } catch (error) {
            console.log(error);
          } finally {
            setIsLoadingProdotti(false);
          }
        };
    
        if (!selectedGroup) {
          setProdotti([]);
        } else {
          retrieveProducts();
        }
      }, [selectedGroup]);
    
      /**
       * Handles the click event for a product chip.
       * @param productId The ID of the product associated with the clicked chip.
       */
      const handleChipClick = (productId: string) => {
        //open product form
        const xrm = window.parent.Xrm;
        xrm.Navigation.openForm({
          entityName: "new_prodotto",
          entityId: productId,
          openInNewWindow: true,
        });
      };
    
      const handleSelectedProductsChanged = (products: Product[]) => {
        if (onSelectedProductsChanged) {
          onSelectedProductsChanged(products);
        }
        setSelectedProductsCount(products?.length ?? 0);
      };
    
      return (
        <Autocomplete
          fullWidth
          disableListWrap
          disableCloseOnSelect
          multiple
          disabled={isLoadingProdotti}
          id="products-standard-unique-id"
          options={prodotti}
          getOptionDisabled={() => (selectedProductsCount ?? 0) >= 25}
          getOptionLabel={(option) => option.new_idprodotto}
          onChange={(_, values) => handleSelectedProductsChanged(values)}
          filterOptions={(options, state) => {
            return options.filter((o) => {
              const lowerInputValue = state.inputValue.toLowerCase();
              return (
                o.new_idprodotto.toLowerCase().includes(lowerInputValue) ||
                o.new_name.toLowerCase().includes(lowerInputValue)
              );
            });
          }}
          renderInput={(params) => (
            <TextField
              {...params}
              variant="standard"
              label={`Prodotti (${prodotti?.length})`}
              placeholder="Ricerca"
            />
          )}
          renderOption={(props, option, state) =>
            [props, option, state.index] as React.ReactNode
          }
          renderTags={(value, getTagProps) =>
            value.map((option, index) => (
              <Chip
                label={option.new_idprodotto}
                color="info"
                {...getTagProps({ index })}
                onClick={() => handleChipClick(option.new_prodottoid)}
              />
            ))
          }
          ListboxComponent={ListboxComponent}
          PopperComponent={StyledPopper}
          value={selectedProducts}
        />
      );
    };