API-интерфейс Intersection Observer переходит в бесконечный цикл рендеринга

Я пытаюсь использовать API наблюдателя пересечения для условного отображения элементов в сетке CSS, когда пользователь начинает прокрутку, но, похоже, происходит бесконечный цикл рендеринга. Вот мой код.

      import { Portfolio } from '@/common/types';
import {
  Box,
  Flex,
  Text,
  useColorModeValue as mode,
  Divider,
  Grid,
  GridItem,
  Center,
  Switch,
  FormControl,
  FormLabel,
  Progress,
  Stack,
  Link,
  Circle,
  Button,
  ButtonGroup,
  useToast,
  ChakraProps,
} from '@chakra-ui/react';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { useInView } from 'react-intersection-observer';
import { useRef, forwardRef, useCallback } from 'react';

export type PortfolioListItemProps = {
  portfolio: Portfolio;
  hidden: boolean | undefined;
};

export const PortfolioListItem = ({
  portfolio,
  hidden,
}: PortfolioListItemProps) => {
  const {
    symbol,
    name,
    buy_qty,
    sell_qty,
    avg_buy_price,
    avg_sell_price,
    pnl,
    exit_date,
    entry_date,
    isin_no,
  } = portfolio;
  {
    const toast = useToast();
    const showCopySuccess = () => {
      toast({
        description: `Copied to clipboard`,
        status: `success`,
        duration: 2000,
        isClosable: true,
        position: `top`,
      });
    };
    let bgColor = `white`;
    let color = `gray.500`;
    let dateFmtExit = ``;
    let dateFmtEntry = ``;
    const fmt = new Intl.NumberFormat(`en-IN`, {
      style: `currency`,
      currency: `INR`,
      notation: `compact`,
    });
    const mo = new Intl.DateTimeFormat(`en-IN`, {
      month: `short`,
      day: `2-digit`,
      year: `numeric`,
    });
    if (pnl > 0) {
      color = `green.400`;
    } else if (pnl < 0) {
      color = `red.400`;
    }
    if (exit_date) {
      const dt = new Date(exit_date);
      if (dt.getFullYear() > 1900) {
        dateFmtExit = mo.format(dt);
        bgColor = `red.50`;
      }
    }
    if (entry_date) {
      const dt = new Date(entry_date);
      if (dt.getFullYear() > 1900) {
        dateFmtEntry = mo.format(dt);
      }
    }
    return (
      <Grid
        gridTemplateColumns="1fr 1fr"
        gridTemplateRows="min-content"
        shadow="md"
        rounded={{ md: `lg` }}
        bg={bgColor}
        hidden={hidden}
      >
        <GridItem display="flex" colSpan={2}>
          <Stack
            bg="white"
            align="center"
            borderTopLeftRadius={6}
            borderTop={1}
            borderLeft={1}
            borderRight={1}
            borderStyle="solid"
            borderColor="gray.300"
            p={4}
            w="50%"
          >
            <Text color="green.400" fontWeight="semibold" fontSize="xl">
              {fmt.format(avg_buy_price)}
            </Text>
            <Text color="gray.400" fontWeight="semibold">
              {buy_qty} qty
            </Text>
          </Stack>
          <Stack
            bg="white"
            align="center"
            borderTop={1}
            borderRight={1}
            borderTopRightRadius={6}
            borderStyle="solid"
            borderColor="gray.300"
            p={4}
            w="50%"
          >
            <Text color="red.400" fontWeight="semibold" fontSize="xl">
              {fmt.format(avg_sell_price)}
            </Text>
            <Text color="gray.400" fontWeight="semibold">
              {sell_qty} qty
            </Text>
          </Stack>
        </GridItem>
        <GridItem colSpan={2}>
          <Stack
            spacing={1}
            borderTop={1}
            borderLeft={1}
            borderRight={1}
            borderStyle="solid"
            borderColor="gray.300"
            p={4}
            bg={bgColor}
            mt="-px"
          >
            <Text
              as="button"
              textAlign="left"
              fontWeight="bold"
              outline="none"
              isTruncated
              cursor="pointer"
              casing="uppercase"
              onClick={() => {
                navigator?.clipboard?.writeText(name);
                showCopySuccess();
              }}
            >
              {name}
            </Text>
            <Flex justify="space-between">
              <Stack spacing={2} align="center" direction="row">
                <Text
                  as="button"
                  textAlign="left"
                  cursor="pointer"
                  outline="none"
                  onClick={() => {
                    navigator?.clipboard?.writeText(symbol);
                    showCopySuccess();
                  }}
                  fontSize="sm"
                  color="gray.400"
                  fontWeight="semibold"
                >
                  {symbol}
                </Text>
                <Circle size="6px" bg="gray.300" />
                <Text
                  as="button"
                  textAlign="left"
                  cursor="pointer"
                  outline="none"
                  onClick={() => {
                    navigator?.clipboard?.writeText(isin_no);
                    showCopySuccess();
                  }}
                  fontSize="sm"
                  color="gray.400"
                  fontWeight="semibold"
                >
                  {isin_no}
                </Text>
              </Stack>
              <Link
                href={`https://www.nseindia.com/get-quotes/equity?symbol=${symbol}`}
                isExternal
              >
                <ExternalLinkIcon />
              </Link>
            </Flex>
            <Stack justify="space-between" direction="row">
              <Text fontSize="sm" color="gray.400" fontWeight="semibold">
                {dateFmtEntry}
              </Text>
              <Text fontSize="sm" color="gray.400" fontWeight="semibold">
                {dateFmtExit}
              </Text>
            </Stack>
          </Stack>
        </GridItem>
        <GridItem colSpan={2}>
          <Flex
            spacing="2"
            borderTop={1}
            borderLeft={1}
            borderRight={1}
            justify="space-between"
            borderStyle="solid"
            borderColor="gray.300"
            p={4}
            align="center"
            bg="white"
            mt="-px"
            borderBottomRightRadius={6}
            borderBottomLeftRadius={6}
          >
            <Text color="gray.400" casing="uppercase" fontWeight="semibold">
              Profit & Loss
            </Text>
            <Text color={color} fontSize="xl" fontWeight="bold">
              {fmt.format(pnl)}
            </Text>
          </Flex>
        </GridItem>
      </Grid>
    );
  }
};

export type PortfolioListProps = {
  portfolios?: Portfolio[];
  title: string;
  isLoading: boolean;
  headerSlot?: React.ReactElement;
};

export const PortfolioList = ({
  portfolios,
  title,
  isLoading,
  headerSlot,
}: PortfolioListProps) => {
  const parRef = useRef(null);
  const ref = useRef(null);
  const [inViewRef, inView] = useInView({
    threshold: 1,
    rootMargin: '20px',
    root: parRef?.current,
  });

  const setRefs = useCallback(
    (node) => {
      // Ref's from useRef needs to have the node assigned to `current`
      ref.current = node;
      // Callback refs, like the one from `useInView`, is a function that takes the node as an argument
      inViewRef(node);
    },
    [inViewRef],
  );

  return (
    <Box
      w="100%"
      mx="auto"
      rounded={{ md: `lg` }}
      bg={mode(`white`, `gray.700`)}
      shadow="md"
      overflow="hidden"
    >
      <Flex align="center" justify="space-between" px="6" py="4">
        <Text as="h3" fontWeight="bold" fontSize="xl">
          {title}
        </Text>
        {headerSlot}
      </Flex>
      <Progress
        size="xs"
        w="100%"
        colorScheme="blue"
        isIndeterminate
        hidden={!isLoading}
      />
      <Divider />
      <Grid
        p={4}
        gap={4}
        templateColumns="1fr 1fr 1fr 1fr"
        templateRows="min-content"
        maxH="500px"
        minH="500px"
        overflowY="auto"
        id="list"
        ref={parRef}
      >
        {portfolios && portfolios?.length > 0 ? (
          portfolios.map((pt) => (
            <GridItem ref={setRefs} _last={{ mb: 4 }} key={pt.symbol}>
              <PortfolioListItem
                hidden={!inView}
                key={pt.symbol}
                portfolio={pt}
              />
            </GridItem>
          ))
        ) : (
          <GridItem hidden={isLoading} colSpan={4} p={8}>
            <Center height="100%">No data found</Center>
          </GridItem>
        )}
      </Grid>
    </Box>
  );
};

2 ответа

Проблема: та же ссылка на 1000 элементов

У вас 1000 GridItem компоненты, которые все получают один и тот же обратный вызов ref setRefs. Все они имеют одинаковую ценность, даже если мы знаем, что в любой момент времени одни видны, а другие нет. В итоге происходит то, что каждый элемент перезаписывает ранее установленную ссылку, так что все 1000 элементов получают логическое значение inView который показывает, находится ли последний элемент в списке в поле зрения, а не сам ли он.

Решение: для каждого элемента

Чтобы знать, находится ли каждый отдельный компонент в поле зрения или нет, нам нужно использовать ловушку отдельно для каждого элемента в списке. Мы можем переместить код для каждого элемента в отдельный компонент. Этому компоненту нужно передать его номер ix и варианты useInView крючок (мы также могли бы просто передать корневую ссылку и создать options объект здесь).

      import { Box, Flex, Text, useColorModeValue as mode, Divider, Grid, GridItem, Center } from '@chakra-ui/react';
import { useInView, IntersectionOptions } from 'react-intersection-observer';
import React, { useRef } from 'react';

interface ItemProps {
  ix: number;
  inViewOptions: IntersectionOptions;
}

export const ListItem = ({ix, inViewOptions}: ItemProps) => {
  const {ref, inView}= useInView(inViewOptions);

  return (
    <GridItem bg={inView?"red.100":"blue.100"} ref={ref} _last={{ mb: 4 }} key={ix}>
      <Center  border={1} borderColor="gray.100" borderStyle="solid" h={16} w="100%">
        Item {ix}
      </Center>
    </GridItem>
  )
}


export type PortfolioListProps = {
  title: string;
};

export const PortfolioList = ({ 
  title,
}: PortfolioListProps) => {
  const parRef = useRef(null);

  return (
    <Box
      w="100%"
      mx="auto"
      rounded={{ md: `lg` }}
      bg={mode(`white`, `gray.700`)}
      shadow="md"
      overflow="hidden"
    >
      <Flex align="center" justify="space-between" px="6" py="4">
        <Text as="h3" fontWeight="bold" fontSize="xl">
          {title}
        </Text>
      </Flex>
      <Divider />
      <Grid
        p={4}
        gap={4}
        templateColumns="1fr 1fr 1fr 1fr"
        templateRows="min-content"
        maxH="500px"
        minH="500px"
        overflowY="auto"
        id="list"
        ref={parRef}
      >
        {[...Array(1000)].map((pt,ix) => (
          <ListItem ix={ix} key={ix} inViewOptions={{
            threshold: 1,
            rootMargin: '0px',
            root: parRef?.current,
          }}/>
        ))}
      </Grid>
    </Box>
  );
};

Ссылка на StackBlitz

Компонент работает не так, как вы ожидаете, потому что вы используете одну и ту же ссылку для всех элементов. Вы можете использовать ref для хранения массива ссылок или создания компонента с логикой элемента списка.

Если вы не хотите отображать все элементы одновременно, вы можете визуализировать часть (100), и каждый раз scrollдоходит до конца, рендер еще 100 и так далее. Я рекомендую вам использовать React.memo чтобы не отображать элемент каждый раз при обновлении состояния:

PortfolioItem.js

      const PortfolioItem = React.memo(({ ix }) => {
  const ref = useRef();
    const [inViewRef, inView] = useInView({
    threshold: 1,
    rootMargin: '0px',
  });
    const setRefs = useCallback(
    (node) => {
      ref.current = node;
      inViewRef(node);
    },
    [], //--> empty dependencies
  );

  return ( <GridItem bg={inView?"red.100":"blue.100"} ref={setRefs} _last={{ mb: 4 }} >
              <Center  border={1} borderColor="gray.100" borderStyle="solid" h={16} w="100%">
                Item {ix}
              </Center>
            </GridItem>)
});

PortfolioList.js

      export const PortfolioList = ({ 
  title,
  count = 100
}: PortfolioListProps) => {
  const ref = useRef(null);
  const items = [...Array(1000)];
  const [index, setIndex] = useState(count);
  
  useEffect(()=> {
    const grid = ref.current;
    function onScroll(){
      if(grid.offsetHeight + grid.scrollTop >= grid.scrollHeight) {
           setIndex(prev => prev+count);
      }
    }
    grid.addEventListener("scroll", onScroll);
    return ()=> {
       grid.removeEventListener("scroll", onScroll);
    }
  }, []);

  return (
    <Box
      w="100%"
      mx="auto"
      rounded={{ md: `lg` }}
      bg={mode(`white`, `gray.700`)}
      shadow="md"
      overflow="hidden"
    >
      <Flex align="center" justify="space-between" px="6" py="4">
        <Text as="h3" fontWeight="bold" fontSize="xl">
          {title}
        </Text>
      </Flex>
      <Divider />
      <Grid
        p={4}
        gap={4}
        templateColumns="1fr 1fr 1fr 1fr"
        templateRows="min-content"
        maxH="500px"
        minH="500px"
        overflowY="auto"
        id="list"
        ref={ref}
      >
        {items.slice(0,index).map((pt,ix) => (
            <PortfolioItem ix={ix} key={`Postfolio__item-${ix}`}/>
          ))
         }
      </Grid>
    </Box>
  );
};

Рабочий пример

Другие вопросы по тегам