From e9636677d2866b9098cec8cec7286c1ceb6aa22b Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Fri, 10 Apr 2026 22:19:33 +0200 Subject: [PATCH 1/2] PLP performance: narrow fields, derive hasMore, drop dead memo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Vercel React best-practices fixes to the product listing: 1. Pass a fields list to the listing fetches (`PRODUCT_CARD_FIELDS`) so Spree returns only the columns ProductCard reads. Shrinks the cached entry, the RSC→client serialization, and the streaming HTML size. Applied to both ProductListing (category/search PLPs) and FeaturedProducts (homepage carousel). Same pattern SearchBar already uses for quick-search suggestions. 2. Derive `hasMore` in InfiniteProductList from `currentPage < knownPages` rather than mirroring it in its own useState. Introduce a separate `hasError` flag so the "nothing left to load" and "fetch failed" signals no longer share state, per the "don't mirror derivable values into state" rule. 3. Drop the manual useMemo wrapping the product grid JSX. reactCompiler is enabled in next.config, so the compiler handles this automatically — the manual memo was redundant. --- src/components/products/FeaturedProducts.tsx | 3 +- .../products/InfiniteProductList.tsx | 56 +++++++++---------- src/components/products/ProductListing.tsx | 6 ++ src/lib/data/cached.ts | 17 ++++++ 4 files changed, 52 insertions(+), 30 deletions(-) diff --git a/src/components/products/FeaturedProducts.tsx b/src/components/products/FeaturedProducts.tsx index 360ec90f..ac989146 100644 --- a/src/components/products/FeaturedProducts.tsx +++ b/src/components/products/FeaturedProducts.tsx @@ -1,5 +1,6 @@ import dynamic from "next/dynamic"; import { ProductCardSkeleton } from "@/components/products/ProductCardSkeleton"; +import { PRODUCT_CARD_FIELDS } from "@/lib/data/cached"; import { cachedListProducts } from "@/lib/data/products"; import { getAccessToken } from "@/lib/spree"; @@ -34,7 +35,7 @@ export async function FeaturedProducts({ }: FeaturedProductsProps) { const userToken = await getAccessToken(); const productsResponse = await cachedListProducts( - { limit: 8 }, + { limit: 8, fields: PRODUCT_CARD_FIELDS }, { locale, country }, userToken, ); diff --git a/src/components/products/InfiniteProductList.tsx b/src/components/products/InfiniteProductList.tsx index bce6d775..9ff4ffed 100644 --- a/src/components/products/InfiniteProductList.tsx +++ b/src/components/products/InfiniteProductList.tsx @@ -3,14 +3,7 @@ import type { PaginatedResponse, Product, ProductListParams } from "@spree/sdk"; import { Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - useTransition, -} from "react"; +import { useCallback, useEffect, useRef, useState, useTransition } from "react"; import { ProductCard } from "@/components/products/ProductCard"; interface InfiniteProductListProps { @@ -60,21 +53,32 @@ export function InfiniteProductList({ const t = useTranslations("products"); const [products, setProducts] = useState(initialProducts); const [currentPage, setCurrentPage] = useState(initialPage); - const [hasMore, setHasMore] = useState(initialPage < totalPages); + // knownPages = the total page count observed from the most recent fetch. + // hasMore / exhausted state is derived from currentPage < knownPages rather + // than being mirrored in its own useState, per the "derive during render" + // rule. Combined with hasError below, this cleanly separates "nothing + // left to load" from "a load attempt failed". + const [knownPages, setKnownPages] = useState(totalPages); + const [hasError, setHasError] = useState(false); const [isPending, startTransition] = useTransition(); const sentinelRef = useRef(null); - // Refs for the load-more callback so the IntersectionObserver effect - // doesn't need to re-subscribe on every state change. + const hasMore = !hasError && currentPage < knownPages; + + // Refs mirror the values loadNextPage needs to read without forcing the + // IntersectionObserver effect to re-subscribe on every state change. const currentPageRef = useRef(currentPage); currentPageRef.current = currentPage; - const hasMoreRef = useRef(hasMore); - hasMoreRef.current = hasMore; + const knownPagesRef = useRef(knownPages); + knownPagesRef.current = knownPages; + const hasErrorRef = useRef(hasError); + hasErrorRef.current = hasError; const isLoadingRef = useRef(false); const loadNextPage = useCallback(() => { - if (isLoadingRef.current || !hasMoreRef.current) return; + if (isLoadingRef.current || hasErrorRef.current) return; const nextPage = currentPageRef.current + 1; + if (nextPage > knownPagesRef.current) return; isLoadingRef.current = true; startTransition(async () => { @@ -86,14 +90,15 @@ export function InfiniteProductList({ return [...prev, ...appended]; }); setCurrentPage(nextPage); - setHasMore(nextPage < response.meta.pages); + setKnownPages(response.meta.pages); } catch (error) { - // Stop scrolling attempts so the IntersectionObserver doesn't - // retry in a hot loop while the sentinel stays in view. The - // user can change filters (which remounts this island) or - // refresh to try again. + // Flip the error flag so hasMore becomes false and the + // IntersectionObserver stops re-triggering loadNextPage in a + // hot loop while the sentinel stays in view. The user can + // change filters (which remounts this island) or refresh to + // try again. console.error("InfiniteProductList: failed to load next page", error); - setHasMore(false); + setHasError(true); } finally { isLoadingRef.current = false; } @@ -117,8 +122,8 @@ export function InfiniteProductList({ return () => observer.disconnect(); }, [loadNextPage]); - const grid = useMemo( - () => ( + return ( + <>
{products.map((product, index) => ( ))}
- ), - [products, basePath, categoryId, listId, listName, currency], - ); - - return ( - <> - {grid}
actually reads + // (see PRODUCT_CARD_FIELDS). Dropping the unused fields shrinks the + // cached entry, the RSC→client serialization, and the streaming HTML. const listParams: ProductListParams = { limit: PAGE_SIZE, + fields: PRODUCT_CARD_FIELDS, ...queryParams, ...baseParams, }; diff --git a/src/lib/data/cached.ts b/src/lib/data/cached.ts index 6af418e4..7b0bbe86 100644 --- a/src/lib/data/cached.ts +++ b/src/lib/data/cached.ts @@ -14,6 +14,23 @@ export const PRODUCT_PAGE_EXPAND = [ /** Slim expand used by generateProductMetadata (needs only the primary image for og:image). */ export const PRODUCT_METADATA_EXPAND = ["primary_media"]; +/** + * Minimal set of Product fields required to render a and + * fire listing analytics. Passed via the SDK's `fields` param on listing + * fetches so Spree returns a narrowed payload — this shrinks the cached + * entry, the RSC→client serialization, and the streaming HTML size. + */ +export const PRODUCT_CARD_FIELDS = [ + "id", + "name", + "slug", + "thumbnail_url", + "purchasable", + "default_variant_id", + "price", + "original_price", +]; + export const getCachedProduct = cache((slugOrId: string, expand: string[]) => getProduct(slugOrId, { expand }), ); From ed4dde732dea9fbab39c943b8e347ce4f7e4233e Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Fri, 10 Apr 2026 22:32:23 +0200 Subject: [PATCH 2/2] Address CodeRabbit review on PR 123 - InfiniteProductList: decouple hasMore from hasError so fetch failures no longer show "no more products to load". hasMore is now pure pagination state (currentPage < knownPages); the render gate for the exhausted message checks !hasError && !hasMore. - PRODUCT_CARD_FIELDS: include `categories` so mapProductToGA4Item can continue to populate GA4 item_category on view_item_list and select_item events. Without it, analytics silently lost category attribution for every listing interaction. - ProductListing: move `fields: PRODUCT_CARD_FIELDS` to the end of the listParams spread so baseParams / queryParams can't accidentally override the narrowed set. --- src/components/products/InfiniteProductList.tsx | 17 ++++++++++------- src/components/products/ProductListing.tsx | 10 ++++++---- src/lib/data/cached.ts | 4 ++++ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/components/products/InfiniteProductList.tsx b/src/components/products/InfiniteProductList.tsx index 9ff4ffed..2f66c595 100644 --- a/src/components/products/InfiniteProductList.tsx +++ b/src/components/products/InfiniteProductList.tsx @@ -63,7 +63,10 @@ export function InfiniteProductList({ const [isPending, startTransition] = useTransition(); const sentinelRef = useRef(null); - const hasMore = !hasError && currentPage < knownPages; + // Pure pagination state — "are there more pages the server told us + // about". Error state is tracked separately so a fetch failure + // doesn't get misinterpreted as "exhausted". + const hasMore = currentPage < knownPages; // Refs mirror the values loadNextPage needs to read without forcing the // IntersectionObserver effect to re-subscribe on every state change. @@ -92,11 +95,11 @@ export function InfiniteProductList({ setCurrentPage(nextPage); setKnownPages(response.meta.pages); } catch (error) { - // Flip the error flag so hasMore becomes false and the - // IntersectionObserver stops re-triggering loadNextPage in a - // hot loop while the sentinel stays in view. The user can - // change filters (which remounts this island) or refresh to - // try again. + // Flip the error flag so the IntersectionObserver gate + // (hasErrorRef) stops re-triggering loadNextPage in a hot + // loop while the sentinel stays in view, and the render hides + // the "no more products" message. The user can change filters + // (which remounts this island) or refresh to try again. console.error("InfiniteProductList: failed to load next page", error); setHasError(true); } finally { @@ -150,7 +153,7 @@ export function InfiniteProductList({ {t("loadingMore")}
)} - {!hasMore && products.length > 0 && ( + {!hasError && !hasMore && products.length > 0 && (

{t("noMoreProducts")}

)} diff --git a/src/components/products/ProductListing.tsx b/src/components/products/ProductListing.tsx index af323cd3..b146a344 100644 --- a/src/components/products/ProductListing.tsx +++ b/src/components/products/ProductListing.tsx @@ -94,14 +94,16 @@ async function ProductListingInner({ // Base SDK list params for the current filter/sort/query state. // The client island reuses this when fetching subsequent pages. // - // `fields` restricts the payload to what actually reads - // (see PRODUCT_CARD_FIELDS). Dropping the unused fields shrinks the - // cached entry, the RSC→client serialization, and the streaming HTML. + // `fields` is applied LAST so neither `queryParams` nor `baseParams` + // can accidentally override the narrowed card-fields set. This + // restricts the payload to what and listing analytics + // actually read — shrinking the cached entry, the RSC→client + // serialization, and the streaming HTML. const listParams: ProductListParams = { limit: PAGE_SIZE, - fields: PRODUCT_CARD_FIELDS, ...queryParams, ...baseParams, + fields: PRODUCT_CARD_FIELDS, }; // Filters fetch: Ransack-wrapped with the same active filter context, diff --git a/src/lib/data/cached.ts b/src/lib/data/cached.ts index 7b0bbe86..db27b123 100644 --- a/src/lib/data/cached.ts +++ b/src/lib/data/cached.ts @@ -19,6 +19,9 @@ export const PRODUCT_METADATA_EXPAND = ["primary_media"]; * fire listing analytics. Passed via the SDK's `fields` param on listing * fetches so Spree returns a narrowed payload — this shrinks the cached * entry, the RSC→client serialization, and the streaming HTML size. + * + * `categories` is included so `mapProductToGA4Item` can populate the + * GA4 `item_category` attribute on view_item_list / select_item events. */ export const PRODUCT_CARD_FIELDS = [ "id", @@ -29,6 +32,7 @@ export const PRODUCT_CARD_FIELDS = [ "default_variant_id", "price", "original_price", + "categories", ]; export const getCachedProduct = cache((slugOrId: string, expand: string[]) =>