Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/products/FeaturedProducts.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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,
);
Expand Down
61 changes: 31 additions & 30 deletions src/components/products/InfiniteProductList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -60,21 +53,35 @@ export function InfiniteProductList({
const t = useTranslations("products");
const [products, setProducts] = useState<Product[]>(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<HTMLDivElement>(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 () => {
Expand All @@ -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;
}
Expand All @@ -117,8 +125,8 @@ export function InfiniteProductList({
return () => observer.disconnect();
}, [loadNextPage]);

const grid = useMemo(
() => (
return (
<>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product, index) => (
<ProductCard
Expand All @@ -134,13 +142,6 @@ export function InfiniteProductList({
/>
))}
</div>
),
[products, basePath, categoryId, listId, listName, currency],
);

return (
<>
{grid}

<div
ref={sentinelRef}
Expand All @@ -152,7 +153,7 @@ export function InfiniteProductList({
{t("loadingMore")}
</div>
)}
{!hasMore && products.length > 0 && (
{!hasError && !hasMore && products.length > 0 && (
<p className="text-gray-500 text-sm">{t("noMoreProducts")}</p>
)}
</div>
Expand Down
8 changes: 8 additions & 0 deletions src/components/products/ProductListing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <ProductCard> 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,
Expand Down
21 changes: 21 additions & 0 deletions src/lib/data/cached.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ProductCard> 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",
];
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const getCachedProduct = cache((slugOrId: string, expand: string[]) =>
getProduct(slugOrId, { expand }),
);
Expand Down
Loading