From 21067ed9357508645dc957e0e3646487a0a8228d Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Tue, 14 Apr 2026 09:06:10 -0500 Subject: [PATCH 1/3] feat(core): add locale parameter to client.fetch() and harden beforeRequest Add optional locale parameter to client.fetch() for use in cached contexts. Wrap headers() call in beforeRequest with try/catch so it gracefully degrades inside unstable_cache. No changes to getChannelId or beforeRequest callback signatures. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/locale-param-client.md | 5 +++++ .changeset/locale-param-core.md | 5 +++++ core/client/index.ts | 20 ++++++++++++-------- packages/client/src/client.ts | 15 +++++++++++---- 4 files changed, 33 insertions(+), 12 deletions(-) create mode 100644 .changeset/locale-param-client.md create mode 100644 .changeset/locale-param-core.md diff --git a/.changeset/locale-param-client.md b/.changeset/locale-param-client.md new file mode 100644 index 0000000000..03e0bd989e --- /dev/null +++ b/.changeset/locale-param-client.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-client": patch +--- + +Add optional `locale` parameter to `client.fetch()`. This allows locale to be passed explicitly for use in cached contexts where `getLocale()` is unavailable. diff --git a/.changeset/locale-param-core.md b/.changeset/locale-param-core.md new file mode 100644 index 0000000000..20d86ca025 --- /dev/null +++ b/.changeset/locale-param-core.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Wrap IP forwarding headers (`X-Forwarded-For`, `True-Client-IP`) in try/catch for safety inside `unstable_cache` contexts where `headers()` is unavailable. diff --git a/core/client/index.ts b/core/client/index.ts index d14bcb463b..e15d72ae19 100644 --- a/core/client/index.ts +++ b/core/client/index.ts @@ -41,11 +41,11 @@ export const client = createClient({ logger: (process.env.NODE_ENV !== 'production' && process.env.CLIENT_LOGGER !== 'false') || process.env.CLIENT_LOGGER === 'true', - getChannelId: async (defaultChannelId: string) => { - const locale = await getLocale(); + getChannelId: async (defaultChannelId: string, locale?: string) => { + const resolvedLocale = locale ?? (await getLocale()); // We use the default channelId as a fallback, but it is not ideal in some scenarios. - return getChannelIdFromLocale(locale) ?? defaultChannelId; + return getChannelIdFromLocale(resolvedLocale) ?? defaultChannelId; }, beforeRequest: async (fetchOptions) => { // We can't serialize a `Headers` object within this method so we have to opt into using a plain object @@ -53,12 +53,16 @@ export const client = createClient({ const locale = await getLocale(); if (fetchOptions?.cache && ['no-store', 'no-cache'].includes(fetchOptions.cache)) { - const { headers } = await import('next/headers'); - const ipAddress = (await headers()).get('X-Forwarded-For'); + try { + const { headers } = await import('next/headers'); + const ipAddress = (await headers()).get('X-Forwarded-For'); - if (ipAddress) { - requestHeaders['X-Forwarded-For'] = ipAddress; - requestHeaders['True-Client-IP'] = ipAddress; + if (ipAddress) { + requestHeaders['X-Forwarded-For'] = ipAddress; + requestHeaders['True-Client-IP'] = ipAddress; + } + } catch { + // headers() is unavailable inside unstable_cache / 'use cache' contexts } } diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index dd25618110..f2700bdea7 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -49,7 +49,7 @@ type GraphQLErrorPolicy = 'none' | 'all' | 'auth' | 'ignore'; class Client { private backendUserAgent: string; private readonly defaultChannelId: string; - private getChannelId: (defaultChannelId: string) => Promise | string; + private getChannelId: (defaultChannelId: string, locale?: string) => Promise | string; private beforeRequest?: ( fetchOptions?: FetcherRequestInit, ) => Promise | undefined> | Partial | undefined; @@ -85,6 +85,7 @@ class Client { customerAccessToken?: string; fetchOptions?: FetcherRequestInit; channelId?: string; + locale?: string; errorPolicy?: GraphQLErrorPolicy; validateCustomerAccessToken?: boolean; }): Promise>; @@ -96,6 +97,7 @@ class Client { customerAccessToken?: string; fetchOptions?: FetcherRequestInit; channelId?: string; + locale?: string; errorPolicy?: GraphQLErrorPolicy; validateCustomerAccessToken?: boolean; }): Promise>; @@ -106,6 +108,7 @@ class Client { customerAccessToken, fetchOptions = {} as FetcherRequestInit, channelId, + locale, errorPolicy = 'none', validateCustomerAccessToken = true, }: { @@ -114,6 +117,7 @@ class Client { customerAccessToken?: string; fetchOptions?: FetcherRequestInit; channelId?: string; + locale?: string; errorPolicy?: GraphQLErrorPolicy; validateCustomerAccessToken?: boolean; }): Promise> { @@ -126,6 +130,7 @@ class Client { channelId, operationInfo.name, operationInfo.type, + locale, ); const { headers: additionalFetchHeaders = {}, ...additionalFetchOptions } = (await this.beforeRequest?.(fetchOptions)) ?? {}; @@ -143,6 +148,7 @@ class Client { ...(this.trustedProxySecret && { 'X-BC-Trusted-Proxy-Secret': this.trustedProxySecret }), ...Object.fromEntries(new Headers(additionalFetchHeaders).entries()), ...Object.fromEntries(new Headers(headers).entries()), + ...(locale && { 'Accept-Language': locale }), }, body: JSON.stringify({ query, @@ -210,8 +216,8 @@ class Client { return response.text(); } - private async getCanonicalUrl(channelId?: string) { - const resolvedChannelId = channelId ?? (await this.getChannelId(this.defaultChannelId)); + private async getCanonicalUrl(channelId?: string, locale?: string) { + const resolvedChannelId = channelId ?? (await this.getChannelId(this.defaultChannelId, locale)); return `https://store-${this.config.storeHash}-${resolvedChannelId}.${graphqlApiDomain}`; } @@ -220,8 +226,9 @@ class Client { channelId?: string, operationName?: string, operationType?: string, + locale?: string, ) { - const baseUrl = new URL(`${await this.getCanonicalUrl(channelId)}/graphql`); + const baseUrl = new URL(`${await this.getCanonicalUrl(channelId, locale)}/graphql`); if (operationName) { baseUrl.searchParams.set('operation', operationName); From 63aafa494fb0c9e2f15c6cb270b91a398545e7fc Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Mon, 13 Apr 2026 13:54:02 -0500 Subject: [PATCH 2/3] feat(core): wrap layout-level guest queries in unstable_cache Wrap home page, header, footer, SEO canonical, reCAPTCHA, and root layout metadata queries in unstable_cache for guest visitors. Authenticated requests continue to bypass the cache with no-store. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/guest-cache-layout.md | 5 ++ core/app/[locale]/(default)/page-data.ts | 29 +++++++-- core/app/[locale]/(default)/page.tsx | 2 +- core/app/[locale]/layout.tsx | 31 ++++++--- core/components/footer/index.tsx | 60 +++++++++++++----- core/components/header/index.tsx | 80 +++++++++++++++++------- core/lib/recaptcha.ts | 35 +++++++---- core/lib/seo/canonical.ts | 29 ++++++--- 8 files changed, 196 insertions(+), 75 deletions(-) create mode 100644 .changeset/guest-cache-layout.md diff --git a/.changeset/guest-cache-layout.md b/.changeset/guest-cache-layout.md new file mode 100644 index 0000000000..40f6958630 --- /dev/null +++ b/.changeset/guest-cache-layout.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Wrap layout-level guest queries (home page, header, footer, SEO canonical, reCAPTCHA, root layout metadata) in `unstable_cache` with configurable revalidation. Authenticated requests continue to bypass the cache. diff --git a/core/app/[locale]/(default)/page-data.ts b/core/app/[locale]/(default)/page-data.ts index ab78d520a6..754f7f4fe3 100644 --- a/core/app/[locale]/(default)/page-data.ts +++ b/core/app/[locale]/(default)/page-data.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -77,15 +78,35 @@ const HomePageQuery = graphql( [FeaturedProductsCarouselFragment, FeaturedProductsListFragment], ); -export const getPageData = cache( - async (currencyCode?: CurrencyCode, customerAccessToken?: string) => { +const getCachedPageData = unstable_cache( + async (locale: string, currencyCode?: CurrencyCode) => { const { data } = await client.fetch({ document: HomePageQuery, - customerAccessToken, variables: { currencyCode }, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return data; }, + ['home-page-data'], + { revalidate }, +); + +export const getPageData = cache( + async (locale: string, currencyCode?: CurrencyCode, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: HomePageQuery, + customerAccessToken, + variables: { currencyCode }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data; + } + + return getCachedPageData(locale, currencyCode); + }, ); diff --git a/core/app/[locale]/(default)/page.tsx b/core/app/[locale]/(default)/page.tsx index 77cf4db613..ebb7c52655 100644 --- a/core/app/[locale]/(default)/page.tsx +++ b/core/app/[locale]/(default)/page.tsx @@ -38,7 +38,7 @@ export default async function Home({ params }: Props) { const customerAccessToken = await getSessionCustomerAccessToken(); const currencyCode = await getPreferredCurrencyCode(); - return getPageData(currencyCode, customerAccessToken); + return getPageData(locale, currencyCode, customerAccessToken); }); const streamableFeaturedProducts = Streamable.from(async () => { diff --git a/core/app/[locale]/layout.tsx b/core/app/[locale]/layout.tsx index 11d3d88e85..ff6b823248 100644 --- a/core/app/[locale]/layout.tsx +++ b/core/app/[locale]/layout.tsx @@ -1,6 +1,7 @@ import { Analytics } from '@vercel/analytics/react'; import { SpeedInsights } from '@vercel/speed-insights/next'; import type { Metadata } from 'next'; +import { unstable_cache } from 'next/cache'; import { notFound } from 'next/navigation'; import { NextIntlClientProvider } from 'next-intl'; import { setRequestLocale } from 'next-intl/server'; @@ -53,15 +54,29 @@ const RootLayoutMetadataQuery = graphql( [WebAnalyticsFragment, ScriptsFragment], ); -const fetchRootLayoutMetadata = cache(async () => { - return await client.fetch({ - document: RootLayoutMetadataQuery, - fetchOptions: { next: { revalidate } }, - }); +const getCachedRootLayoutMetadata = unstable_cache( + async (locale: string) => { + return await client.fetch({ + document: RootLayoutMetadataQuery, + locale, + fetchOptions: { cache: 'no-store' }, + }); + }, + ['root-layout-metadata'], + { revalidate }, +); + +const fetchRootLayoutMetadata = cache(async (locale: string) => { + return getCachedRootLayoutMetadata(locale); }); -export async function generateMetadata(): Promise { - const { data } = await fetchRootLayoutMetadata(); +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + const { data } = await fetchRootLayoutMetadata(locale); const storeName = data.site.settings?.storeName ?? ''; @@ -119,7 +134,7 @@ interface Props extends PropsWithChildren { export default async function RootLayout({ params, children }: Props) { const { locale } = await params; - const rootData = await fetchRootLayoutMetadata(); + const rootData = await fetchRootLayoutMetadata(locale); const toastNotificationCookieData = await getToastNotification(); if (!routing.locales.includes(locale)) { diff --git a/core/components/footer/index.tsx b/core/components/footer/index.tsx index 8148c2c035..b911c0b5ab 100644 --- a/core/components/footer/index.tsx +++ b/core/components/footer/index.tsx @@ -6,7 +6,8 @@ import { SiX, SiYoutube, } from '@icons-pack/react-simple-icons'; -import { getTranslations } from 'next-intl/server'; +import { unstable_cache } from 'next/cache'; +import { getLocale, getTranslations } from 'next-intl/server'; import { cache, JSX } from 'react'; import { Streamable } from '@/vibes/soul/lib/streamable'; @@ -46,34 +47,63 @@ const socialIcons: Record = { YouTube: { icon: }, }; -const getFooterSections = cache( - async (customerAccessToken?: string, currencyCode?: CurrencyCode) => { +const getCachedFooterSections = unstable_cache( + async (locale: string, currencyCode?: CurrencyCode) => { const { data: response } = await client.fetch({ document: GetLinksAndSectionsQuery, - customerAccessToken, variables: { currencyCode }, - // Since this query is needed on every page, it's a good idea not to validate the customer access token. - // The 'cache' function also caches errors, so we might get caught in a redirect loop if the cache saves an invalid token error response. + locale, validateCustomerAccessToken: false, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + fetchOptions: { cache: 'no-store' }, }); return readFragment(FooterSectionsFragment, response).site; }, + ['footer-sections'], + { revalidate }, ); -const getFooterData = cache(async () => { - const { data: response } = await client.fetch({ - document: LayoutQuery, - fetchOptions: { next: { revalidate } }, - }); +const getFooterSections = cache( + async (locale: string, customerAccessToken?: string, currencyCode?: CurrencyCode) => { + if (customerAccessToken) { + const { data: response } = await client.fetch({ + document: GetLinksAndSectionsQuery, + customerAccessToken, + variables: { currencyCode }, + locale, + validateCustomerAccessToken: false, + fetchOptions: { cache: 'no-store' }, + }); + + return readFragment(FooterSectionsFragment, response).site; + } + + return getCachedFooterSections(locale, currencyCode); + }, +); + +const getCachedFooterData = unstable_cache( + async (locale: string) => { + const { data: response } = await client.fetch({ + document: LayoutQuery, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return readFragment(FooterFragment, response).site; + }, + ['footer-data'], + { revalidate }, +); - return readFragment(FooterFragment, response).site; +const getFooterData = cache(async (locale: string) => { + return getCachedFooterData(locale); }); export const Footer = async () => { const t = await getTranslations('Components.Footer'); - const data = await getFooterData(); + const locale = await getLocale(); + const data = await getFooterData(locale); const logo = data.settings ? logoTransformer(data.settings) : ''; @@ -96,7 +126,7 @@ export const Footer = async () => { const streamableSections = Streamable.from(async () => { const customerAccessToken = await getSessionCustomerAccessToken(); const currencyCode = await getPreferredCurrencyCode(); - const sectionsData = await getFooterSections(customerAccessToken, currencyCode); + const sectionsData = await getFooterSections(locale, customerAccessToken, currencyCode); return [ { diff --git a/core/components/header/index.tsx b/core/components/header/index.tsx index f686f8d65d..7ebe0c8c46 100644 --- a/core/components/header/index.tsx +++ b/core/components/header/index.tsx @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { getLocale, getTranslations } from 'next-intl/server'; import { cache } from 'react'; @@ -47,34 +48,64 @@ const getCartCount = cache(async (cartId: string, customerAccessToken?: string) return response.data.site.cart?.lineItems.totalQuantity ?? null; }); -const getHeaderLinks = cache(async (customerAccessToken?: string, currencyCode?: CurrencyCode) => { - const { data: response } = await client.fetch({ - document: GetLinksAndSectionsQuery, - customerAccessToken, - variables: { currencyCode }, - // Since this query is needed on every page, it's a good idea not to validate the customer access token. - // The 'cache' function also caches errors, so we might get caught in a redirect loop if the cache saves an invalid token error response. - validateCustomerAccessToken: false, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, - }); - - return readFragment(HeaderLinksFragment, response).site; -}); - -const getHeaderData = cache(async () => { - const { data: response } = await client.fetch({ - document: LayoutQuery, - fetchOptions: { next: { revalidate } }, - }); +const getCachedHeaderLinks = unstable_cache( + async (locale: string, currencyCode?: CurrencyCode) => { + const { data: response } = await client.fetch({ + document: GetLinksAndSectionsQuery, + variables: { currencyCode }, + locale, + validateCustomerAccessToken: false, + fetchOptions: { cache: 'no-store' }, + }); + + return readFragment(HeaderLinksFragment, response).site; + }, + ['header-links'], + { revalidate }, +); + +const getHeaderLinks = cache( + async (locale: string, customerAccessToken?: string, currencyCode?: CurrencyCode) => { + if (customerAccessToken) { + const { data: response } = await client.fetch({ + document: GetLinksAndSectionsQuery, + customerAccessToken, + variables: { currencyCode }, + locale, + validateCustomerAccessToken: false, + fetchOptions: { cache: 'no-store' }, + }); + + return readFragment(HeaderLinksFragment, response).site; + } - return readFragment(HeaderFragment, response).site; + return getCachedHeaderLinks(locale, currencyCode); + }, +); + +const getCachedHeaderData = unstable_cache( + async (locale: string) => { + const { data: response } = await client.fetch({ + document: LayoutQuery, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return readFragment(HeaderFragment, response).site; + }, + ['header-data'], + { revalidate }, +); + +const getHeaderData = cache(async (locale: string) => { + return getCachedHeaderData(locale); }); export const Header = async () => { const t = await getTranslations('Components.Header'); const locale = await getLocale(); - const data = await getHeaderData(); + const data = await getHeaderData(locale); const logo = data.settings ? logoTransformer(data.settings) : ''; @@ -101,7 +132,8 @@ export const Header = async () => { ]); // const customerAccessToken = await getSessionCustomerAccessToken(); // const currencyCode = await getPreferredCurrencyCode(); - const categoryTree = (await getHeaderLinks(customerAccessToken, currencyCode)).categoryTree; + const headerLinks = await getHeaderLinks(locale, customerAccessToken, currencyCode); + const categoryTree = headerLinks.categoryTree; /** To prevent the navigation menu from overflowing, we limit the number of categories to 6. To show a full list of categories, modify the `slice` method to remove the limit. @@ -128,8 +160,8 @@ export const Header = async () => { getSessionCustomerAccessToken(), getPreferredCurrencyCode(), ]); - const giftCertificateSettings = (await getHeaderLinks(customerAccessToken, currencyCode)) - .settings?.giftCertificates; + const headerLinksData = await getHeaderLinks(locale, customerAccessToken, currencyCode); + const giftCertificateSettings = headerLinksData.settings?.giftCertificates; return giftCertificateSettings?.isEnabled ?? false; }); diff --git a/core/lib/recaptcha.ts b/core/lib/recaptcha.ts index 77d5aa1518..0bac32042c 100644 --- a/core/lib/recaptcha.ts +++ b/core/lib/recaptcha.ts @@ -1,5 +1,6 @@ import 'server-only'; +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -24,22 +25,30 @@ export const ReCaptchaSettingsQuery = graphql(` } `); -export const getReCaptchaSettings = cache(async (): Promise => { - const { data } = await client.fetch({ - document: ReCaptchaSettingsQuery, - fetchOptions: { next: { revalidate } }, - }); +const getCachedReCaptchaSettings = unstable_cache( + async (): Promise => { + const { data } = await client.fetch({ + document: ReCaptchaSettingsQuery, + fetchOptions: { cache: 'no-store' }, + }); - const reCaptcha = data.site.settings?.reCaptcha; + const reCaptcha = data.site.settings?.reCaptcha; - if (!reCaptcha?.siteKey) { - return null; - } + if (!reCaptcha?.siteKey) { + return null; + } - return { - isEnabledOnStorefront: reCaptcha.isEnabledOnStorefront, - siteKey: reCaptcha.siteKey, - }; + return { + isEnabledOnStorefront: reCaptcha.isEnabledOnStorefront, + siteKey: reCaptcha.siteKey, + }; + }, + ['recaptcha-settings'], + { revalidate }, +); + +export const getReCaptchaSettings = cache(async (): Promise => { + return getCachedReCaptchaSettings(); }); export const getRecaptchaSiteKey = cache(async (): Promise => { diff --git a/core/lib/seo/canonical.ts b/core/lib/seo/canonical.ts index dd1573947a..cc3abfe55d 100644 --- a/core/lib/seo/canonical.ts +++ b/core/lib/seo/canonical.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -45,19 +46,27 @@ const VanityUrlQuery = graphql(` } `); -const getVanityUrl = cache(async () => { - const { data } = await client.fetch({ - document: VanityUrlQuery, - fetchOptions: { next: { revalidate } }, - }); +const getCachedVanityUrl = unstable_cache( + async () => { + const { data } = await client.fetch({ + document: VanityUrlQuery, + fetchOptions: { cache: 'no-store' }, + }); - const vanityUrl = data.site.settings?.url.vanityUrl; + const vanityUrl = data.site.settings?.url.vanityUrl; - if (!vanityUrl) { - throw new Error('Vanity URL not found in site settings'); - } + if (!vanityUrl) { + throw new Error('Vanity URL not found in site settings'); + } - return vanityUrl; + return vanityUrl; + }, + ['vanity-url'], + { revalidate }, +); + +const getVanityUrl = cache(async () => { + return getCachedVanityUrl(); }); export async function getMetadataAlternates(options: CanonicalUrlOptions) { From 5e3d7074ba5b930c566a163e6f391d29c59bc17f Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Mon, 13 Apr 2026 13:58:05 -0500 Subject: [PATCH 3/3] feat(core): wrap product page guest queries in unstable_cache Wrap product details, pricing, inventory, variant inventory, related products, inventory settings, and reviews queries in unstable_cache for guest visitors. Authenticated requests bypass the cache. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/guest-cache-product.md | 5 + .../product/[slug]/_components/reviews.tsx | 23 +- .../(default)/product/[slug]/page-data.ts | 218 +++++++++++++++--- .../(default)/product/[slug]/page.tsx | 18 +- 4 files changed, 213 insertions(+), 51 deletions(-) create mode 100644 .changeset/guest-cache-product.md diff --git a/.changeset/guest-cache-product.md b/.changeset/guest-cache-product.md new file mode 100644 index 0000000000..e97afc0ece --- /dev/null +++ b/.changeset/guest-cache-product.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Wrap product page guest queries in `unstable_cache` with configurable revalidation. Includes product details, pricing, inventory, reviews, and related products. Authenticated requests continue to bypass the cache. diff --git a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx index 47a533d5c9..df2bfddecd 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx @@ -1,4 +1,5 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { unstable_cache } from 'next/cache'; import { getFormatter, getTranslations } from 'next-intl/server'; import { createLoader, parseAsString, SearchParams } from 'nuqs/server'; import { cache } from 'react'; @@ -64,14 +65,22 @@ const ReviewsQuery = graphql( [ProductReviewSchemaFragment, PaginationFragment], ); -const getReviews = cache(async (productId: number, paginationArgs: object) => { - const { data } = await client.fetch({ - document: ReviewsQuery, - variables: { ...paginationArgs, entityId: productId }, - fetchOptions: { next: { revalidate } }, - }); +const getCachedReviews = unstable_cache( + async (productId: number, paginationArgs: object) => { + const { data } = await client.fetch({ + document: ReviewsQuery, + variables: { ...paginationArgs, entityId: productId }, + fetchOptions: { cache: 'no-store' }, + }); - return data.site.product; + return data.site.product; + }, + ['product-reviews'], + { revalidate }, +); + +const getReviews = cache(async (productId: number, paginationArgs: object) => { + return getCachedReviews(productId, paginationArgs); }); interface Props { diff --git a/core/app/[locale]/(default)/product/[slug]/page-data.ts b/core/app/[locale]/(default)/product/[slug]/page-data.ts index 02a8293735..002d3d69a4 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -154,17 +155,37 @@ const ProductPageMetadataQuery = graphql(` } `); -export const getProductPageMetadata = cache( - async (entityId: number, customerAccessToken?: string) => { +const getCachedProductPageMetadata = unstable_cache( + async (locale: string, entityId: number) => { const { data } = await client.fetch({ document: ProductPageMetadataQuery, variables: { entityId }, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return data.site.product; }, + ['product-page-metadata'], + { revalidate }, +); + +export const getProductPageMetadata = cache( + async (locale: string, entityId: number, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: ProductPageMetadataQuery, + variables: { entityId }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; + } + + return getCachedProductPageMetadata(locale, entityId); + }, ); const ProductQuery = graphql( @@ -200,16 +221,38 @@ const ProductQuery = graphql( [ProductOptionsFragment], ); -export const getProduct = cache(async (entityId: number, customerAccessToken?: string) => { - const { data } = await client.fetch({ - document: ProductQuery, - variables: { entityId }, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, - }); +const getCachedProduct = unstable_cache( + async (locale: string, entityId: number) => { + const { data } = await client.fetch({ + document: ProductQuery, + variables: { entityId }, + locale, + fetchOptions: { cache: 'no-store' }, + }); - return data.site; -}); + return data.site; + }, + ['product-data'], + { revalidate }, +); + +export const getProduct = cache( + async (locale: string, entityId: number, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: ProductQuery, + variables: { entityId }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site; + } + + return getCachedProduct(locale, entityId); + }, +); const StreamableProductVariantInventoryBySkuQuery = graphql(` query ProductVariantBySkuQuery($productId: Int!, $sku: String!) { @@ -249,17 +292,37 @@ const StreamableProductVariantInventoryBySkuQuery = graphql(` type VariantInventoryVariables = VariablesOf; -export const getStreamableProductVariantInventory = cache( - async (variables: VariantInventoryVariables, customerAccessToken?: string) => { +const getCachedStreamableProductVariantInventory = unstable_cache( + async (locale: string, variables: VariantInventoryVariables) => { const { data } = await client.fetch({ document: StreamableProductVariantInventoryBySkuQuery, variables, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate: 60 } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return data.site.product?.variants; }, + ['product-variant-inventory'], + { revalidate: 60 }, +); + +export const getStreamableProductVariantInventory = cache( + async (locale: string, variables: VariantInventoryVariables, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: StreamableProductVariantInventoryBySkuQuery, + variables, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product?.variants; + } + + return getCachedStreamableProductVariantInventory(locale, variables); + }, ); const StreamableProductQuery = graphql( @@ -322,17 +385,37 @@ const StreamableProductQuery = graphql( type Variables = VariablesOf; -export const getStreamableProduct = cache( - async (variables: Variables, customerAccessToken?: string) => { +const getCachedStreamableProduct = unstable_cache( + async (locale: string, variables: Variables) => { const { data } = await client.fetch({ document: StreamableProductQuery, variables, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return data.site.product; }, + ['streamable-product'], + { revalidate }, +); + +export const getStreamableProduct = cache( + async (locale: string, variables: Variables, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: StreamableProductQuery, + variables, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; + } + + return getCachedStreamableProduct(locale, variables); + }, ); const StreamableProductInventoryQuery = graphql( @@ -365,17 +448,37 @@ const StreamableProductInventoryQuery = graphql( type ProductInventoryVariables = VariablesOf; -export const getStreamableProductInventory = cache( - async (variables: ProductInventoryVariables, customerAccessToken?: string) => { +const getCachedStreamableProductInventory = unstable_cache( + async (locale: string, variables: ProductInventoryVariables) => { const { data } = await client.fetch({ document: StreamableProductInventoryQuery, variables, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate: 60 } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return data.site.product; }, + ['streamable-product-inventory'], + { revalidate: 60 }, +); + +export const getStreamableProductInventory = cache( + async (locale: string, variables: ProductInventoryVariables, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: StreamableProductInventoryQuery, + variables, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; + } + + return getCachedStreamableProductInventory(locale, variables); + }, ); // Fields that require currencyCode as a query variable @@ -409,17 +512,37 @@ const ProductPricingAndRelatedProductsQuery = graphql( [PricingFragment, FeaturedProductsCarouselFragment], ); -export const getProductPricingAndRelatedProducts = cache( - async (variables: Variables, customerAccessToken?: string) => { +const getCachedProductPricingAndRelatedProducts = unstable_cache( + async (locale: string, variables: Variables) => { const { data } = await client.fetch({ document: ProductPricingAndRelatedProductsQuery, variables, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return data.site.product; }, + ['product-pricing-related'], + { revalidate }, +); + +export const getProductPricingAndRelatedProducts = cache( + async (locale: string, variables: Variables, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: ProductPricingAndRelatedProductsQuery, + variables, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; + } + + return getCachedProductPricingAndRelatedProducts(locale, variables); + }, ); const InventorySettingsQuery = graphql(` @@ -440,12 +563,33 @@ const InventorySettingsQuery = graphql(` } `); -export const getStreamableInventorySettingsQuery = cache(async (customerAccessToken?: string) => { - const { data } = await client.fetch({ - document: InventorySettingsQuery, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, - }); +const getCachedInventorySettings = unstable_cache( + async (locale: string) => { + const { data } = await client.fetch({ + document: InventorySettingsQuery, + locale, + fetchOptions: { cache: 'no-store' }, + }); - return data.site.settings?.inventory; -}); + return data.site.settings?.inventory; + }, + ['inventory-settings'], + { revalidate }, +); + +export const getStreamableInventorySettingsQuery = cache( + async (locale: string, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: InventorySettingsQuery, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.settings?.inventory; + } + + return getCachedInventorySettings(locale); + }, +); diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index 8d2001ee8c..0de775747d 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -45,7 +45,7 @@ export async function generateMetadata({ params }: Props): Promise { const productId = Number(slug); - const product = await getProductPageMetadata(productId, customerAccessToken); + const product = await getProductPageMetadata(locale, productId, customerAccessToken); if (!product) { return notFound(); @@ -78,7 +78,7 @@ export default async function Product({ params, searchParams }: Props) { const productId = Number(slug); const [{ product: baseProduct, settings }, recaptchaSiteKey] = await Promise.all([ - getProduct(productId, customerAccessToken), + getProduct(locale, productId, customerAccessToken), getRecaptchaSiteKey(), ]); @@ -107,7 +107,7 @@ export default async function Product({ params, searchParams }: Props) { useDefaultOptionSelections: true, }; - const product = await getStreamableProduct(variables, customerAccessToken); + const product = await getStreamableProduct(locale, variables, customerAccessToken); if (!product) { return notFound(); @@ -123,7 +123,7 @@ export default async function Product({ params, searchParams }: Props) { entityId: Number(productId), }; - const product = await getStreamableProductInventory(variables, customerAccessToken); + const product = await getStreamableProductInventory(locale, variables, customerAccessToken); if (!product) { return notFound(); @@ -144,7 +144,11 @@ export default async function Product({ params, searchParams }: Props) { sku: product.sku, }; - const variants = await getStreamableProductVariantInventory(variables, customerAccessToken); + const variants = await getStreamableProductVariantInventory( + locale, + variables, + customerAccessToken, + ); if (!variants) { return undefined; @@ -174,7 +178,7 @@ export default async function Product({ params, searchParams }: Props) { currencyCode, }; - return await getProductPricingAndRelatedProducts(variables, customerAccessToken); + return await getProductPricingAndRelatedProducts(locale, variables, customerAccessToken); }); const streamablePrices = Streamable.from(async () => { @@ -242,7 +246,7 @@ export default async function Product({ params, searchParams }: Props) { }); const streamableInventorySettings = Streamable.from(async () => { - return await getStreamableInventorySettingsQuery(customerAccessToken); + return await getStreamableInventorySettingsQuery(locale, customerAccessToken); }); const getBackorderAvailabilityPrompt = ({