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..2f66c595 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,35 @@ 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. + // 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. 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 +93,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 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); - setHasMore(false); + setHasError(true); } finally { isLoadingRef.current = false; } @@ -117,8 +125,8 @@ export function InfiniteProductList({ return () => observer.disconnect(); }, [loadNextPage]); - const grid = useMemo( - () => ( + return ( + <>
{products.map((product, index) => ( ))}
- ), - [products, basePath, categoryId, listId, listName, currency], - ); - - return ( - <> - {grid}
)} - {!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 3ded4846..b146a344 100644 --- a/src/components/products/ProductListing.tsx +++ b/src/components/products/ProductListing.tsx @@ -11,6 +11,7 @@ import { InfiniteProductList } from "@/components/products/InfiniteProductList"; import { ListingAnalytics } from "@/components/products/ListingAnalytics"; import { ListingFilterBar } from "@/components/products/ListingFilterBar"; import { ProductListingSkeleton } from "@/components/products/ProductListingSkeleton"; +import { PRODUCT_CARD_FIELDS } from "@/lib/data/cached"; import { type ListingSearchParams, listingKey, @@ -92,10 +93,17 @@ async function ProductListingInner({ // Base SDK list params for the current filter/sort/query state. // The client island reuses this when fetching subsequent pages. + // + // `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, ...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 6af418e4..db27b123 100644 --- a/src/lib/data/cached.ts +++ b/src/lib/data/cached.ts @@ -14,6 +14,27 @@ 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. + * + * `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", + "name", + "slug", + "thumbnail_url", + "purchasable", + "default_variant_id", + "price", + "original_price", + "categories", +]; + export const getCachedProduct = cache((slugOrId: string, expand: string[]) => getProduct(slugOrId, { expand }), );