diff --git a/test/helpers/setup-helper.js b/test/helpers/setup-helper.js index 7bb8345bd1eb..c9a29cebe4ab 100644 --- a/test/helpers/setup-helper.js +++ b/test/helpers/setup-helper.js @@ -134,7 +134,11 @@ window.localStorage = { }; // used for native dark/light mode detection -window.matchMedia = () => true; +window.matchMedia = () => ({ + // Used for NFT list virtualization + // eslint-disable-next-line no-empty-function + addEventListener: () => {}, +}); // override @metamask/logo window.requestAnimationFrame = () => undefined; diff --git a/ui/components/app/assets/nfts/nft-grid/nft-grid.tsx b/ui/components/app/assets/nfts/nft-grid/nft-grid.tsx index ba8bfccb356b..1f31b0bba87a 100644 --- a/ui/components/app/assets/nfts/nft-grid/nft-grid.tsx +++ b/ui/components/app/assets/nfts/nft-grid/nft-grid.tsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import { toHex } from '@metamask/controller-utils'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { AlignItems, Display, @@ -18,6 +19,7 @@ import useGetAssetImageUrl from '../../../../../hooks/useGetAssetImageUrl'; import { getImageForChainId } from '../../../../../selectors/multichain'; import { getNetworkConfigurationsByChainId } from '../../../../../../shared/modules/selectors/networks'; import useFetchNftDetailsFromTokenURI from '../../../../../hooks/useFetchNftDetailsFromTokenURI'; +import { useScrollContainer } from '../../../../../contexts/scroll-container'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { isWebUrl } from '../../../../../../app/scripts/lib/util'; @@ -71,6 +73,9 @@ const NFTGridItem = (props: { ); }; +// Breakpoint matches design-system $screen-md-max (768px - 1px) +const SCREEN_MD_MAX = 767; + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention export default function NftGrid({ @@ -82,30 +87,118 @@ export default function NftGrid({ handleNftClick: (nft: NFT) => void; privacyMode?: boolean; }) { + const scrollContainerRef = useScrollContainer(); const nftsStillFetchingIndication = useSelector( getNftIsStillFetchingIndication, ); + // Detect screen size to match CSS Grid column count + // 4 columns for large screens, 3 columns for medium and below + const [itemsPerRow, setItemsPerRow] = useState(() => + window.innerWidth > SCREEN_MD_MAX ? 4 : 3, + ); + + useEffect(() => { + const mediaQuery = window.matchMedia(`(max-width: ${SCREEN_MD_MAX}px)`); + + const handleResize = (e: MediaQueryListEvent) => { + setItemsPerRow(e.matches ? 3 : 4); + }; + + mediaQuery.addEventListener('change', handleResize); + return () => mediaQuery.removeEventListener('change', handleResize); + }, []); + + // Group NFTs into rows for virtualization + const nftRows = useMemo(() => { + const rows: NFT[][] = []; + for (let i = 0; i < nfts.length; i += itemsPerRow) { + rows.push(nfts.slice(i, i + itemsPerRow)); + } + return rows; + }, [nfts, itemsPerRow]); + + const virtualizer = useVirtualizer({ + count: nftRows.length, + getScrollElement: () => scrollContainerRef?.current || null, + estimateSize: () => 200, + overscan: 5, + }); + + // Disable virtualization when scroll container is not available (e.g., in tests) + // or when there are few NFTs (no performance benefit) + const shouldDisableVirtualization = + !scrollContainerRef?.current || nfts.length <= 20; + return ( - - {nfts.map((nft: NFT, index: number) => { - return ( - null}> - + {nfts.map((nft: NFT, index: number) => { + return ( + null}> + + handleNftClick(nft)} + privacyMode={privacyMode} + /> + + + ); + })} + + ) : ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const rowNfts = nftRows[virtualRow.index]; + return ( +
- handleNftClick(nft)} - privacyMode={privacyMode} - /> - - - ); - })} - + + {rowNfts.map((nft, index) => ( + null} + > + + handleNftClick(nft)} + privacyMode={privacyMode} + /> + + + ))} + +
+ ); + })} +
+ )} {nftsStillFetchingIndication ? (