From d943fd3d46c38294313e6298bca274552d4a960e Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Tue, 3 Mar 2026 14:10:51 -0600 Subject: [PATCH 1/5] feat(core): add X-Correlation-ID header, cache guest queries separately --- .changeset/curly-hats-greet.md | 112 +++++++++ .changeset/plain-results-happen.md | 40 ++++ .../(auth)/change-password/page-data.ts | 29 ++- .../(default)/(auth)/change-password/page.tsx | 2 +- .../(default)/(auth)/register/page-data.ts | 63 ++--- .../(default)/(auth)/register/page.tsx | 2 +- .../(faceted)/brand/[slug]/page-data.ts | 41 +++- .../(default)/(faceted)/brand/[slug]/page.tsx | 42 ++-- .../(faceted)/category/[slug]/page-data.ts | 41 +++- .../(faceted)/category/[slug]/page.tsx | 39 +++- .../(faceted)/fetch-compare-products.ts | 35 ++- .../(faceted)/fetch-faceted-search.ts | 90 +++++++- .../(default)/(faceted)/search/page-data.ts | 22 +- .../(default)/(faceted)/search/page.tsx | 13 +- .../(default)/account/addresses/page-data.ts | 9 +- .../(default)/account/addresses/page.tsx | 22 +- .../account/orders/[id]/page-data.tsx | 5 +- .../(default)/account/orders/[id]/page.tsx | 4 +- .../(default)/account/orders/page-data.ts | 15 +- .../(default)/account/orders/page.tsx | 50 ++-- .../(default)/account/settings/page-data.tsx | 63 +++-- .../(default)/account/settings/page.tsx | 4 +- .../account/wishlists/[id]/page-data.ts | 44 ++-- .../(default)/account/wishlists/[id]/page.tsx | 62 ++++- .../(default)/account/wishlists/page-data.ts | 41 ++-- .../(default)/account/wishlists/page.tsx | 39 +++- .../(default)/blog/[blogId]/page-data.ts | 32 ++- .../[locale]/(default)/blog/[blogId]/page.tsx | 14 +- core/app/[locale]/(default)/blog/page-data.ts | 55 ++++- core/app/[locale]/(default)/blog/page.tsx | 16 +- .../cart/_actions/update-coupon-code.ts | 9 +- .../cart/_actions/update-gift-certificate.ts | 9 +- .../cart/_actions/update-shipping-info.ts | 9 +- core/app/[locale]/(default)/cart/page-data.ts | 32 ++- core/app/[locale]/(default)/cart/page.tsx | 25 +- .../[locale]/(default)/compare/page-data.ts | 42 +++- core/app/[locale]/(default)/compare/page.tsx | 34 ++- .../gift-certificates/balance/page.tsx | 2 +- .../(default)/gift-certificates/page-data.ts | 36 ++- .../(default)/gift-certificates/page.tsx | 2 +- .../gift-certificates/purchase/page-data.ts | 38 +-- .../gift-certificates/purchase/page.tsx | 10 +- core/app/[locale]/(default)/page-data.ts | 29 ++- core/app/[locale]/(default)/page.tsx | 2 +- .../(default)/product/[slug]/page-data.ts | 218 +++++++++++++++--- .../(default)/product/[slug]/page.tsx | 35 +-- .../webpages/[id]/contact/page-data.ts | 24 +- .../(default)/webpages/[id]/contact/page.tsx | 28 +-- .../webpages/[id]/normal/page-data.ts | 24 +- .../(default)/webpages/[id]/normal/page.tsx | 14 +- .../(default)/wishlist/[token]/page-data.ts | 40 ++-- .../(default)/wishlist/[token]/page.tsx | 45 +++- core/app/[locale]/layout.tsx | 19 +- core/client/correlation-id.ts | 8 + core/client/index.ts | 58 ++--- core/components/footer/index.tsx | 48 +++- core/components/header/index.tsx | 60 +++-- packages/client/src/client.ts | 17 +- 58 files changed, 1425 insertions(+), 538 deletions(-) create mode 100644 .changeset/curly-hats-greet.md create mode 100644 .changeset/plain-results-happen.md create mode 100644 core/client/correlation-id.ts diff --git a/.changeset/curly-hats-greet.md b/.changeset/curly-hats-greet.md new file mode 100644 index 0000000000..f9e24eed11 --- /dev/null +++ b/.changeset/curly-hats-greet.md @@ -0,0 +1,112 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +All GraphQL requests now include an `X-Correlation-ID` header containing a UUID that is stable for the duration of a single page render (via `React.cache`), making it easier to trace and correlate all requests made during a single render in server logs. + +Guest (unauthenticated) queries are now cached using `unstable_cache` with the configured revalidation interval, while authenticated requests continue to use `cache: 'no-store'`. This separates cacheable public data from session-specific data, improving performance for unauthenticated visitors. The `X-Forwarded-For` and `True-Client-IP` headers are only forwarded on uncached (`no-store`) requests since they are unavailable inside `unstable_cache`. + +## Migration + +### Step 1: Add the correlation ID helper + +Create `core/client/correlation-id.ts`: + +```ts +import { cache } from 'react'; + +/** + * Returns a stable correlation ID for the current request. + * React.cache ensures the same UUID is returned for all fetches within a + * single page render, while being unique across renders/requests. + */ +export const getCorrelationId = cache((): string => crypto.randomUUID()); +``` + +### Step 2: Update `core/client/index.ts` + +Update the `beforeRequest` hook to add the `X-Correlation-ID` header to all requests and to only forward `X-Forwarded-For` / `True-Client-IP` on uncached requests: + +```diff ++ import { getCorrelationId } from './correlation-id'; + + export const client = createClient({ + ... + beforeRequest: async (fetchOptions) => { + const requestHeaders: Record = {}; + +- try { +- const ipAddress = (await headers()).get('X-Forwarded-For'); +- if (ipAddress) { +- requestHeaders['X-Forwarded-For'] = ipAddress; +- requestHeaders['True-Client-IP'] = ipAddress; +- } +- } catch { +- // Not in a request context +- } ++ if (fetchOptions?.cache && ['no-store', 'no-cache'].includes(fetchOptions.cache)) { ++ try { ++ // headers() is a dynamic API unavailable inside unstable_cache; skip IP forwarding in that context ++ const ipAddress = (await headers()).get('X-Forwarded-For'); ++ if (ipAddress) { ++ requestHeaders['X-Forwarded-For'] = ipAddress; ++ requestHeaders['True-Client-IP'] = ipAddress; ++ } ++ } catch { ++ // Not in a request context (e.g. inside unstable_cache); IP forwarding not available ++ } ++ } ++ ++ requestHeaders['X-Correlation-ID'] = getCorrelationId(); + + return { headers: requestHeaders }; + }, + }); +``` + +### Step 3: Wrap guest queries with `unstable_cache` + +For each page data file, wrap the guest (unauthenticated) fetch in `unstable_cache` and branch on whether a `customerAccessToken` is present. Example pattern: + +```diff ++ import { unstable_cache } from 'next/cache'; + import { cache } from 'react'; ++ import { revalidate } from '~/client/revalidate-target'; + ++ const getCachedPageData = unstable_cache( ++ async (locale: string, ...args) => { ++ const { data } = await client.fetch({ ++ document: PageQuery, ++ variables: { ... }, ++ locale, ++ fetchOptions: { cache: 'no-store' }, ++ }); ++ return data; ++ }, ++ ['cache-key'], ++ { revalidate }, ++ ); + + export const getPageData = cache( +- async (locale: string, customerAccessToken?: string) => { +- const { data } = await client.fetch({ +- document: PageQuery, +- locale, +- fetchOptions: { cache: 'no-store' }, +- }); +- return data; +- }, ++ async (locale: string, customerAccessToken?: string) => { ++ if (customerAccessToken) { ++ const { data } = await client.fetch({ ++ document: PageQuery, ++ customerAccessToken, ++ locale, ++ fetchOptions: { cache: 'no-store' }, ++ }); ++ return data; ++ } ++ return getCachedPageData(locale); ++ }, + ); +``` diff --git a/.changeset/plain-results-happen.md b/.changeset/plain-results-happen.md new file mode 100644 index 0000000000..5302a96829 --- /dev/null +++ b/.changeset/plain-results-happen.md @@ -0,0 +1,40 @@ +--- +"@bigcommerce/catalyst-client": minor +--- + +`locale` is now a parameter on `client.fetch()` and is required for all queries. It is passed through to channel ID resolution so the `getChannelId` callback can return a locale-specific channel, and it is used to set the `Accept-Language` request header on each GraphQL call. + +The `getChannelId` config callback signature now accepts `locale` as an optional second argument: + +```diff +- getChannelId?: (defaultChannelId: string) => Promise | string; ++ getChannelId?: (defaultChannelId: string, locale?: string) => Promise | string; +``` + +## Migration + +### Step 1: Update all `client.fetch()` calls + +Pass `locale` as a parameter to every `client.fetch()` call across your page data files: + +```diff + const { data } = await client.fetch({ + document: PageQuery, + variables: { ... }, ++ locale, + fetchOptions: { cache: 'no-store' }, + }); +``` + +### Step 2: Update the `getChannelId` callback in `core/client/index.ts` + +Update the callback to accept and forward the `locale` parameter: + +```diff +- getChannelId: (defaultChannelId: string) => { +- return getChannelIdFromLocale() ?? defaultChannelId; +- }, ++ getChannelId: (defaultChannelId: string, locale?: string) => { ++ return getChannelIdFromLocale(locale) ?? defaultChannelId; ++ }, +``` diff --git a/core/app/[locale]/(default)/(auth)/change-password/page-data.ts b/core/app/[locale]/(default)/(auth)/change-password/page-data.ts index 43a72f2d3a..c48ab1d1a4 100644 --- a/core/app/[locale]/(default)/(auth)/change-password/page-data.ts +++ b/core/app/[locale]/(default)/(auth)/change-password/page-data.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -24,16 +25,24 @@ const ChangePasswordQuery = graphql(` } `); -export const getChangePasswordQuery = cache(async () => { - const response = await client.fetch({ - document: ChangePasswordQuery, - fetchOptions: { next: { revalidate } }, - }); +const getCachedChangePasswordQuery = unstable_cache( + async (locale: string) => { + const response = await client.fetch({ + document: ChangePasswordQuery, + locale, + }); - const passwordComplexitySettings = - response.data.site.settings?.customers?.passwordComplexitySettings; + const passwordComplexitySettings = + response.data.site.settings?.customers?.passwordComplexitySettings; - return { - passwordComplexitySettings, - }; + return { + passwordComplexitySettings, + }; + }, + ['get-change-password-query'], + { revalidate }, +); + +export const getChangePasswordQuery = cache(async (locale: string) => { + return getCachedChangePasswordQuery(locale); }); diff --git a/core/app/[locale]/(default)/(auth)/change-password/page.tsx b/core/app/[locale]/(default)/(auth)/change-password/page.tsx index 1e7e251a6a..fe8f70832d 100644 --- a/core/app/[locale]/(default)/(auth)/change-password/page.tsx +++ b/core/app/[locale]/(default)/(auth)/change-password/page.tsx @@ -38,7 +38,7 @@ export default async function ChangePassword({ params, searchParams }: Props) { return redirect({ href: '/login', locale }); } - const { passwordComplexitySettings } = await getChangePasswordQuery(); + const { passwordComplexitySettings } = await getChangePasswordQuery(locale); return ( { - const customerAccessToken = await getSessionCustomerAccessToken(); +const getCachedRegisterCustomerQuery = unstable_cache( + async (locale: string, { address, customer }: Props) => { + const response = await client.fetch({ + document: RegisterCustomerQuery, + variables: { + addressFilters: address?.filters, + addressSortBy: address?.sortBy, + customerFilters: customer?.filters, + customerSortBy: customer?.sortBy, + }, + fetchOptions: { cache: 'no-store' }, + locale, + }); - const response = await client.fetch({ - document: RegisterCustomerQuery, - variables: { - addressFilters: address?.filters, - addressSortBy: address?.sortBy, - customerFilters: customer?.filters, - customerSortBy: customer?.sortBy, - }, - fetchOptions: { cache: 'no-store' }, - customerAccessToken, - }); + const addressFields = response.data.site.settings?.formFields.shippingAddress; + const customerFields = response.data.site.settings?.formFields.customer; + const countries = response.data.geography.countries; + const passwordComplexitySettings = + response.data.site.settings?.customers?.passwordComplexitySettings; - const addressFields = response.data.site.settings?.formFields.shippingAddress; - const customerFields = response.data.site.settings?.formFields.customer; - const countries = response.data.geography.countries; - const passwordComplexitySettings = - response.data.site.settings?.customers?.passwordComplexitySettings; + if (!addressFields || !customerFields) { + return null; + } - if (!addressFields || !customerFields) { - return null; - } + return { + addressFields, + customerFields, + countries, + passwordComplexitySettings, + }; + }, + ['get-register-customer-query'], + { revalidate }, +); - return { - addressFields, - customerFields, - countries, - passwordComplexitySettings, - }; +export const getRegisterCustomerQuery = cache(async (locale: string, props: Props) => { + return getCachedRegisterCustomerQuery(locale, props); }); diff --git a/core/app/[locale]/(default)/(auth)/register/page.tsx b/core/app/[locale]/(default)/(auth)/register/page.tsx index 23004a7b8f..a4a12e2963 100644 --- a/core/app/[locale]/(default)/(auth)/register/page.tsx +++ b/core/app/[locale]/(default)/(auth)/register/page.tsx @@ -52,7 +52,7 @@ export default async function Register({ params }: Props) { const t = await getTranslations('Auth.Register'); - const registerCustomerData = await getRegisterCustomerQuery({ + const registerCustomerData = await getRegisterCustomerQuery(locale, { address: { sortBy: 'SORT_ORDER' }, customer: { sortBy: 'SORT_ORDER' }, }); diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts index 9bb605d215..c7e0296921 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -38,13 +39,35 @@ const BrandPageQuery = graphql(` } `); -export const getBrandPageData = cache(async (entityId: number, customerAccessToken?: string) => { - const response = await client.fetch({ - document: BrandPageQuery, - variables: { entityId }, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, - }); +const getCachedBrandPageData = unstable_cache( + async (locale: string, entityId: number) => { + const response = await client.fetch({ + document: BrandPageQuery, + variables: { entityId }, + locale, + fetchOptions: { cache: 'no-store' }, + }); - return response.data.site; -}); + return response.data.site; + }, + ['get-brand-page-data'], + { revalidate }, +); + +export const getBrandPageData = cache( + async (locale: string, entityId: number, customerAccessToken?: string) => { + if (customerAccessToken) { + const response = await client.fetch({ + document: BrandPageQuery, + variables: { entityId }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return response.data.site; + } + + return getCachedBrandPageData(locale, entityId); + }, +); diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx index 4278def31c..8433461b2c 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx @@ -30,9 +30,14 @@ const getCachedBrand = cache((brandId: string) => { const compareLoader = createCompareLoader(); const createBrandSearchParamsLoader = cache( - async (brandId: string, customerAccessToken?: string) => { + async (locale: string, brandId: string, customerAccessToken?: string) => { const cachedBrand = getCachedBrand(brandId); - const brandSearch = await fetchFacetedSearch(cachedBrand, undefined, customerAccessToken); + const brandSearch = await fetchFacetedSearch( + locale, + cachedBrand, + undefined, + customerAccessToken, + ); const brandFacets = brandSearch.facets.items.filter( (facet) => facet.__typename !== 'BrandSearchFilter', ); @@ -68,12 +73,14 @@ interface Props { } export async function generateMetadata(props: Props): Promise { - const { slug, locale } = await props.params; - const customerAccessToken = await getSessionCustomerAccessToken(); + const [{ slug, locale }, customerAccessToken] = await Promise.all([ + props.params, + getSessionCustomerAccessToken(), + ]); const brandId = Number(slug); - const { brand } = await getBrandPageData(brandId, customerAccessToken); + const { brand } = await getBrandPageData(locale, brandId, customerAccessToken); if (!brand) { return notFound(); @@ -90,16 +97,17 @@ export async function generateMetadata(props: Props): Promise { } export default async function Brand(props: Props) { - const { locale, slug } = await props.params; - const customerAccessToken = await getSessionCustomerAccessToken(); + const [{ locale, slug }, customerAccessToken, t] = await Promise.all([ + props.params, + getSessionCustomerAccessToken(), + getTranslations('Faceted'), + ]); setRequestLocale(locale); - const t = await getTranslations('Faceted'); - const brandId = Number(slug); - const { brand, settings } = await getBrandPageData(brandId, customerAccessToken); + const { brand, settings } = await getBrandPageData(locale, brandId, customerAccessToken); if (!brand) { return notFound(); @@ -114,10 +122,11 @@ export default async function Brand(props: Props) { const searchParams = await props.searchParams; const currencyCode = await getPreferredCurrencyCode(); - const loadSearchParams = await createBrandSearchParamsLoader(slug, customerAccessToken); + const loadSearchParams = await createBrandSearchParamsLoader(locale, slug, customerAccessToken); const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; const search = await fetchFacetedSearch( + locale, { ...searchParams, ...parsedSearchParams, @@ -162,10 +171,15 @@ export default async function Brand(props: Props) { const streamableFilters = Streamable.from(async () => { const searchParams = await props.searchParams; - const loadSearchParams = await createBrandSearchParamsLoader(slug, customerAccessToken); + const loadSearchParams = await createBrandSearchParamsLoader(locale, slug, customerAccessToken); const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; const cachedBrand = getCachedBrand(slug); - const categorySearch = await fetchFacetedSearch(cachedBrand, undefined, customerAccessToken); + const categorySearch = await fetchFacetedSearch( + locale, + cachedBrand, + undefined, + customerAccessToken, + ); const refinedSearch = await streamableFacetedSearch; const allFacets = categorySearch.facets.items.filter( @@ -195,7 +209,7 @@ export default async function Brand(props: Props) { const compareIds = { entityIds: compare ? compare.map((id: string) => Number(id)) : [] }; - const products = await getCompareProductsData(compareIds, customerAccessToken); + const products = await getCompareProductsData(locale, compareIds, customerAccessToken); return products.map((product) => ({ id: product.entityId.toString(), diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts index 6c2c4633fe..2e49f085d5 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -58,13 +59,35 @@ const CategoryPageQuery = graphql( [BreadcrumbsCategoryFragment], ); -export const getCategoryPageData = cache(async (entityId: number, customerAccessToken?: string) => { - const response = await client.fetch({ - document: CategoryPageQuery, - variables: { entityId }, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, - }); +const getCachedCategoryPageData = unstable_cache( + async (locale: string, entityId: number) => { + const response = await client.fetch({ + document: CategoryPageQuery, + variables: { entityId }, + locale, + fetchOptions: { cache: 'no-store' }, + }); - return response.data.site; -}); + return response.data.site; + }, + ['get-category-page-data'], + { revalidate }, +); + +export const getCategoryPageData = cache( + async (locale: string, entityId: number, customerAccessToken?: string) => { + if (customerAccessToken) { + const response = await client.fetch({ + document: CategoryPageQuery, + variables: { entityId }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return response.data.site; + } + + return getCachedCategoryPageData(locale, entityId); + }, +); diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx index ee143281b5..4a88820965 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx @@ -32,9 +32,14 @@ const getCachedCategory = cache((categoryId: number) => { const compareLoader = createCompareLoader(); const createCategorySearchParamsLoader = cache( - async (categoryId: number, customerAccessToken?: string) => { + async (locale: string, categoryId: number, customerAccessToken?: string) => { const cachedCategory = getCachedCategory(categoryId); - const categorySearch = await fetchFacetedSearch(cachedCategory, undefined, customerAccessToken); + const categorySearch = await fetchFacetedSearch( + locale, + cachedCategory, + undefined, + customerAccessToken, + ); const categoryFacets = categorySearch.facets.items.filter( (facet) => facet.__typename !== 'CategorySearchFilter', ); @@ -70,12 +75,14 @@ interface Props { } export async function generateMetadata(props: Props): Promise { - const { slug, locale } = await props.params; - const customerAccessToken = await getSessionCustomerAccessToken(); + const [{ slug, locale }, customerAccessToken] = await Promise.all([ + props.params, + getSessionCustomerAccessToken(), + ]); const categoryId = Number(slug); - const { category } = await getCategoryPageData(categoryId, customerAccessToken); + const { category } = await getCategoryPageData(locale, categoryId, customerAccessToken); if (!category) { return notFound(); @@ -97,16 +104,18 @@ export async function generateMetadata(props: Props): Promise { } export default async function Category(props: Props) { - const { slug, locale } = await props.params; - const customerAccessToken = await getSessionCustomerAccessToken(); + const [{ slug, locale }, customerAccessToken, t] = await Promise.all([ + props.params, + getSessionCustomerAccessToken(), + getTranslations('Faceted'), + ]); setRequestLocale(locale); - const t = await getTranslations('Faceted'); - const categoryId = Number(slug); const { category, settings, categoryTree } = await getCategoryPageData( + locale, categoryId, customerAccessToken, ); @@ -130,12 +139,14 @@ export default async function Category(props: Props) { const currencyCode = await getPreferredCurrencyCode(); const loadSearchParams = await createCategorySearchParamsLoader( + locale, categoryId, customerAccessToken, ); const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; const search = await fetchFacetedSearch( + locale, { ...searchParams, ...parsedSearchParams, @@ -182,12 +193,18 @@ export default async function Category(props: Props) { const searchParams = await props.searchParams; const loadSearchParams = await createCategorySearchParamsLoader( + locale, categoryId, customerAccessToken, ); const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; const cachedCategory = getCachedCategory(categoryId); - const categorySearch = await fetchFacetedSearch(cachedCategory, undefined, customerAccessToken); + const categorySearch = await fetchFacetedSearch( + locale, + cachedCategory, + undefined, + customerAccessToken, + ); const refinedSearch = await streamableFacetedSearch; const allFacets = categorySearch.facets.items.filter( @@ -234,7 +251,7 @@ export default async function Category(props: Props) { const compareIds = { entityIds: compare ? compare.map((id: string) => Number(id)) : [] }; - const products = await getCompareProducts(compareIds, customerAccessToken); + const products = await getCompareProducts(locale, compareIds, customerAccessToken); return products.map((product) => ({ id: product.entityId.toString(), diff --git a/core/app/[locale]/(default)/(faceted)/fetch-compare-products.ts b/core/app/[locale]/(default)/(faceted)/fetch-compare-products.ts index 8033dc8235..af4f0139ce 100644 --- a/core/app/[locale]/(default)/(faceted)/fetch-compare-products.ts +++ b/core/app/[locale]/(default)/(faceted)/fetch-compare-products.ts @@ -1,5 +1,6 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { VariablesOf } from 'gql.tada'; +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { z } from 'zod'; @@ -42,8 +43,8 @@ const CompareProductsQuery = graphql(` type Variables = VariablesOf; -export const getCompareProducts = cache( - async (variables: Variables, customerAccessToken?: string) => { +const getCachedCompareProducts = unstable_cache( + async (locale: string, variables: Variables) => { const parsedVariables = CompareProductsSchema.parse(variables); if (parsedVariables.entityIds.length === 0) { @@ -53,10 +54,36 @@ export const getCompareProducts = cache( const response = await client.fetch({ document: CompareProductsQuery, variables: { ...parsedVariables, first: MAX_COMPARE_LIMIT }, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return removeEdgesAndNodes(response.data.site.products); }, + ['get-compare-products'], + { revalidate }, +); + +export const getCompareProducts = cache( + async (locale: string, variables: Variables, customerAccessToken?: string) => { + if (customerAccessToken) { + const parsedVariables = CompareProductsSchema.parse(variables); + + if (parsedVariables.entityIds.length === 0) { + return []; + } + + const response = await client.fetch({ + document: CompareProductsQuery, + variables: { ...parsedVariables, first: MAX_COMPARE_LIMIT }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return removeEdgesAndNodes(response.data.site.products); + } + + return getCachedCompareProducts(locale, variables); + }, ); diff --git a/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts b/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts index 115d639d1c..019b33e35d 100644 --- a/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts +++ b/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts @@ -1,4 +1,5 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { z } from 'zod'; @@ -178,11 +179,11 @@ interface ProductSearch { filters: SearchProductsFiltersInput; } -const getProductSearchResults = cache( +const getCachedProductSearchResults = unstable_cache( async ( + locale: string, { limit = 9, after, before, sort, filters }: ProductSearch, currencyCode?: CurrencyCode, - customerAccessToken?: string, ) => { const filterArgs = { filters, sort }; const paginationArgs = before ? { last: limit, before } : { first: limit, after }; @@ -190,12 +191,11 @@ const getProductSearchResults = cache( const response = await client.fetch({ document: GetProductSearchResultsQuery, variables: { ...filterArgs, ...paginationArgs, currencyCode }, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate: 300 } }, + locale, + fetchOptions: { cache: 'no-store' }, }); const { site } = response.data; - const searchResults = site.search.searchProducts; const items = removeEdgesAndNodes(searchResults.products).map((product) => ({ @@ -242,6 +242,84 @@ const getProductSearchResults = cache( }, }; }, + ['get-product-search-results'], + { revalidate: 300 }, +); + +const getProductSearchResults = cache( + // We need to make sure the reference passed into this function is the same if we want it to be memoized. + async ( + locale: string, + { limit = 9, after, before, sort, filters }: ProductSearch, + currencyCode?: CurrencyCode, + customerAccessToken?: string, + ) => { + if (customerAccessToken) { + const filterArgs = { filters, sort }; + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; + + const response = await client.fetch({ + document: GetProductSearchResultsQuery, + variables: { ...filterArgs, ...paginationArgs, currencyCode }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + const { site } = response.data; + const searchResults = site.search.searchProducts; + + const items = removeEdgesAndNodes(searchResults.products).map((product) => ({ + ...product, + })); + + return { + facets: { + items: removeEdgesAndNodes(searchResults.filters).map((node) => { + switch (node.__typename) { + case 'BrandSearchFilter': + return { + ...node, + brands: removeEdgesAndNodes(node.brands), + }; + + case 'CategorySearchFilter': + return { + ...node, + categories: removeEdgesAndNodes(node.categories), + }; + + case 'ProductAttributeSearchFilter': + return { + ...node, + attributes: removeEdgesAndNodes(node.attributes), + }; + + case 'RatingSearchFilter': + return { + ...node, + ratings: removeEdgesAndNodes(node.ratings), + }; + + default: + return node; + } + }), + }, + products: { + collectionInfo: searchResults.products.collectionInfo, + pageInfo: searchResults.products.pageInfo, + items, + }, + }; + } + + return getCachedProductSearchResults( + locale, + { limit, after, before, sort, filters }, + currencyCode, + ); + }, ); const SearchParamSchema = z.union([z.string(), z.array(z.string()), z.undefined()]); @@ -406,6 +484,7 @@ export const PublicToPrivateParams = PublicSearchParamsSchema.catchall(SearchPar export const fetchFacetedSearch = cache( // We need to make sure the reference passed into this function is the same if we want it to be memoized. async ( + locale: string, params: z.input, currencyCode?: CurrencyCode, customerAccessToken?: string, @@ -413,6 +492,7 @@ export const fetchFacetedSearch = cache( const { after, before, limit = 9, sort, filters } = PublicToPrivateParams.parse(params); return getProductSearchResults( + locale, { after, before, diff --git a/core/app/[locale]/(default)/(faceted)/search/page-data.ts b/core/app/[locale]/(default)/(faceted)/search/page-data.ts index 37f571c49b..607832f973 100644 --- a/core/app/[locale]/(default)/(faceted)/search/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/search/page-data.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -29,11 +30,20 @@ const SearchPageQuery = graphql(` } `); -export const getSearchPageData = cache(async () => { - const response = await client.fetch({ - document: SearchPageQuery, - fetchOptions: { next: { revalidate } }, - }); +const getCachedSearchPageData = unstable_cache( + async (locale: string) => { + const response = await client.fetch({ + document: SearchPageQuery, + locale, + fetchOptions: { cache: 'no-store' }, + }); - return response.data.site; + return response.data.site; + }, + ['get-search-page-data'], + { revalidate }, +); + +export const getSearchPageData = cache(async (locale: string) => { + return getCachedSearchPageData(locale); }); diff --git a/core/app/[locale]/(default)/(faceted)/search/page.tsx b/core/app/[locale]/(default)/(faceted)/search/page.tsx index bc86471c32..d5f8007a34 100644 --- a/core/app/[locale]/(default)/(faceted)/search/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/search/page.tsx @@ -22,14 +22,14 @@ import { getSearchPageData } from './page-data'; const compareLoader = createCompareLoader(); const createSearchSearchParamsLoader = cache( - async (searchParams: SearchParams, customerAccessToken?: string) => { + async (locale: string, searchParams: SearchParams, customerAccessToken?: string) => { const searchTerm = typeof searchParams.term === 'string' ? searchParams.term : ''; if (!searchTerm) { return null; } - const search = await fetchFacetedSearch(searchParams, undefined, customerAccessToken); + const search = await fetchFacetedSearch(locale, searchParams, undefined, customerAccessToken); const searchFacets = search.facets.items; const transformedSearchFacets = await facetsTransformer({ refinedFacets: searchFacets, @@ -76,7 +76,7 @@ export default async function Search(props: Props) { const t = await getTranslations('Faceted'); - const { settings } = await getSearchPageData(); + const { settings } = await getSearchPageData(locale); const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); @@ -89,12 +89,14 @@ export default async function Search(props: Props) { const currencyCode = await getPreferredCurrencyCode(); const loadSearchParams = await createSearchSearchParamsLoader( + locale, searchParams, customerAccessToken, ); const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; const search = await fetchFacetedSearch( + locale, { ...searchParams, ...parsedSearchParams, @@ -186,11 +188,12 @@ export default async function Search(props: Props) { } const loadSearchParams = await createSearchSearchParamsLoader( + locale, searchParams, customerAccessToken, ); const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; - const categorySearch = await fetchFacetedSearch({}, undefined, customerAccessToken); + const categorySearch = await fetchFacetedSearch(locale, {}, undefined, customerAccessToken); const refinedSearch = await streamableFacetedSearch; const allFacets = categorySearch.facets.items.filter( @@ -221,7 +224,7 @@ export default async function Search(props: Props) { const compareIds = { entityIds: compare ? compare.map((id: string) => Number(id)) : [] }; - const products = await getCompareProductsData(compareIds, customerAccessToken); + const products = await getCompareProductsData(locale, compareIds, customerAccessToken); return products.map((product) => ({ id: product.entityId.toString(), diff --git a/core/app/[locale]/(default)/account/addresses/page-data.ts b/core/app/[locale]/(default)/account/addresses/page-data.ts index 66433985ff..f44e833967 100644 --- a/core/app/[locale]/(default)/account/addresses/page-data.ts +++ b/core/app/[locale]/(default)/account/addresses/page-data.ts @@ -1,7 +1,6 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { cache } from 'react'; -import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { PaginationFragment } from '~/client/fragments/pagination'; import { graphql } from '~/client/graphql'; @@ -70,13 +69,17 @@ interface Pagination { } export const getCustomerAddresses = cache( - async ({ before = '', after = '', limit = 10 }: Pagination) => { - const customerAccessToken = await getSessionCustomerAccessToken(); + async ( + locale: string, + { before = '', after = '', limit = 10 }: Pagination, + customerAccessToken?: string, + ) => { const paginationArgs = before ? { last: limit, before } : { first: limit, after }; const response = await client.fetch({ document: GetCustomerAddressesQuery, variables: { ...paginationArgs }, + locale, customerAccessToken, fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, }); diff --git a/core/app/[locale]/(default)/account/addresses/page.tsx b/core/app/[locale]/(default)/account/addresses/page.tsx index 49c02451a6..865ba4f575 100644 --- a/core/app/[locale]/(default)/account/addresses/page.tsx +++ b/core/app/[locale]/(default)/account/addresses/page.tsx @@ -3,6 +3,7 @@ import { notFound } from 'next/navigation'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Address, AddressListSection } from '@/vibes/soul/sections/address-list-section'; +import { getSessionCustomerAccessToken } from '~/auth'; import { formFieldTransformer, injectCountryCodeOptions, @@ -41,13 +42,20 @@ export default async function Addresses({ params, searchParams }: Props) { setRequestLocale(locale); - const t = await getTranslations('Account.Addresses'); - const { before, after } = await searchParams; - - const data = await getCustomerAddresses({ - ...(after && { after }), - ...(before && { before }), - }); + const [customerAccessToken, t, { before, after }] = await Promise.all([ + getSessionCustomerAccessToken(), + getTranslations('Account.Addresses'), + searchParams, + ]); + + const data = await getCustomerAddresses( + locale, + { + ...(after && { after }), + ...(before && { before }), + }, + customerAccessToken, + ); if (!data) { notFound(); diff --git a/core/app/[locale]/(default)/account/orders/[id]/page-data.tsx b/core/app/[locale]/(default)/account/orders/[id]/page-data.tsx index 9fd6c21afc..c69981298a 100644 --- a/core/app/[locale]/(default)/account/orders/[id]/page-data.tsx +++ b/core/app/[locale]/(default)/account/orders/[id]/page-data.tsx @@ -1,7 +1,6 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { cache } from 'react'; -import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { TAGS } from '~/client/tags'; @@ -155,9 +154,7 @@ const CustomerOrderDetails = graphql( [OrderItemFragment, OrderGiftCertificateItemFragment], ); -export const getCustomerOrderDetails = cache(async (id: number) => { - const customerAccessToken = await getSessionCustomerAccessToken(); - +export const getCustomerOrderDetails = cache(async (id: number, customerAccessToken?: string) => { const response = await client.fetch({ document: CustomerOrderDetails, variables: { diff --git a/core/app/[locale]/(default)/account/orders/[id]/page.tsx b/core/app/[locale]/(default)/account/orders/[id]/page.tsx index 20e166389f..4fbc166565 100644 --- a/core/app/[locale]/(default)/account/orders/[id]/page.tsx +++ b/core/app/[locale]/(default)/account/orders/[id]/page.tsx @@ -3,6 +3,7 @@ import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/serve import { Streamable } from '@/vibes/soul/lib/streamable'; import { OrderDetailsSection } from '@/vibes/soul/sections/order-details-section'; +import { getSessionCustomerAccessToken } from '~/auth'; import { orderDetailsTransformer } from '~/data-transformers/order-details-transformer'; import { getCustomerOrderDetails } from './page-data'; @@ -23,7 +24,8 @@ export default async function OrderDetails(props: Props) { const format = await getFormatter(); const streamableOrder = Streamable.from(async () => { - const order = await getCustomerOrderDetails(Number(id)); + const customerAccessToken = await getSessionCustomerAccessToken(); + const order = await getCustomerOrderDetails(Number(id), customerAccessToken); if (!order) { notFound(); diff --git a/core/app/[locale]/(default)/account/orders/page-data.ts b/core/app/[locale]/(default)/account/orders/page-data.ts index a2b240f4f4..85b3f32c9f 100644 --- a/core/app/[locale]/(default)/account/orders/page-data.ts +++ b/core/app/[locale]/(default)/account/orders/page-data.ts @@ -1,7 +1,6 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { cache } from 'react'; -import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { PaginationFragment } from '~/client/fragments/pagination'; import { graphql, VariablesOf } from '~/client/graphql'; @@ -89,14 +88,11 @@ interface CustomerOrdersArgs { } export const getCustomerOrders = cache( - async ({ - before = '', - after = '', - filterByStatus, - filterByDateRange, - limit = 5, - }: CustomerOrdersArgs) => { - const customerAccessToken = await getSessionCustomerAccessToken(); + async ( + locale: string, + { before = '', after = '', filterByStatus, filterByDateRange, limit = 5 }: CustomerOrdersArgs, + customerAccessToken?: string, + ) => { const paginationArgs = before ? { last: limit, before } : { first: limit, after }; const filtersArgs = { filters: { @@ -107,6 +103,7 @@ export const getCustomerOrders = cache( const response = await client.fetch({ document: CustomerAllOrders, variables: { ...paginationArgs, ...filtersArgs }, + locale, customerAccessToken, fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, errorPolicy: 'auth', diff --git a/core/app/[locale]/(default)/account/orders/page.tsx b/core/app/[locale]/(default)/account/orders/page.tsx index 65d4167426..45b7d14940 100644 --- a/core/app/[locale]/(default)/account/orders/page.tsx +++ b/core/app/[locale]/(default)/account/orders/page.tsx @@ -1,6 +1,7 @@ import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; import { Order, OrderList } from '@/vibes/soul/sections/order-list'; +import { getSessionCustomerAccessToken } from '~/auth'; import { ordersTransformer } from '~/data-transformers/orders-transformer'; import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; @@ -15,12 +16,21 @@ interface Props { }>; } -async function getOrders(after?: string, before?: string): Promise { +async function getOrders( + locale: string, + after?: string, + before?: string, + customerAccessToken?: string, +): Promise { const format = await getFormatter(); - const customerOrdersDetails = await getCustomerOrders({ - ...(after && { after }), - ...(before && { before }), - }); + const customerOrdersDetails = await getCustomerOrders( + locale, + { + ...(after && { after }), + ...(before && { before }), + }, + customerAccessToken, + ); if (!customerOrdersDetails) { return []; @@ -31,11 +41,20 @@ async function getOrders(after?: string, before?: string): Promise { return ordersTransformer(orders, format); } -async function getPaginationInfo(after?: string, before?: string) { - const customerOrdersDetails = await getCustomerOrders({ - ...(after && { after }), - ...(before && { before }), - }); +async function getPaginationInfo( + locale: string, + after?: string, + before?: string, + customerAccessToken?: string, +) { + const customerOrdersDetails = await getCustomerOrders( + locale, + { + ...(after && { after }), + ...(before && { before }), + }, + customerAccessToken, + ); return pageInfoTransformer(customerOrdersDetails?.pageInfo ?? defaultPageInfo); } @@ -45,16 +64,19 @@ export default async function Orders({ params, searchParams }: Props) { setRequestLocale(locale); - const { before, after } = await searchParams; - const t = await getTranslations('Account.Orders'); + const [{ before, after }, t, customerAccessToken] = await Promise.all([ + searchParams, + getTranslations('Account.Orders'), + getSessionCustomerAccessToken(), + ]); return ( { - const customerAccessToken = await getSessionCustomerAccessToken(); +export const getAccountSettingsQuery = cache( + async ({ address, customer }: Props = {}, customerAccessToken?: string) => { + const response = await client.fetch({ + document: AccountSettingsQuery, + variables: { + addressFilters: address?.filters, + addressSortBy: address?.sortBy, + customerFilters: customer?.filters, + customerSortBy: customer?.sortBy, + }, + fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, + customerAccessToken, + }); - const response = await client.fetch({ - document: AccountSettingsQuery, - variables: { - addressFilters: address?.filters, - addressSortBy: address?.sortBy, - customerFilters: customer?.filters, - customerSortBy: customer?.sortBy, - }, - fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, - customerAccessToken, - }); + const addressFields = response.data.site.settings?.formFields.shippingAddress; + const customerFields = response.data.site.settings?.formFields.customer; + const customerInfo = response.data.customer; + const newsletterSettings = response.data.site.settings?.newsletter; + const passwordComplexitySettings = + response.data.site.settings?.customers?.passwordComplexitySettings; - const addressFields = response.data.site.settings?.formFields.shippingAddress; - const customerFields = response.data.site.settings?.formFields.customer; - const customerInfo = response.data.customer; - const newsletterSettings = response.data.site.settings?.newsletter; - const passwordComplexitySettings = - response.data.site.settings?.customers?.passwordComplexitySettings; - - if (!addressFields || !customerFields || !customerInfo) { - return null; - } + if (!addressFields || !customerFields || !customerInfo) { + return null; + } - return { - addressFields, - customerFields, - customerInfo, - newsletterSettings, - passwordComplexitySettings, - }; -}); + return { + addressFields, + customerFields, + customerInfo, + newsletterSettings, + passwordComplexitySettings, + }; + }, +); diff --git a/core/app/[locale]/(default)/account/settings/page.tsx b/core/app/[locale]/(default)/account/settings/page.tsx index cad145dc6f..26cdaad81e 100644 --- a/core/app/[locale]/(default)/account/settings/page.tsx +++ b/core/app/[locale]/(default)/account/settings/page.tsx @@ -4,6 +4,7 @@ import { notFound } from 'next/navigation'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { AccountSettingsSection } from '@/vibes/soul/sections/account-settings'; +import { getSessionCustomerAccessToken } from '~/auth'; import { changePassword } from './_actions/change-password'; import { updateCustomer } from './_actions/update-customer'; @@ -30,8 +31,9 @@ export default async function Settings({ params }: Props) { setRequestLocale(locale); const t = await getTranslations('Account.Settings'); + const customerAccessToken = await getSessionCustomerAccessToken(); - const accountSettings = await getAccountSettingsQuery(); + const accountSettings = await getAccountSettingsQuery({}, customerAccessToken); if (!accountSettings) { notFound(); diff --git a/core/app/[locale]/(default)/account/wishlists/[id]/page-data.ts b/core/app/[locale]/(default)/account/wishlists/[id]/page-data.ts index ae14ab4173..602f3729bc 100644 --- a/core/app/[locale]/(default)/account/wishlists/[id]/page-data.ts +++ b/core/app/[locale]/(default)/account/wishlists/[id]/page-data.ts @@ -1,11 +1,10 @@ import { cache } from 'react'; -import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { TAGS } from '~/client/tags'; +import type { CurrencyCode } from '~/components/header/fragment'; import { WishlistPaginatedItemsFragment } from '~/components/wishlist/fragment'; -import { getPreferredCurrencyCode } from '~/lib/currency'; const WishlistDetailsQuery = graphql( ` @@ -37,23 +36,30 @@ interface Pagination { after: string | null; } -export const getCustomerWishlist = cache(async (entityId: number, pagination: Pagination) => { - const { before, after, limit = 9 } = pagination; - const customerAccessToken = await getSessionCustomerAccessToken(); - const currencyCode = await getPreferredCurrencyCode(); - const paginationArgs = before ? { last: limit, before } : { first: limit, after }; - const response = await client.fetch({ - document: WishlistDetailsQuery, - variables: { ...paginationArgs, currencyCode, entityId }, - customerAccessToken, - fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, - }); +export const getCustomerWishlist = cache( + async ( + locale: string, + entityId: number, + pagination: Pagination, + customerAccessToken?: string, + currencyCode?: CurrencyCode, + ) => { + const { before, after, limit = 9 } = pagination; + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; + const response = await client.fetch({ + document: WishlistDetailsQuery, + variables: { ...paginationArgs, currencyCode, entityId }, + locale, + customerAccessToken, + fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, + }); - const wishlist = response.data.customer?.wishlists.edges?.[0]?.node; + const wishlist = response.data.customer?.wishlists.edges?.[0]?.node; - if (!wishlist) { - return null; - } + if (!wishlist) { + return null; + } - return wishlist; -}); + return wishlist; + }, +); diff --git a/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx b/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx index 6ea2baa869..b55be1d869 100644 --- a/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx +++ b/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx @@ -6,10 +6,13 @@ import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/ser import { Streamable } from '@/vibes/soul/lib/streamable'; import { CursorPaginationInfo } from '@/vibes/soul/primitives/cursor-pagination'; import { Wishlist, WishlistDetails } from '@/vibes/soul/sections/wishlist-details'; +import { getSessionCustomerAccessToken } from '~/auth'; import { ExistingResultType } from '~/client/util'; +import type { CurrencyCode } from '~/components/header/fragment'; import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { wishlistDetailsTransformer } from '~/data-transformers/wishlists-transformer'; import { redirect } from '~/i18n/routing'; +import { getPreferredCurrencyCode } from '~/lib/currency'; import { isMobileUser } from '~/lib/user-agent'; import { removeWishlistItem } from '../_actions/remove-wishlist-item'; @@ -39,11 +42,19 @@ async function getWishlist( pt: ExistingResultType>, searchParamsPromise: Promise, locale: string, + customerAccessToken?: string, + currencyCode?: CurrencyCode, ): Promise { const entityId = Number(id); const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); const formatter = await getFormatter(); - const wishlist = await getCustomerWishlist(entityId, searchParamsParsed); + const wishlist = await getCustomerWishlist( + locale, + entityId, + searchParamsParsed, + customerAccessToken, + currencyCode, + ); if (!wishlist) { return redirect({ href: '/account/wishlists/', locale }); @@ -52,10 +63,22 @@ async function getWishlist( return wishlistDetailsTransformer(wishlist, t, pt, formatter); } -const getAnalyticsData = async (id: string, searchParamsPromise: Promise) => { +const getAnalyticsData = async ( + locale: string, + id: string, + searchParamsPromise: Promise, + customerAccessToken?: string, + currencyCode?: CurrencyCode, +) => { const entityId = Number(id); const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const wishlist = await getCustomerWishlist(entityId, searchParamsParsed); + const wishlist = await getCustomerWishlist( + locale, + entityId, + searchParamsParsed, + customerAccessToken, + currencyCode, + ); if (!wishlist) { return []; @@ -77,12 +100,21 @@ const getAnalyticsData = async (id: string, searchParamsPromise: Promise, + customerAccessToken?: string, + currencyCode?: CurrencyCode, ): Promise { const entityId = Number(id); const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const wishlist = await getCustomerWishlist(entityId, searchParamsParsed); + const wishlist = await getCustomerWishlist( + locale, + entityId, + searchParamsParsed, + customerAccessToken, + currencyCode, + ); return pageInfoTransformer(wishlist?.items.pageInfo ?? defaultPageInfo); } @@ -92,8 +124,12 @@ export default async function WishlistPage({ params, searchParams }: Props) { setRequestLocale(locale); - const t = await getTranslations('Wishlist'); - const pt = await getTranslations('Product.ProductDetails'); + const [t, pt, customerAccessToken, currencyCode] = await Promise.all([ + getTranslations('Wishlist'), + getTranslations('Product.ProductDetails'), + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + ]); const wishlistActions = (wishlist?: Wishlist) => { if (!wishlist) { return ; @@ -127,16 +163,24 @@ export default async function WishlistPage({ params, searchParams }: Props) { }; return ( - getAnalyticsData(id, searchParams))}> + + getAnalyticsData(locale, id, searchParams, customerAccessToken, currencyCode), + )} + > getPaginationInfo(id, searchParams))} + paginationInfo={Streamable.from(() => + getPaginationInfo(locale, id, searchParams, customerAccessToken, currencyCode), + )} prevHref="/account/wishlists" removeAction={removeWishlistItem} removeButtonTitle={t('removeButtonTitle')} - wishlist={Streamable.from(() => getWishlist(id, t, pt, searchParams, locale))} + wishlist={Streamable.from(() => + getWishlist(id, t, pt, searchParams, locale, customerAccessToken, currencyCode), + )} /> ); diff --git a/core/app/[locale]/(default)/account/wishlists/page-data.ts b/core/app/[locale]/(default)/account/wishlists/page-data.ts index 02a1c3f7b1..fef85399e0 100644 --- a/core/app/[locale]/(default)/account/wishlists/page-data.ts +++ b/core/app/[locale]/(default)/account/wishlists/page-data.ts @@ -1,11 +1,10 @@ import { cache } from 'react'; -import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { TAGS } from '~/client/tags'; +import type { CurrencyCode } from '~/components/header/fragment'; import { WishlistsFragment } from '~/components/wishlist/fragment'; -import { getPreferredCurrencyCode } from '~/lib/currency'; const WishlistsPageQuery = graphql( ` @@ -33,22 +32,28 @@ interface Pagination { after: string | null; } -export const getCustomerWishlists = cache(async ({ limit = 9, before, after }: Pagination) => { - const customerAccessToken = await getSessionCustomerAccessToken(); - const currencyCode = await getPreferredCurrencyCode(); - const paginationArgs = before ? { last: limit, before } : { first: limit, after }; - const response = await client.fetch({ - document: WishlistsPageQuery, - variables: { ...paginationArgs, currencyCode }, - customerAccessToken, - fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, - }); +export const getCustomerWishlists = cache( + async ( + locale: string, + { limit = 9, before, after }: Pagination, + customerAccessToken?: string, + currencyCode?: CurrencyCode, + ) => { + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; + const response = await client.fetch({ + document: WishlistsPageQuery, + variables: { ...paginationArgs, currencyCode }, + locale, + customerAccessToken, + fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, + }); - const wishlists = response.data.customer?.wishlists; + const wishlists = response.data.customer?.wishlists; - if (!wishlists) { - return null; - } + if (!wishlists) { + return null; + } - return wishlists; -}); + return wishlists; + }, +); diff --git a/core/app/[locale]/(default)/account/wishlists/page.tsx b/core/app/[locale]/(default)/account/wishlists/page.tsx index 5fba791cfa..26e9b738c0 100644 --- a/core/app/[locale]/(default)/account/wishlists/page.tsx +++ b/core/app/[locale]/(default)/account/wishlists/page.tsx @@ -7,9 +7,12 @@ import { CursorPaginationInfo } from '@/vibes/soul/primitives/cursor-pagination' import * as Skeleton from '@/vibes/soul/primitives/skeleton'; import { Wishlist } from '@/vibes/soul/sections/wishlist-details'; import { WishlistsSection } from '@/vibes/soul/sections/wishlists-section'; +import { getSessionCustomerAccessToken } from '~/auth'; import { ExistingResultType } from '~/client/util'; +import type { CurrencyCode } from '~/components/header/fragment'; import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { wishlistsTransformer } from '~/data-transformers/wishlists-transformer'; +import { getPreferredCurrencyCode } from '~/lib/currency'; import { isMobileUser } from '~/lib/user-agent'; import { NewWishlistButton } from './_components/new-wishlist-button'; @@ -36,12 +39,20 @@ const searchParamsCache = createSearchParamsCache({ }); async function listWishlists( + locale: string, searchParamsPromise: Promise, t: ExistingResultType>, + customerAccessToken?: string, + currencyCode?: CurrencyCode, ): Promise { const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); const formatter = await getFormatter(); - const wishlists = await getCustomerWishlists(searchParamsParsed); + const wishlists = await getCustomerWishlists( + locale, + searchParamsParsed, + customerAccessToken, + currencyCode, + ); if (!wishlists) { return []; @@ -51,10 +62,18 @@ async function listWishlists( } async function getPaginationInfo( + locale: string, searchParamsPromise: Promise, + customerAccessToken?: string, + currencyCode?: CurrencyCode, ): Promise { const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const wishlists = await getCustomerWishlists(searchParamsParsed); + const wishlists = await getCustomerWishlists( + locale, + searchParamsParsed, + customerAccessToken, + currencyCode, + ); return pageInfoTransformer(wishlists?.pageInfo ?? defaultPageInfo); } @@ -64,8 +83,12 @@ export default async function Wishlists({ params, searchParams }: Props) { setRequestLocale(locale); - const t = await getTranslations('Wishlist'); - const isMobile = await isMobileUser(); + const [t, isMobile, customerAccessToken, currencyCode] = await Promise.all([ + getTranslations('Wishlist'), + isMobileUser(), + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + ]); const newWishlistModal = getNewWishlistModal(t); return ( @@ -121,10 +144,14 @@ export default async function Wishlists({ params, searchParams }: Props) { ); }, }} - paginationInfo={Streamable.from(() => getPaginationInfo(searchParams))} + paginationInfo={Streamable.from(() => + getPaginationInfo(locale, searchParams, customerAccessToken, currencyCode), + )} title={t('title')} viewWishlistLabel={t('viewWishlist')} - wishlists={Streamable.from(() => listWishlists(searchParams, t))} + wishlists={Streamable.from(() => + listWishlists(locale, searchParams, t, customerAccessToken, currencyCode), + )} /> ); } diff --git a/core/app/[locale]/(default)/blog/[blogId]/page-data.ts b/core/app/[locale]/(default)/blog/[blogId]/page-data.ts index 472c44059a..3acda342f3 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page-data.ts +++ b/core/app/[locale]/(default)/blog/[blogId]/page-data.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -38,18 +39,27 @@ const BlogPageQuery = graphql(` type Variables = VariablesOf; -export const getBlogPageData = cache(async (variables: Variables) => { - const response = await client.fetch({ - document: BlogPageQuery, - variables, - fetchOptions: { next: { revalidate } }, - }); +const getCachedBlogPageData = unstable_cache( + async (locale: string, variables: Variables) => { + const response = await client.fetch({ + document: BlogPageQuery, + variables, + locale, + fetchOptions: { cache: 'no-store' }, + }); - const { blog } = response.data.site.content; + const { blog } = response.data.site.content; - if (!blog?.post) { - return null; - } + if (!blog?.post) { + return null; + } + + return blog; + }, + ['get-blog-page-data'], + { revalidate }, +); - return blog; +export const getBlogPageData = cache(async (locale: string, variables: Variables) => { + return getCachedBlogPageData(locale, variables); }); diff --git a/core/app/[locale]/(default)/blog/[blogId]/page.tsx b/core/app/[locale]/(default)/blog/[blogId]/page.tsx index 6b8da45019..94c4959405 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page.tsx +++ b/core/app/[locale]/(default)/blog/[blogId]/page.tsx @@ -23,7 +23,7 @@ export async function generateMetadata({ params }: Props): Promise { const variables = cachedBlogPageDataVariables(blogId); - const blog = await getBlogPageData(variables); + const blog = await getBlogPageData(locale, variables); const blogPost = blog?.post; if (!blogPost) { @@ -43,13 +43,11 @@ export async function generateMetadata({ params }: Props): Promise { } async function getBlogPost(props: Props): Promise { - const format = await getFormatter(); - - const { blogId } = await props.params; + const [format, { blogId, locale }] = await Promise.all([getFormatter(), props.params]); const variables = cachedBlogPageDataVariables(blogId); - const blog = await getBlogPageData(variables); + const blog = await getBlogPageData(locale, variables); const blogPost = blog?.post; if (!blog || !blogPost) { @@ -74,13 +72,11 @@ async function getBlogPost(props: Props): Promise { } async function getBlogPostBreadcrumbs(props: Props): Promise { - const t = await getTranslations('Blog'); - - const { blogId } = await props.params; + const [t, { blogId, locale }] = await Promise.all([getTranslations('Blog'), props.params]); const variables = cachedBlogPageDataVariables(blogId); - const blog = await getBlogPageData(variables); + const blog = await getBlogPageData(locale, variables); const blogPost = blog?.post; if (!blog || !blogPost) { diff --git a/core/app/[locale]/(default)/blog/page-data.ts b/core/app/[locale]/(default)/blog/page-data.ts index d51cf024cd..bd62608475 100644 --- a/core/app/[locale]/(default)/blog/page-data.ts +++ b/core/app/[locale]/(default)/blog/page-data.ts @@ -1,4 +1,5 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { unstable_cache } from 'next/cache'; import { getFormatter } from 'next-intl/server'; import { cache } from 'react'; @@ -72,24 +73,34 @@ interface Pagination { after: string | null; } -export const getBlog = cache(async () => { - const response = await client.fetch({ - document: BlogQuery, - fetchOptions: { next: { revalidate } }, - }); +const getCachedBlog = unstable_cache( + async (locale: string) => { + const response = await client.fetch({ + document: BlogQuery, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return response.data.site.content.blog; + }, + ['get-blog'], + { revalidate }, +); - return response.data.site.content.blog; +export const getBlog = cache(async (locale: string) => { + return getCachedBlog(locale); }); -export const getBlogPosts = cache( - async ({ tag, limit = 9, before, after }: BlogPostsFiltersInput & Pagination) => { +const getCachedBlogPosts = unstable_cache( + async (locale: string, { tag, limit = 9, before, after }: BlogPostsFiltersInput & Pagination) => { const filterArgs = tag ? { filters: { tags: [tag] } } : {}; const paginationArgs = before ? { last: limit, before } : { first: limit, after }; const response = await client.fetch({ document: BlogPostsPageQuery, variables: { ...filterArgs, ...paginationArgs }, - fetchOptions: { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); const { blog } = response.data.site.content; @@ -98,15 +109,13 @@ export const getBlogPosts = cache( return null; } - const format = await getFormatter(); - return { pageInfo: blog.posts.pageInfo, posts: removeEdgesAndNodes(blog.posts).map((post) => ({ id: String(post.entityId), author: post.author, content: post.plainTextSummary, - date: format.dateTime(new Date(post.publishedDate.utc)), + dateUtc: post.publishedDate.utc, image: post.thumbnailImage ? { src: post.thumbnailImage.url, @@ -118,4 +127,26 @@ export const getBlogPosts = cache( })), }; }, + ['get-blog-posts'], + { revalidate }, +); + +export const getBlogPosts = cache( + async (locale: string, { tag, limit = 9, before, after }: BlogPostsFiltersInput & Pagination) => { + const raw = await getCachedBlogPosts(locale, { tag, limit, before, after }); + + if (!raw) { + return null; + } + + const format = await getFormatter(); + + return { + pageInfo: raw.pageInfo, + posts: raw.posts.map(({ dateUtc, ...post }) => ({ + ...post, + date: format.dateTime(new Date(dateUtc)), + })), + }; + }, ); diff --git a/core/app/[locale]/(default)/blog/page.tsx b/core/app/[locale]/(default)/blog/page.tsx index 1b8d90cf91..f7b972f709 100644 --- a/core/app/[locale]/(default)/blog/page.tsx +++ b/core/app/[locale]/(default)/blog/page.tsx @@ -29,7 +29,7 @@ export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; const t = await getTranslations({ locale, namespace: 'Blog' }); - const blog = await getBlog(); + const blog = await getBlog(locale); const description = blog?.description && blog.description.length > 150 @@ -43,9 +43,9 @@ export async function generateMetadata({ params }: Props): Promise { }; } -async function listBlogPosts(searchParamsPromise: Promise) { +async function listBlogPosts(locale: string, searchParamsPromise: Promise) { const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const blogPosts = await getBlogPosts(searchParamsParsed); + const blogPosts = await getBlogPosts(locale, searchParamsParsed); const posts = blogPosts?.posts ?? []; return posts; @@ -63,9 +63,9 @@ async function getEmptyStateSubtitle(): Promise { return t('subtitle'); } -async function getPaginationInfo(searchParamsPromise: Promise) { +async function getPaginationInfo(locale: string, searchParamsPromise: Promise) { const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const blogPosts = await getBlogPosts(searchParamsParsed); + const blogPosts = await getBlogPosts(locale, searchParamsParsed); return pageInfoTransformer(blogPosts?.pageInfo ?? defaultPageInfo); } @@ -79,7 +79,7 @@ export default async function Blog(props: Props) { const searchParamsParsed = searchParamsCache.parse(await props.searchParams); const { tag } = searchParamsParsed; - const blog = await getBlog(); + const blog = await getBlog(locale); if (!blog) { return notFound(); @@ -103,9 +103,9 @@ export default async function Blog(props: Props) { description={blog.description} emptyStateSubtitle={Streamable.from(getEmptyStateSubtitle)} emptyStateTitle={Streamable.from(getEmptyStateTitle)} - paginationInfo={Streamable.from(() => getPaginationInfo(props.searchParams))} + paginationInfo={Streamable.from(() => getPaginationInfo(locale, props.searchParams))} placeholderCount={6} - posts={Streamable.from(() => listBlogPosts(props.searchParams))} + posts={Streamable.from(() => listBlogPosts(locale, props.searchParams))} title={blog.name} /> ); diff --git a/core/app/[locale]/(default)/cart/_actions/update-coupon-code.ts b/core/app/[locale]/(default)/cart/_actions/update-coupon-code.ts index 01611007f7..55ffc640dd 100644 --- a/core/app/[locale]/(default)/cart/_actions/update-coupon-code.ts +++ b/core/app/[locale]/(default)/cart/_actions/update-coupon-code.ts @@ -3,9 +3,10 @@ import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; import { SubmissionResult } from '@conform-to/react'; import { parseWithZod } from '@conform-to/zod'; -import { getTranslations } from 'next-intl/server'; +import { getLocale, getTranslations } from 'next-intl/server'; import { couponCodeActionFormDataSchema } from '@/vibes/soul/sections/cart/schema'; +import { getSessionCustomerAccessToken } from '~/auth'; import { getCartId } from '~/lib/cart'; import { getCart } from '../page-data'; @@ -34,7 +35,11 @@ export const updateCouponCode = async ( return { ...prevState, lastResult: submission.reply({ formErrors: [t('cartNotFound')] }) }; } - const cart = await getCart({ cartId }); + const [locale, customerAccessToken] = await Promise.all([ + getLocale(), + getSessionCustomerAccessToken(), + ]); + const cart = await getCart(locale, { cartId }, customerAccessToken); const checkout = cart.site.checkout; if (!checkout) { diff --git a/core/app/[locale]/(default)/cart/_actions/update-gift-certificate.ts b/core/app/[locale]/(default)/cart/_actions/update-gift-certificate.ts index b7f2c52ac3..bf1aa88e42 100644 --- a/core/app/[locale]/(default)/cart/_actions/update-gift-certificate.ts +++ b/core/app/[locale]/(default)/cart/_actions/update-gift-certificate.ts @@ -3,9 +3,10 @@ import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; import { SubmissionResult } from '@conform-to/react'; import { parseWithZod } from '@conform-to/zod'; -import { getTranslations } from 'next-intl/server'; +import { getLocale, getTranslations } from 'next-intl/server'; import { giftCertificateCodeActionFormDataSchema } from '@/vibes/soul/sections/cart/schema'; +import { getSessionCustomerAccessToken } from '~/auth'; import { getCartId } from '~/lib/cart'; import { getCart } from '../page-data'; @@ -36,7 +37,11 @@ export const updateGiftCertificate = async ( return { ...prevState, lastResult: submission.reply({ formErrors: [t('cartNotFound')] }) }; } - const cart = await getCart({ cartId }); + const [locale, customerAccessToken] = await Promise.all([ + getLocale(), + getSessionCustomerAccessToken(), + ]); + const cart = await getCart(locale, { cartId }, customerAccessToken); const checkout = cart.site.checkout; if (!checkout) { diff --git a/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts b/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts index 662ba0f7a6..809a3e6d43 100644 --- a/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts +++ b/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts @@ -2,10 +2,11 @@ import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; import { parseWithZod } from '@conform-to/zod'; -import { getTranslations } from 'next-intl/server'; +import { getLocale, getTranslations } from 'next-intl/server'; import { shippingActionFormDataSchema } from '@/vibes/soul/sections/cart/schema'; import { ShippingFormState } from '@/vibes/soul/sections/cart/shipping-form'; +import { getSessionCustomerAccessToken } from '~/auth'; import { getCartId } from '~/lib/cart'; import { getCart } from '../page-data'; @@ -32,7 +33,11 @@ export const updateShippingInfo = async ( return { ...prevState, lastResult: submission.reply({ formErrors: [t('cartNotFound')] }) }; } - const cart = await getCart({ cartId }); + const [locale, customerAccessToken] = await Promise.all([ + getLocale(), + getSessionCustomerAccessToken(), + ]); + const cart = await getCart(locale, { cartId }, customerAccessToken); const checkout = cart.site.checkout; if (!checkout || !cart.site.cart) { diff --git a/core/app/[locale]/(default)/cart/page-data.ts b/core/app/[locale]/(default)/cart/page-data.ts index c6e47636dc..19d9d58d6f 100644 --- a/core/app/[locale]/(default)/cart/page-data.ts +++ b/core/app/[locale]/(default)/cart/page-data.ts @@ -1,6 +1,6 @@ +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; -import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; @@ -295,12 +295,15 @@ const CartPageQuery = graphql( type Variables = VariablesOf; -export const getCart = async (variables: Variables) => { - const customerAccessToken = await getSessionCustomerAccessToken(); - +export const getCart = async ( + locale: string, + variables: Variables, + customerAccessToken?: string, +) => { const { data } = await client.fetch({ document: CartPageQuery, variables, + locale, customerAccessToken, fetchOptions: { cache: 'no-store', @@ -336,11 +339,20 @@ const SupportedShippingDestinationsQuery = graphql(` } `); -export const getShippingCountries = cache(async () => { - const { data } = await client.fetch({ - document: SupportedShippingDestinationsQuery, - fetchOptions: { next: { revalidate } }, - }); +const getCachedShippingCountries = unstable_cache( + async (locale: string) => { + const { data } = await client.fetch({ + document: SupportedShippingDestinationsQuery, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.settings?.shipping?.supportedShippingDestinations.countries ?? []; + }, + ['get-shipping-countries'], + { revalidate }, +); - return data.site.settings?.shipping?.supportedShippingDestinations.countries ?? []; +export const getShippingCountries = cache(async (locale: string) => { + return getCachedShippingCountries(locale); }); diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index bc07f3d473..9967347516 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -4,6 +4,7 @@ import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/serve import { Streamable } from '@/vibes/soul/lib/streamable'; import { Cart as CartComponent, CartEmptyState } from '@/vibes/soul/sections/cart'; import { CartAnalyticsProvider } from '~/app/[locale]/(default)/cart/_components/cart-analytics-provider'; +import { getSessionCustomerAccessToken } from '~/auth'; import { getCartId } from '~/lib/cart'; import { getPreferredCurrencyCode } from '~/lib/currency'; import { exists } from '~/lib/utils'; @@ -32,8 +33,8 @@ export async function generateMetadata({ params }: Props): Promise { }; } -const getAnalyticsData = async (cartId: string) => { - const data = await getCart({ cartId }); +const getAnalyticsData = async (locale: string, cartId: string, customerAccessToken?: string) => { + const data = await getCart(locale, { cartId }, customerAccessToken); const cart = data.site.cart; @@ -65,10 +66,7 @@ export default async function Cart({ params }: Props) { setRequestLocale(locale); - const t = await getTranslations('Cart'); - const tGiftCertificates = await getTranslations('GiftCertificates'); - const format = await getFormatter(); - const cartId = await getCartId(); + const [t, cartId] = await Promise.all([getTranslations('Cart'), getCartId()]); if (!cartId) { return ( @@ -80,8 +78,13 @@ export default async function Cart({ params }: Props) { ); } - const currencyCode = await getPreferredCurrencyCode(); - const data = await getCart({ cartId, currencyCode }); + const [tGiftCertificates, format, currencyCode, customerAccessToken] = await Promise.all([ + getTranslations('GiftCertificates'), + getFormatter(), + getPreferredCurrencyCode(), + getSessionCustomerAccessToken(), + ]); + const data = await getCart(locale, { cartId, currencyCode }, customerAccessToken); const cart = data.site.cart; const checkout = data.site.checkout; @@ -227,7 +230,7 @@ export default async function Cart({ params }: Props) { checkout?.shippingConsignments?.find((consignment) => consignment.selectedShippingOption) || checkout?.shippingConsignments?.[0]; - const shippingCountries = await getShippingCountries(); + const shippingCountries = await getShippingCountries(locale); const countries = shippingCountries.map((country) => ({ value: country.code, @@ -260,7 +263,9 @@ export default async function Cart({ params }: Props) { return ( <> - getAnalyticsData(cartId))}> + getAnalyticsData(locale, cartId, customerAccessToken))} + > {checkoutUrl ? : null} { +const getCachedComparedProducts = unstable_cache( + async (locale: string, productIds: number[], currencyCode?: CurrencyCode) => { if (productIds.length === 0) { return []; } @@ -68,10 +69,43 @@ export const getComparedProducts = cache( first: productIds.length ? MAX_COMPARE_LIMIT : 0, currencyCode, }, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return removeEdgesAndNodes(data.site.products); }, + ['get-compared-products'], + { revalidate }, +); + +export const getComparedProducts = cache( + async ( + locale: string, + productIds: number[] = [], + currencyCode?: CurrencyCode, + customerAccessToken?: string, + ) => { + if (customerAccessToken) { + if (productIds.length === 0) { + return []; + } + + const { data } = await client.fetch({ + document: ComparedProductsQuery, + variables: { + entityIds: productIds, + first: productIds.length ? MAX_COMPARE_LIMIT : 0, + currencyCode, + }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return removeEdgesAndNodes(data.site.products); + } + + return getCachedComparedProducts(locale, productIds, currencyCode); + }, ); diff --git a/core/app/[locale]/(default)/compare/page.tsx b/core/app/[locale]/(default)/compare/page.tsx index ac851ddb1a..5f5866d492 100644 --- a/core/app/[locale]/(default)/compare/page.tsx +++ b/core/app/[locale]/(default)/compare/page.tsx @@ -57,15 +57,21 @@ export default async function Compare(props: Props) { const t = await getTranslations('Compare'); const streamableProducts = Streamable.from(async () => { - const customerAccessToken = await getSessionCustomerAccessToken(); - const currencyCode = await getPreferredCurrencyCode(); - - const searchParams = await props.searchParams; + const [customerAccessToken, currencyCode, searchParams, format] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + props.searchParams, + getFormatter(), + ]); const parsed = CompareParamsSchema.parse(searchParams); const productIds = parsed.ids?.filter((id) => !Number.isNaN(id)); - const products = await getComparedProducts(productIds, currencyCode, customerAccessToken); - const format = await getFormatter(); + const products = await getComparedProducts( + locale, + productIds, + currencyCode, + customerAccessToken, + ); return products.map((product) => ({ id: product.entityId.toString(), @@ -90,14 +96,20 @@ export default async function Compare(props: Props) { }); const streamableAnalyticsData = Streamable.from(async () => { - const customerAccessToken = await getSessionCustomerAccessToken(); - const currencyCode = await getPreferredCurrencyCode(); - - const searchParams = await props.searchParams; + const [customerAccessToken, currencyCode, searchParams] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + props.searchParams, + ]); const parsed = CompareParamsSchema.parse(searchParams); const productIds = parsed.ids?.filter((id) => !Number.isNaN(id)); - const products = await getComparedProducts(productIds, currencyCode, customerAccessToken); + const products = await getComparedProducts( + locale, + productIds, + currencyCode, + customerAccessToken, + ); return products.map((product) => { return { diff --git a/core/app/[locale]/(default)/gift-certificates/balance/page.tsx b/core/app/[locale]/(default)/gift-certificates/balance/page.tsx index 0320e48acb..e6eca42d97 100644 --- a/core/app/[locale]/(default)/gift-certificates/balance/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/balance/page.tsx @@ -32,7 +32,7 @@ export default async function GiftCertificates(props: Props) { const t = await getTranslations('GiftCertificates'); const currencyCode = await getPreferredCurrencyCode(); - const data = await getGiftCertificatesData(currencyCode); + const data = await getGiftCertificatesData(locale, currencyCode); if (!data.giftCertificatesEnabled) { return redirect({ href: '/', locale }); diff --git a/core/app/[locale]/(default)/gift-certificates/page-data.ts b/core/app/[locale]/(default)/gift-certificates/page-data.ts index 6905ee0b7f..5ece542997 100644 --- a/core/app/[locale]/(default)/gift-certificates/page-data.ts +++ b/core/app/[locale]/(default)/gift-certificates/page-data.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -26,16 +27,27 @@ const GiftCertificatesRootQuery = graphql( [StoreLogoFragment], ); -export const getGiftCertificatesData = cache(async (currencyCode?: CurrencyCode) => { - const response = await client.fetch({ - document: GiftCertificatesRootQuery, - variables: { currencyCode }, - fetchOptions: { next: { revalidate } }, - }); +const getCachedGiftCertificatesData = unstable_cache( + async (locale: string, currencyCode?: CurrencyCode) => { + const response = await client.fetch({ + document: GiftCertificatesRootQuery, + variables: { currencyCode }, + locale, + fetchOptions: { cache: 'no-store' }, + }); - return { - giftCertificatesEnabled: response.data.site.settings?.giftCertificates?.isEnabled ?? false, - defaultCurrency: response.data.site.settings?.currency.defaultCurrency ?? undefined, - logo: response.data.site.settings ? logoTransformer(response.data.site.settings) : '', - }; -}); + return { + giftCertificatesEnabled: response.data.site.settings?.giftCertificates?.isEnabled ?? false, + defaultCurrency: response.data.site.settings?.currency.defaultCurrency ?? undefined, + logo: response.data.site.settings ? logoTransformer(response.data.site.settings) : '', + }; + }, + ['get-gift-certificates-data'], + { revalidate }, +); + +export const getGiftCertificatesData = cache( + async (locale: string, currencyCode?: CurrencyCode) => { + return getCachedGiftCertificatesData(locale, currencyCode); + }, +); diff --git a/core/app/[locale]/(default)/gift-certificates/page.tsx b/core/app/[locale]/(default)/gift-certificates/page.tsx index 5c50984fb3..67e62827c0 100644 --- a/core/app/[locale]/(default)/gift-certificates/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/page.tsx @@ -31,7 +31,7 @@ export default async function GiftCertificates(props: Props) { const t = await getTranslations('GiftCertificates'); const format = await getFormatter(); const currencyCode = await getPreferredCurrencyCode(); - const data = await getGiftCertificatesData(currencyCode); + const data = await getGiftCertificatesData(locale, currencyCode); if (!data.giftCertificatesEnabled) { return redirect({ href: '/', locale }); diff --git a/core/app/[locale]/(default)/gift-certificates/purchase/page-data.ts b/core/app/[locale]/(default)/gift-certificates/purchase/page-data.ts index 609584722a..76b763f086 100644 --- a/core/app/[locale]/(default)/gift-certificates/purchase/page-data.ts +++ b/core/app/[locale]/(default)/gift-certificates/purchase/page-data.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -29,17 +30,28 @@ const GiftCertificatePurchaseSettingsQuery = graphql( [GiftCertificateSettingsFragment, StoreLogoFragment], ); -export const getGiftCertificatePurchaseData = cache(async (currencyCode?: CurrencyCode) => { - const response = await client.fetch({ - document: GiftCertificatePurchaseSettingsQuery, - variables: { currencyCode }, - fetchOptions: { next: { revalidate } }, - }); +const getCachedGiftCertificatePurchaseData = unstable_cache( + async (locale: string, currencyCode?: CurrencyCode) => { + const response = await client.fetch({ + document: GiftCertificatePurchaseSettingsQuery, + variables: { currencyCode }, + locale, + fetchOptions: { cache: 'no-store' }, + }); - return { - giftCertificateSettings: response.data.site.settings?.giftCertificates ?? null, - logo: response.data.site.settings ? logoTransformer(response.data.site.settings) : '', - storeName: response.data.site.settings?.storeName ?? undefined, - defaultCurrency: response.data.site.settings?.currency.defaultCurrency ?? undefined, - }; -}); + return { + giftCertificateSettings: response.data.site.settings?.giftCertificates ?? null, + logo: response.data.site.settings ? logoTransformer(response.data.site.settings) : '', + storeName: response.data.site.settings?.storeName ?? undefined, + defaultCurrency: response.data.site.settings?.currency.defaultCurrency ?? undefined, + }; + }, + ['get-gift-certificate-purchase-data'], + { revalidate }, +); + +export const getGiftCertificatePurchaseData = cache( + async (locale: string, currencyCode?: CurrencyCode) => { + return getCachedGiftCertificatePurchaseData(locale, currencyCode); + }, +); diff --git a/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx b/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx index 29e4158c75..89feb6333f 100644 --- a/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx @@ -150,10 +150,12 @@ function getExpiryDate( export default async function GiftCertificatePurchasePage({ params }: Props) { const { locale } = await params; - const t = await getTranslations({ locale, namespace: 'GiftCertificates' }); - const format = await getFormatter(); - const currencyCode = await getPreferredCurrencyCode(); - const data = await getGiftCertificatePurchaseData(currencyCode); + const [t, format, currencyCode] = await Promise.all([ + getTranslations({ locale, namespace: 'GiftCertificates' }), + getFormatter(), + getPreferredCurrencyCode(), + ]); + const data = await getGiftCertificatePurchaseData(locale, currencyCode); if (!data.giftCertificateSettings?.isEnabled) { return redirect({ href: '/', locale }); diff --git a/core/app/[locale]/(default)/page-data.ts b/core/app/[locale]/(default)/page-data.ts index ab78d520a6..a35ef4b811 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; }, + ['get-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]/(default)/product/[slug]/page-data.ts b/core/app/[locale]/(default)/product/[slug]/page-data.ts index 02a8293735..ac6d3fcdda 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; }, + ['get-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; + }, + ['get-product'], + { 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; }, + ['get-streamable-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; }, + ['get-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; }, + ['get-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; }, + ['get-product-pricing-and-related-products'], + { 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 getCachedStreamableInventorySettingsQuery = 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; + }, + ['get-streamable-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 getCachedStreamableInventorySettingsQuery(locale); + }, +); diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index 8d2001ee8c..26e6790b99 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -40,12 +40,14 @@ interface Props { } export async function generateMetadata({ params }: Props): Promise { - const { slug, locale } = await params; - const customerAccessToken = await getSessionCustomerAccessToken(); + const [{ slug, locale }, customerAccessToken] = await Promise.all([ + params, + getSessionCustomerAccessToken(), + ]); const productId = Number(slug); - const product = await getProductPageMetadata(productId, customerAccessToken); + const product = await getProductPageMetadata(locale, productId, customerAccessToken); if (!product) { return notFound(); @@ -66,19 +68,20 @@ export async function generateMetadata({ params }: Props): Promise { } export default async function Product({ params, searchParams }: Props) { - const { locale, slug } = await params; - const customerAccessToken = await getSessionCustomerAccessToken(); + const [{ locale, slug }, customerAccessToken, t, format] = await Promise.all([ + params, + getSessionCustomerAccessToken(), + getTranslations('Product'), + getFormatter(), + ]); const detachedWishlistFormId = 'product-add-to-wishlist-form'; setRequestLocale(locale); - const t = await getTranslations('Product'); - const format = await getFormatter(); - const productId = Number(slug); const [{ product: baseProduct, settings }, recaptchaSiteKey] = await Promise.all([ - getProduct(productId, customerAccessToken), + getProduct(locale, productId, customerAccessToken), getRecaptchaSiteKey(), ]); @@ -107,7 +110,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 +126,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 +147,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 +181,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 +249,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 = ({ diff --git a/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts b/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts index 191e5f56ac..c1952ca187 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts +++ b/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -31,12 +32,21 @@ const ContactPageQuery = graphql( type Variables = VariablesOf; -export const getWebpageData = cache(async (variables: Variables) => { - const { data } = await client.fetch({ - document: ContactPageQuery, - variables, - fetchOptions: { next: { revalidate } }, - }); +const getCachedWebpageData = unstable_cache( + async (locale: string, variables: Variables) => { + const { data } = await client.fetch({ + document: ContactPageQuery, + variables, + locale, + fetchOptions: { cache: 'no-store' }, + }); - return data; + return data; + }, + ['get-contact-webpage-data'], + { revalidate }, +); + +export const getWebpageData = cache(async (locale: string, variables: Variables) => { + return getCachedWebpageData(locale, variables); }); diff --git a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx index 2c8896c405..c31d8c9c3a 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx @@ -41,8 +41,8 @@ const fieldMapping = { type ContactField = keyof typeof fieldMapping; -const getWebPage = cache(async (id: string): Promise => { - const data = await getWebpageData({ id: decodeURIComponent(id) }); +const getWebPage = cache(async (locale: string, id: string): Promise => { + const data = await getWebpageData(locale, { id: decodeURIComponent(id) }); const webpage = data.node?.__typename === 'ContactPage' ? data.node : null; if (!webpage) { @@ -62,10 +62,10 @@ const getWebPage = cache(async (id: string): Promise => { }; }); -async function getWebPageBreadcrumbs(id: string): Promise { +async function getWebPageBreadcrumbs(locale: string, id: string): Promise { const t = await getTranslations('WebPages.ContactUs'); - const webpage = await getWebPage(id); + const webpage = await getWebPage(locale, id); const [, ...rest] = webpage.breadcrumbs.reverse(); const breadcrumbs = [ { @@ -82,8 +82,8 @@ async function getWebPageBreadcrumbs(id: string): Promise { return truncateBreadcrumbs(breadcrumbs, 5); } -async function getWebPageWithSuccessContent(id: string, message: string) { - const webpage = await getWebPage(id); +async function getWebPageWithSuccessContent(locale: string, id: string, message: string) { + const webpage = await getWebPage(locale, id); return { ...webpage, @@ -91,9 +91,9 @@ async function getWebPageWithSuccessContent(id: string, message: string) { }; } -async function getContactFields(id: string) { +async function getContactFields(locale: string, id: string) { const t = await getTranslations('WebPages.ContactUs.Form'); - const { entityId, path, contactFields } = await getWebPage(id); + const { entityId, path, contactFields } = await getWebPage(locale, id); const toGroupsOfTwo = (fields: Field[]) => fields.reduce>>((acc, _, i) => { if (i % 2 === 0) { @@ -156,7 +156,7 @@ async function getContactFields(id: string) { export async function generateMetadata({ params }: Props): Promise { const { id, locale } = await params; - const webpage = await getWebPage(id); + const webpage = await getWebPage(locale, id); const { pageTitle, metaDescription, metaKeywords } = webpage.seo; return { @@ -180,8 +180,8 @@ export default async function ContactPage({ params, searchParams }: Props) { if (success === 'true') { return ( getWebPageBreadcrumbs(id))} - webPage={Streamable.from(() => getWebPageWithSuccessContent(id, t('success')))} + breadcrumbs={Streamable.from(() => getWebPageBreadcrumbs(locale, id))} + webPage={Streamable.from(() => getWebPageWithSuccessContent(locale, id, t('success')))} > getWebPageBreadcrumbs(id))} - webPage={Streamable.from(() => getWebPage(id))} + breadcrumbs={Streamable.from(() => getWebPageBreadcrumbs(locale, id))} + webPage={Streamable.from(() => getWebPage(locale, id))} >
diff --git a/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts b/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts index eb87a7884d..1a6bc521f1 100644 --- a/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts +++ b/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -29,12 +30,21 @@ const NormalPageQuery = graphql( type Variables = VariablesOf; -export const getWebpageData = cache(async (variables: Variables) => { - const { data } = await client.fetch({ - document: NormalPageQuery, - variables, - fetchOptions: { next: { revalidate } }, - }); +const getCachedWebpageData = unstable_cache( + async (locale: string, variables: Variables) => { + const { data } = await client.fetch({ + document: NormalPageQuery, + variables, + locale, + fetchOptions: { cache: 'no-store' }, + }); - return data; + return data; + }, + ['get-normal-webpage-data'], + { revalidate }, +); + +export const getWebpageData = cache(async (locale: string, variables: Variables) => { + return getCachedWebpageData(locale, variables); }); diff --git a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx index a222ea18c8..3b55299d9e 100644 --- a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx @@ -19,8 +19,8 @@ interface Props { params: Promise<{ locale: string; id: string }>; } -const getWebPage = cache(async (id: string): Promise => { - const data = await getWebpageData({ id: decodeURIComponent(id) }); +const getWebPage = cache(async (locale: string, id: string): Promise => { + const data = await getWebpageData(locale, { id: decodeURIComponent(id) }); const webpage = data.node?.__typename === 'NormalPage' ? data.node : null; if (!webpage) { @@ -37,10 +37,10 @@ const getWebPage = cache(async (id: string): Promise => { }; }); -async function getWebPageBreadcrumbs(id: string): Promise { +async function getWebPageBreadcrumbs(locale: string, id: string): Promise { const t = await getTranslations('WebPages.Normal'); - const webpage = await getWebPage(id); + const webpage = await getWebPage(locale, id); const [, ...rest] = webpage.breadcrumbs.reverse(); const breadcrumbs = [ { @@ -59,7 +59,7 @@ async function getWebPageBreadcrumbs(id: string): Promise { export async function generateMetadata({ params }: Props): Promise { const { id, locale } = await params; - const webpage = await getWebPage(id); + const webpage = await getWebPage(locale, id); const { pageTitle, metaDescription, metaKeywords } = webpage.seo; // Get the path from the last breadcrumb @@ -80,8 +80,8 @@ export default async function WebPage({ params }: Props) { return ( getWebPageBreadcrumbs(id))} - webPage={Streamable.from(() => getWebPage(id))} + breadcrumbs={Streamable.from(() => getWebPageBreadcrumbs(locale, id))} + webPage={Streamable.from(() => getWebPage(locale, id))} /> ); } diff --git a/core/app/[locale]/(default)/wishlist/[token]/page-data.ts b/core/app/[locale]/(default)/wishlist/[token]/page-data.ts index 4c3e56de3d..d03424ea59 100644 --- a/core/app/[locale]/(default)/wishlist/[token]/page-data.ts +++ b/core/app/[locale]/(default)/wishlist/[token]/page-data.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -5,9 +6,9 @@ import { PaginationFragment } from '~/client/fragments/pagination'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; import { TAGS } from '~/client/tags'; +import type { CurrencyCode } from '~/components/header/fragment'; import { ProductCardFragment } from '~/components/product-card/fragment'; import { WishlistItemFragment } from '~/components/wishlist/fragment'; -import { getPreferredCurrencyCode } from '~/lib/currency'; const PublicWishlistQuery = graphql( ` @@ -50,22 +51,27 @@ interface Pagination { after?: string | null; } -export const getPublicWishlist = cache(async (token: string, pagination: Pagination) => { - const { before, after, limit = 9 } = pagination; - const currencyCode = await getPreferredCurrencyCode(); - const paginationArgs = before ? { last: limit, before } : { first: limit, after }; - const response = await client.fetch({ - document: PublicWishlistQuery, - variables: { ...paginationArgs, currencyCode, token }, - // Since the wishlist is public, it's okay that we cache this request - fetchOptions: { next: { revalidate, tags: [TAGS.customer] } }, - }); +const getCachedPublicWishlist = unstable_cache( + async (locale: string, token: string, pagination: Pagination, currencyCode?: CurrencyCode) => { + const { before, after, limit = 9 } = pagination; + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; + const response = await client.fetch({ + document: PublicWishlistQuery, + variables: { ...paginationArgs, currencyCode, token }, + locale, + fetchOptions: { cache: 'no-store' }, + }); - const wishlist = response.data.site.publicWishlist; + const wishlist = response.data.site.publicWishlist; - if (!wishlist) { - return null; - } + return wishlist; + }, + ['get-public-wishlist'], + { revalidate, tags: [TAGS.customer] }, +); - return wishlist; -}); +export const getPublicWishlist = cache( + async (locale: string, token: string, pagination: Pagination, currencyCode?: CurrencyCode) => { + return getCachedPublicWishlist(locale, token, pagination, currencyCode); + }, +); diff --git a/core/app/[locale]/(default)/wishlist/[token]/page.tsx b/core/app/[locale]/(default)/wishlist/[token]/page.tsx index e6fe52378f..180e77e33e 100644 --- a/core/app/[locale]/(default)/wishlist/[token]/page.tsx +++ b/core/app/[locale]/(default)/wishlist/[token]/page.tsx @@ -13,12 +13,14 @@ import { Wishlist, WishlistDetails } from '@/vibes/soul/sections/wishlist-detail import { addWishlistItemToCart } from '~/app/[locale]/(default)/account/wishlists/[id]/_actions/add-to-cart'; import { WishlistAnalyticsProvider } from '~/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-analytics-provider'; import { ExistingResultType } from '~/client/util'; +import type { CurrencyCode } from '~/components/header/fragment'; import { WishlistShareButton, WishlistShareButtonSkeleton, } from '~/components/wishlist/share-button'; import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { publicWishlistDetailsTransformer } from '~/data-transformers/wishlists-transformer'; +import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMetadataAlternates } from '~/lib/seo/canonical'; import { isMobileUser } from '~/lib/user-agent'; @@ -38,14 +40,16 @@ const searchParamsCache = createSearchParamsCache({ }); async function getWishlist( + locale: string, token: string, t: ExistingResultType>, pt: ExistingResultType>, searchParams: Promise, + currencyCode?: CurrencyCode, ): Promise { const searchParamsParsed = searchParamsCache.parse(await searchParams); const formatter = await getFormatter(); - const wishlist = await getPublicWishlist(token, searchParamsParsed); + const wishlist = await getPublicWishlist(locale, token, searchParamsParsed, currencyCode); if (!wishlist) { return notFound(); @@ -55,11 +59,13 @@ async function getWishlist( } async function getPaginationInfo( + locale: string, token: string, searchParams: Promise, + currencyCode?: CurrencyCode, ): Promise { const searchParamsParsed = searchParamsCache.parse(await searchParams); - const wishlist = await getPublicWishlist(token, searchParamsParsed); + const wishlist = await getPublicWishlist(locale, token, searchParamsParsed, currencyCode); return pageInfoTransformer(wishlist?.items.pageInfo ?? defaultPageInfo); } @@ -70,7 +76,8 @@ export async function generateMetadata({ params, searchParams }: Props): Promise // to make sure we aren't bypassing an existing cache just for the metadata generation. const searchParamsParsed = searchParamsCache.parse(await searchParams); const t = await getTranslations({ locale, namespace: 'PublicWishlist' }); - const wishlist = await getPublicWishlist(token, searchParamsParsed); + const currencyCode = await getPreferredCurrencyCode(); + const wishlist = await getPublicWishlist(locale, token, searchParamsParsed, currencyCode); return { title: wishlist?.name ?? t('title'), @@ -78,9 +85,14 @@ export async function generateMetadata({ params, searchParams }: Props): Promise }; } -const getAnalyticsData = async (token: string, searchParamsPromise: Promise) => { +const getAnalyticsData = async ( + locale: string, + token: string, + searchParamsPromise: Promise, + currencyCode?: CurrencyCode, +) => { const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const wishlist = await getPublicWishlist(token, searchParamsParsed); + const wishlist = await getPublicWishlist(locale, token, searchParamsParsed, currencyCode); if (!wishlist) { return []; @@ -102,12 +114,14 @@ const getAnalyticsData = async (token: string, searchParamsPromise: Promise, + currencyCode?: CurrencyCode, ): Promise { const t = await getTranslations('PublicWishlist'); const searchParamsParsed = searchParamsCache.parse(await searchParams); - const wishlist = await getPublicWishlist(token, searchParamsParsed); + const wishlist = await getPublicWishlist(locale, token, searchParamsParsed, currencyCode); return [ { href: '/', label: 'Home' }, @@ -120,6 +134,7 @@ export default async function PublicWishlist({ params, searchParams }: Props) { setRequestLocale(locale); + const currencyCode = await getPreferredCurrencyCode(); const t = await getTranslations('Wishlist'); const pwt = await getTranslations('PublicWishlist'); const pt = await getTranslations('Product.ProductDetails'); @@ -159,17 +174,27 @@ export default async function PublicWishlist({ params, searchParams }: Props) { }; return ( - getAnalyticsData(token, searchParams))}> + getAnalyticsData(locale, token, searchParams, currencyCode))} + > - getBreadcrumbs(token, searchParams))} /> + + getBreadcrumbs(locale, token, searchParams, currencyCode), + )} + /> getPaginationInfo(token, searchParams))} - wishlist={Streamable.from(() => getWishlist(token, t, pt, searchParams))} + paginationInfo={Streamable.from(() => + getPaginationInfo(locale, token, searchParams, currencyCode), + )} + wishlist={Streamable.from(() => + getWishlist(locale, token, t, pt, searchParams, currencyCode), + )} /> diff --git a/core/app/[locale]/layout.tsx b/core/app/[locale]/layout.tsx index 11d3d88e85..1be02ded1c 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,12 +54,18 @@ const RootLayoutMetadataQuery = graphql( [WebAnalyticsFragment, ScriptsFragment], ); -const fetchRootLayoutMetadata = cache(async () => { - return await client.fetch({ - document: RootLayoutMetadataQuery, - fetchOptions: { next: { revalidate } }, - }); -}); +const getCachedRootLayoutMetadata = unstable_cache( + async () => { + return await client.fetch({ + document: RootLayoutMetadataQuery, + fetchOptions: { cache: 'no-store' }, + }); + }, + ['root-layout-metadata'], + { revalidate }, +); + +const fetchRootLayoutMetadata = cache(async () => getCachedRootLayoutMetadata()); export async function generateMetadata(): Promise { const { data } = await fetchRootLayoutMetadata(); diff --git a/core/client/correlation-id.ts b/core/client/correlation-id.ts new file mode 100644 index 0000000000..f3ce208e1a --- /dev/null +++ b/core/client/correlation-id.ts @@ -0,0 +1,8 @@ +import { cache } from 'react'; + +/** + * Returns a stable correlation ID for the current request. + * React.cache ensures the same UUID is returned for all fetches within a + * single page render, while being unique across renders/requests. + */ +export const getCorrelationId = cache((): string => crypto.randomUUID()); diff --git a/core/client/index.ts b/core/client/index.ts index d14bcb463b..7dc470cf48 100644 --- a/core/client/index.ts +++ b/core/client/index.ts @@ -3,35 +3,7 @@ import { BigCommerceAuthError, createClient } from '@bigcommerce/catalyst-client import { getChannelIdFromLocale } from '../channels.config'; import { backendUserAgent } from '../user-agent'; -// next/headers, next/navigation, and next-intl/server are imported dynamically -// (via `import()`) rather than statically. Static imports cause these modules to -// be evaluated during module graph resolution when next.config.ts imports this -// file, which poisons the process-wide AsyncLocalStorage context (pnpm symlinks -// create two separate singleton instances of next/headers). Dynamic imports -// defer module loading to call time, after Next.js has fully initialized. -// -// During config resolution, the dynamic import of next-intl/server succeeds but -// getLocale() throws ("not supported in Client Components") — the try/catch -// below absorbs this gracefully, and getChannelId falls back to defaultChannelId. - -const getLocale = async () => { - try { - const { getLocale: getServerLocale } = await import('next-intl/server'); - - return await getServerLocale(); - } catch { - /** - * Next-intl `getLocale` only works on the server, and when the proxy has run. - * - * Instances when `getLocale` will not work: - * - Requests during next.config.ts resolution - * - Requests in proxies - * - Requests in `generateStaticParams` - * - Request in api routes - * - Requests in static sites without `setRequestLocale` - */ - } -}; +import { getCorrelationId } from './correlation-id'; export const client = createClient({ storefrontToken: process.env.BIGCOMMERCE_STOREFRONT_TOKEN ?? '', @@ -41,30 +13,30 @@ 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(); - - // We use the default channelId as a fallback, but it is not ideal in some scenarios. + getChannelId: (defaultChannelId: string, locale?: string) => { return getChannelIdFromLocale(locale) ?? defaultChannelId; }, beforeRequest: async (fetchOptions) => { // We can't serialize a `Headers` object within this method so we have to opt into using a plain object const requestHeaders: Record = {}; - 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'); - - if (ipAddress) { - requestHeaders['X-Forwarded-For'] = ipAddress; - requestHeaders['True-Client-IP'] = ipAddress; + try { + // headers() is a dynamic API unavailable inside unstable_cache; skip IP forwarding in that context + 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; + } + } catch { + // Not in a request context (e.g. inside unstable_cache); IP forwarding not available } } - if (locale) { - requestHeaders['Accept-Language'] = locale; - } + requestHeaders['X-Correlation-ID'] = getCorrelationId(); return { headers: requestHeaders, diff --git a/core/components/footer/index.tsx b/core/components/footer/index.tsx index 8148c2c035..5316ee1dbb 100644 --- a/core/components/footer/index.tsx +++ b/core/components/footer/index.tsx @@ -6,6 +6,7 @@ import { SiX, SiYoutube, } from '@icons-pack/react-simple-icons'; +import { unstable_cache } from 'next/cache'; import { getTranslations } from 'next-intl/server'; import { cache, JSX } from 'react'; @@ -46,30 +47,55 @@ const socialIcons: Record = { YouTube: { icon: }, }; -const getFooterSections = cache( - async (customerAccessToken?: string, currencyCode?: CurrencyCode) => { +const getCachedFooterSections = unstable_cache( + async (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 } }, + fetchOptions: { cache: 'no-store' }, }); return readFragment(FooterSectionsFragment, response).site; }, + ['get-footer-sections'], + { revalidate }, ); -const getFooterData = cache(async () => { - const { data: response } = await client.fetch({ - document: LayoutQuery, - fetchOptions: { next: { revalidate } }, - }); +const getFooterSections = cache( + async (customerAccessToken?: string, currencyCode?: CurrencyCode) => { + if (customerAccessToken) { + const { data: response } = await client.fetch({ + document: GetLinksAndSectionsQuery, + customerAccessToken, + variables: { currencyCode }, + validateCustomerAccessToken: false, + fetchOptions: { cache: 'no-store' }, + }); + + return readFragment(FooterSectionsFragment, response).site; + } + + return getCachedFooterSections(currencyCode); + }, +); + +const getCachedFooterData = unstable_cache( + async () => { + const { data: response } = await client.fetch({ + document: LayoutQuery, + fetchOptions: { cache: 'no-store' }, + }); + + return readFragment(FooterFragment, response).site; + }, + ['get-footer-data'], + { revalidate }, +); - return readFragment(FooterFragment, response).site; -}); +const getFooterData = cache(async () => getCachedFooterData()); export const Footer = async () => { const t = await getTranslations('Components.Footer'); diff --git a/core/components/header/index.tsx b/core/components/header/index.tsx index f686f8d65d..1f0839e6be 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,28 +48,53 @@ const getCartCount = cache(async (cartId: string, customerAccessToken?: string) return response.data.site.cart?.lineItems.totalQuantity ?? null; }); +const getCachedHeaderLinks = unstable_cache( + async (currencyCode?: CurrencyCode) => { + const { data: response } = await client.fetch({ + document: GetLinksAndSectionsQuery, + 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: { cache: 'no-store' }, + }); + + return readFragment(HeaderLinksFragment, response).site; + }, + ['get-header-links'], + { revalidate }, +); + 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 } }, - }); + if (customerAccessToken) { + const { data: response } = await client.fetch({ + document: GetLinksAndSectionsQuery, + customerAccessToken, + variables: { currencyCode }, + validateCustomerAccessToken: false, + fetchOptions: { cache: 'no-store' }, + }); + + return readFragment(HeaderLinksFragment, response).site; + } - return readFragment(HeaderLinksFragment, response).site; + return getCachedHeaderLinks(currencyCode); }); -const getHeaderData = cache(async () => { - const { data: response } = await client.fetch({ - document: LayoutQuery, - fetchOptions: { next: { revalidate } }, - }); +const getCachedHeaderData = unstable_cache( + async () => { + const { data: response } = await client.fetch({ + document: LayoutQuery, + fetchOptions: { cache: 'no-store' }, + }); - return readFragment(HeaderFragment, response).site; -}); + return readFragment(HeaderFragment, response).site; + }, + ['get-header-data'], + { revalidate }, +); + +const getHeaderData = cache(async () => getCachedHeaderData()); export const Header = async () => { const t = await getTranslations('Components.Header'); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index dd25618110..6930069969 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -20,7 +20,7 @@ interface Config { platform?: string; backendUserAgentExtensions?: string; logger?: boolean; - getChannelId?: (defaultChannelId: string) => Promise | string; + getChannelId?: (defaultChannelId: string, locale?: string) => Promise | string; beforeRequest?: ( fetchOptions?: FetcherRequestInit, ) => Promise | undefined> | Partial | undefined; @@ -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)) ?? {}; @@ -136,6 +141,7 @@ class Client { 'Content-Type': 'application/json', Authorization: `Bearer ${this.config.storefrontToken}`, 'User-Agent': this.backendUserAgent, + ...(locale && { 'Accept-Language': locale }), ...(customerAccessToken && { 'X-Bc-Customer-Access-Token': customerAccessToken }), ...(validateCustomerAccessToken && { 'X-Bc-Error-On-Invalid-Customer-Access-Token': 'true', @@ -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 30b3a9cffe95f7a76154885e4f89c4465ffac169 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Mon, 9 Mar 2026 11:30:40 -0500 Subject: [PATCH 2/5] fix: update review query to use unstable_cached --- .../product/[slug]/_components/reviews.tsx | 51 +++++++++++++++---- .../(default)/product/[slug]/page.tsx | 2 + 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx index 47a533d5c9..a2f5b14f23 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,18 +65,48 @@ 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 (locale: string, productId: number, paginationArgs: object) => { + const { data } = await client.fetch({ + document: ReviewsQuery, + variables: { ...paginationArgs, entityId: productId }, + locale, + fetchOptions: { next: { revalidate } }, + }); - return data.site.product; -}); + return data.site.product; + }, + ['get-reviews'], + { revalidate }, +); + +const getReviews = cache( + async ( + locale: string, + productId: number, + paginationArgs: object, + customerAccessToken?: string, + ) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: ReviewsQuery, + variables: { ...paginationArgs, entityId: productId }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; + } + + return getCachedReviews(locale, productId, paginationArgs); + }, +); interface Props { productId: number; + locale: string; + customerAccessToken?: string; searchParams: Promise; streamableImages: Streamable<{ images: Array<{ src: string; alt: string }>; @@ -87,6 +118,8 @@ interface Props { export const Reviews = async ({ productId, + locale, + customerAccessToken, searchParams, streamableProduct, streamableImages, @@ -103,7 +136,7 @@ export const Reviews = async ({ } = paginationSearchParams; const paginationArgs = before == null ? { first: 5, after } : { last: 5, before }; - return getReviews(productId, paginationArgs); + return getReviews(locale, productId, paginationArgs, customerAccessToken); }); const streamableReviews = Streamable.from(async () => { diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index 26e6790b99..2f728dbef4 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -613,6 +613,8 @@ export default async function Product({ params, searchParams }: Props) { {showRating && (
Date: Mon, 9 Mar 2026 11:37:52 -0500 Subject: [PATCH 3/5] fix: update vanity query url to use unstable_cache --- core/lib/seo/canonical.ts | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/core/lib/seo/canonical.ts b/core/lib/seo/canonical.ts index dd1573947a..a21e82b2f0 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,20 +46,26 @@ 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: { next: { revalidate } }, + }); - 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; + }, + ['get-vanity-url'], + { revalidate }, +); - return vanityUrl; -}); +const getVanityUrl = cache(getCachedVanityUrl); export async function getMetadataAlternates(options: CanonicalUrlOptions) { const { path, locale, includeAlternates = true } = options; From cd035fa3c3dd1e7449ba9c1f75dec660b9ab7405 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Mon, 9 Mar 2026 14:40:38 -0500 Subject: [PATCH 4/5] fix: update web page children query to use unstable_cache --- .../(default)/webpages/[id]/layout.tsx | 56 +++++++++++-------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/core/app/[locale]/(default)/webpages/[id]/layout.tsx b/core/app/[locale]/(default)/webpages/[id]/layout.tsx index cde3f40ea5..a2633afd3e 100644 --- a/core/app/[locale]/(default)/webpages/[id]/layout.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/layout.tsx @@ -1,4 +1,5 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { unstable_cache } from 'next/cache'; import { setRequestLocale } from 'next-intl/server'; import { cache } from 'react'; @@ -45,34 +46,43 @@ interface PageLink { href: string; } -const getWebPageChildren = cache(async (id: string): Promise => { - const { data } = await client.fetch({ - document: WebPageChildrenQuery, - variables: { id: decodeURIComponent(id) }, - fetchOptions: { next: { revalidate } }, - }); +const getCachedWebPageChildren = unstable_cache( + async (locale: string, id: string): Promise => { + const { data } = await client.fetch({ + document: WebPageChildrenQuery, + variables: { id: decodeURIComponent(id) }, + locale, + fetchOptions: { cache: 'no-store' }, + }); - if (!data.node) { - return []; - } + if (!data.node) { + return []; + } - if (!('children' in data.node)) { - return []; - } + if (!('children' in data.node)) { + return []; + } - const { children } = data.node; + const { children } = data.node; - return removeEdgesAndNodes(children).reduce((acc: PageLink[], child) => { - if ('path' in child) { - return [...acc, { label: child.name, href: child.path }]; - } + return removeEdgesAndNodes(children).reduce((acc: PageLink[], child) => { + if ('path' in child) { + return [...acc, { label: child.name, href: child.path }]; + } - if ('link' in child) { - return [...acc, { label: child.name, href: child.link }]; - } + if ('link' in child) { + return [...acc, { label: child.name, href: child.link }]; + } + + return acc; + }, []); + }, + ['get-webpage-children'], + { revalidate }, +); - return acc; - }, []); +const getWebPageChildren = cache(async (locale: string, id: string): Promise => { + return getCachedWebPageChildren(locale, id); }); export default async function WebPageLayout({ params, children }: Props) { @@ -82,7 +92,7 @@ export default async function WebPageLayout({ params, children }: Props) { return ( } + sidebar={} sidebarSize="small" > {children} From d3557c83afe1d08f7e3389f436ac66aaf7c7115b Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Mon, 13 Apr 2026 12:51:53 -0500 Subject: [PATCH 5/5] feat: use cached components --- .../(auth)/change-password/page-data.ts | 36 +-- .../(default)/(auth)/change-password/page.tsx | 12 +- core/app/[locale]/(default)/(auth)/layout.tsx | 14 +- .../(default)/(auth)/login/_actions/login.ts | 2 +- .../(auth)/login/forgot-password/page.tsx | 18 +- .../[locale]/(default)/(auth)/login/page.tsx | 13 +- .../(auth)/login/token/[token]/route.ts | 2 - .../[locale]/(default)/(auth)/logout/route.ts | 2 +- .../register/_actions/register-customer.ts | 2 +- .../(default)/(auth)/register/page-data.ts | 64 +++--- .../(default)/(auth)/register/page.tsx | 10 +- .../(faceted)/brand/[slug]/page-data.ts | 28 +-- .../(default)/(faceted)/brand/[slug]/page.tsx | 81 ++++--- .../(faceted)/category/[slug]/page-data.ts | 28 +-- .../(faceted)/category/[slug]/page.tsx | 127 +++++++---- .../(faceted)/fetch-compare-products.ts | 36 +-- .../(faceted)/fetch-faceted-search.ts | 134 +++++------ .../(default)/(faceted)/search/page-data.ts | 26 +-- .../(default)/(faceted)/search/page.tsx | 10 +- .../(default)/account/addresses/page.tsx | 35 ++- .../app/[locale]/(default)/account/layout.tsx | 12 +- .../(default)/account/orders/[id]/page.tsx | 17 +- .../(default)/account/orders/page.tsx | 29 ++- .../(default)/account/settings/page.tsx | 25 ++- .../(default)/account/wishlists/[id]/page.tsx | 50 +++-- .../(default)/account/wishlists/page.tsx | 25 ++- .../(default)/blog/[blogId]/page-data.ts | 36 +-- .../[locale]/(default)/blog/[blogId]/page.tsx | 14 +- core/app/[locale]/(default)/blog/page-data.ts | 99 +++++---- core/app/[locale]/(default)/blog/page.tsx | 42 ++-- core/app/[locale]/(default)/cart/loading.tsx | 2 + core/app/[locale]/(default)/cart/page-data.ts | 26 +-- core/app/[locale]/(default)/cart/page.tsx | 10 +- core/app/[locale]/(default)/checkout/route.ts | 2 +- .../[locale]/(default)/compare/page-data.ts | 46 ++-- core/app/[locale]/(default)/compare/page.tsx | 6 +- .../gift-certificates/balance/page.tsx | 23 +- .../(default)/gift-certificates/page-data.ts | 36 +-- .../(default)/gift-certificates/page.tsx | 23 +- .../gift-certificates/purchase/page-data.ts | 40 ++-- .../gift-certificates/purchase/page.tsx | 19 +- core/app/[locale]/(default)/layout.tsx | 11 +- core/app/[locale]/(default)/page-data.ts | 28 +-- core/app/[locale]/(default)/page.tsx | 4 +- .../[slug]/_actions/wishlist-action.ts | 2 +- .../product/[slug]/_components/reviews.tsx | 34 +-- .../search-params-router-refresh.tsx | 2 +- .../add-to-new-wishlist-modal.tsx | 2 +- .../_components/wishlist-button/dropdown.tsx | 2 +- .../(default)/product/[slug]/page-data.ts | 188 ++++++++-------- .../(default)/product/[slug]/page.tsx | 197 +++++++++++------ .../contact/_actions/submit-contact-form.ts | 2 +- .../webpages/[id]/contact/page-data.ts | 30 +-- .../(default)/webpages/[id]/contact/page.tsx | 4 +- .../(default)/webpages/[id]/layout.tsx | 78 ++++--- .../webpages/[id]/normal/page-data.ts | 30 +-- .../(default)/webpages/[id]/normal/page.tsx | 4 +- .../(default)/wishlist/[token]/page-data.ts | 40 ++-- .../(default)/wishlist/[token]/page.tsx | 43 ++-- core/app/[locale]/layout.tsx | 104 +++++---- core/app/[locale]/maintenance/page.tsx | 33 +-- core/app/admin/route.ts | 2 +- core/app/favicon.ico/route.ts | 31 ++- core/app/layout.tsx | 14 -- core/app/robots.txt/route.ts | 19 +- core/app/xmlsitemap.php/route.ts | 2 +- core/auth/handle-login-cart.ts | 20 ++ core/auth/index.ts | 25 +-- core/client/correlation-id.ts | 16 +- core/client/index.ts | 20 +- core/components/footer/index.tsx | 86 ++++---- core/components/force-refresh/index.tsx | 2 +- core/components/header/index.tsx | 116 +++++----- core/components/link/index.tsx | 48 ++-- core/i18n/navigation.ts | 10 + core/i18n/request.ts | 20 +- core/i18n/routing.ts | 8 - core/lib/recaptcha.ts | 13 +- core/lib/seo/canonical.ts | 32 +-- core/next.config.ts | 2 + core/package.json | 4 +- core/vibes/soul/lib/streamable.tsx | 5 +- .../compare-card/add-to-cart-form.tsx | 2 +- .../soul/primitives/navigation/index.tsx | 2 +- .../wishlist-item-add-to-cart.tsx | 2 +- .../sections/order-details-section/index.tsx | 14 +- .../product-detail/product-detail-form.tsx | 2 +- .../soul/sections/product-list/index.tsx | 6 +- .../sections/products-list-section/index.tsx | 2 +- .../sidebar-menu/sidebar-menu-link.tsx | 2 +- .../sidebar-menu/sidebar-menu-select.tsx | 2 +- .../soul/sections/wishlist-details/index.tsx | 12 +- pnpm-lock.yaml | 208 ++++++++++-------- 93 files changed, 1572 insertions(+), 1277 deletions(-) delete mode 100644 core/app/layout.tsx create mode 100644 core/auth/handle-login-cart.ts create mode 100644 core/i18n/navigation.ts diff --git a/core/app/[locale]/(default)/(auth)/change-password/page-data.ts b/core/app/[locale]/(default)/(auth)/change-password/page-data.ts index c48ab1d1a4..e6b1f6ed83 100644 --- a/core/app/[locale]/(default)/(auth)/change-password/page-data.ts +++ b/core/app/[locale]/(default)/(auth)/change-password/page-data.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -25,23 +25,23 @@ const ChangePasswordQuery = graphql(` } `); -const getCachedChangePasswordQuery = unstable_cache( - async (locale: string) => { - const response = await client.fetch({ - document: ChangePasswordQuery, - locale, - }); - - const passwordComplexitySettings = - response.data.site.settings?.customers?.passwordComplexitySettings; - - return { - passwordComplexitySettings, - }; - }, - ['get-change-password-query'], - { revalidate }, -); +async function getCachedChangePasswordQuery(locale: string) { + 'use cache'; + + cacheLife({ revalidate }); + + const response = await client.fetch({ + document: ChangePasswordQuery, + locale, + }); + + const passwordComplexitySettings = + response.data.site.settings?.customers?.passwordComplexitySettings; + + return { + passwordComplexitySettings, + }; +} export const getChangePasswordQuery = cache(async (locale: string) => { return getCachedChangePasswordQuery(locale); diff --git a/core/app/[locale]/(default)/(auth)/change-password/page.tsx b/core/app/[locale]/(default)/(auth)/change-password/page.tsx index fe8f70832d..75ffe75706 100644 --- a/core/app/[locale]/(default)/(auth)/change-password/page.tsx +++ b/core/app/[locale]/(default)/(auth)/change-password/page.tsx @@ -1,10 +1,10 @@ /* eslint-disable react/jsx-no-bind */ import { Metadata } from 'next'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { ResetPasswordSection } from '@/vibes/soul/sections/reset-password-section'; import { getChangePasswordQuery } from '~/app/[locale]/(default)/(auth)/change-password/page-data'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { changePassword } from './_actions/change-password'; @@ -16,10 +16,8 @@ interface Props { }>; } -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Auth.ChangePassword' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Auth.ChangePassword'); return { title: t('title'), @@ -29,8 +27,6 @@ export async function generateMetadata({ params }: Props): Promise { export default async function ChangePassword({ params, searchParams }: Props) { const { locale } = await params; - setRequestLocale(locale); - const { c: customerEntityId, t: token } = await searchParams; const t = await getTranslations('Auth.ChangePassword'); diff --git a/core/app/[locale]/(default)/(auth)/layout.tsx b/core/app/[locale]/(default)/(auth)/layout.tsx index cdccae9970..8f26a65c52 100644 --- a/core/app/[locale]/(default)/(auth)/layout.tsx +++ b/core/app/[locale]/(default)/(auth)/layout.tsx @@ -1,13 +1,13 @@ -import { PropsWithChildren } from 'react'; +import { PropsWithChildren, Suspense } from 'react'; import { isLoggedIn } from '~/auth'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; interface Props extends PropsWithChildren { params: Promise<{ locale: string }>; } -export default async function Layout({ children, params }: Props) { +async function AuthGuard({ children, params }: Props) { const loggedIn = await isLoggedIn(); const { locale } = await params; @@ -17,3 +17,11 @@ export default async function Layout({ children, params }: Props) { return children; } + +export default function Layout({ children, params }: Props) { + return ( + + {children} + + ); +} diff --git a/core/app/[locale]/(default)/(auth)/login/_actions/login.ts b/core/app/[locale]/(default)/(auth)/login/_actions/login.ts index 640ccc5d4f..b0f1b5f37f 100644 --- a/core/app/[locale]/(default)/(auth)/login/_actions/login.ts +++ b/core/app/[locale]/(default)/(auth)/login/_actions/login.ts @@ -8,7 +8,7 @@ import { getLocale, getTranslations } from 'next-intl/server'; import { schema } from '@/vibes/soul/sections/sign-in-section/schema'; import { signIn } from '~/auth'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { getCartId } from '~/lib/cart'; export const login = async ( diff --git a/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx b/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx index 58dd54691f..b5758b8033 100644 --- a/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx +++ b/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx @@ -1,29 +1,19 @@ import { Metadata } from 'next'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { ForgotPasswordSection } from '@/vibes/soul/sections/forgot-password-section'; import { resetPassword } from './_actions/reset-password'; -interface Props { - params: Promise<{ locale: string }>; -} - -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Auth.Login.ForgotPassword' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Auth.Login.ForgotPassword'); return { title: t('title'), }; } -export default async function Reset(props: Props) { - const { locale } = await props.params; - - setRequestLocale(locale); - +export default async function Reset() { const t = await getTranslations('Auth.Login.ForgotPassword'); return ( diff --git a/core/app/[locale]/(default)/(auth)/login/page.tsx b/core/app/[locale]/(default)/(auth)/login/page.tsx index dadb1eeb5c..d641a7ad73 100644 --- a/core/app/[locale]/(default)/(auth)/login/page.tsx +++ b/core/app/[locale]/(default)/(auth)/login/page.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/jsx-no-bind */ import { Metadata } from 'next'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { ButtonLink } from '@/vibes/soul/primitives/button-link'; import { SignInSection } from '@/vibes/soul/sections/sign-in-section'; @@ -17,22 +17,17 @@ interface Props { }>; } -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Auth.Login' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Auth.Login'); return { title: t('title'), }; } -export default async function Login({ params, searchParams }: Props) { - const { locale } = await params; +export default async function Login({ searchParams }: Props) { const { redirectTo = '/account/orders', error } = await searchParams; - setRequestLocale(locale); - const t = await getTranslations('Auth.Login'); const vanityUrl = buildConfig.get('urls').vanityUrl; diff --git a/core/app/[locale]/(default)/(auth)/login/token/[token]/route.ts b/core/app/[locale]/(default)/(auth)/login/token/[token]/route.ts index 16f806330a..e7e092e828 100644 --- a/core/app/[locale]/(default)/(auth)/login/token/[token]/route.ts +++ b/core/app/[locale]/(default)/(auth)/login/token/[token]/route.ts @@ -35,5 +35,3 @@ export async function GET(_: Request, { params }: { params: Promise<{ token: str redirect(`/login?error=InvalidToken`); } } - -export const dynamic = 'force-dynamic'; diff --git a/core/app/[locale]/(default)/(auth)/logout/route.ts b/core/app/[locale]/(default)/(auth)/logout/route.ts index 713cc99acb..a433a34278 100644 --- a/core/app/[locale]/(default)/(auth)/logout/route.ts +++ b/core/app/[locale]/(default)/(auth)/logout/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from 'next/server'; import { signOut } from '~/auth'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { setForceRefreshCookie } from '~/lib/force-refresh'; export const GET = async ( diff --git a/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts b/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts index 29c5fee3ed..eed974d307 100644 --- a/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts +++ b/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts @@ -12,7 +12,7 @@ import { signIn } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; import { FieldNameToFieldId } from '~/data-transformers/form-field-transformer/utils'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { getCartId } from '~/lib/cart'; import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha'; diff --git a/core/app/[locale]/(default)/(auth)/register/page-data.ts b/core/app/[locale]/(default)/(auth)/register/page-data.ts index 7e15fb00fc..d0be4f875e 100644 --- a/core/app/[locale]/(default)/(auth)/register/page-data.ts +++ b/core/app/[locale]/(default)/(auth)/register/page-data.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -62,40 +62,40 @@ interface Props { }; } -const getCachedRegisterCustomerQuery = unstable_cache( - async (locale: string, { address, customer }: Props) => { - const response = await client.fetch({ - document: RegisterCustomerQuery, - variables: { - addressFilters: address?.filters, - addressSortBy: address?.sortBy, - customerFilters: customer?.filters, - customerSortBy: customer?.sortBy, - }, - fetchOptions: { cache: 'no-store' }, - locale, - }); +async function getCachedRegisterCustomerQuery(locale: string, { address, customer }: Props) { + 'use cache'; - const addressFields = response.data.site.settings?.formFields.shippingAddress; - const customerFields = response.data.site.settings?.formFields.customer; - const countries = response.data.geography.countries; - const passwordComplexitySettings = - response.data.site.settings?.customers?.passwordComplexitySettings; + cacheLife({ revalidate }); - if (!addressFields || !customerFields) { - return null; - } + const response = await client.fetch({ + document: RegisterCustomerQuery, + variables: { + addressFilters: address?.filters, + addressSortBy: address?.sortBy, + customerFilters: customer?.filters, + customerSortBy: customer?.sortBy, + }, + fetchOptions: { cache: 'no-store' }, + locale, + }); - return { - addressFields, - customerFields, - countries, - passwordComplexitySettings, - }; - }, - ['get-register-customer-query'], - { revalidate }, -); + const addressFields = response.data.site.settings?.formFields.shippingAddress; + const customerFields = response.data.site.settings?.formFields.customer; + const countries = response.data.geography.countries; + const passwordComplexitySettings = + response.data.site.settings?.customers?.passwordComplexitySettings; + + if (!addressFields || !customerFields) { + return null; + } + + return { + addressFields, + customerFields, + countries, + passwordComplexitySettings, + }; +} export const getRegisterCustomerQuery = cache(async (locale: string, props: Props) => { return getCachedRegisterCustomerQuery(locale, props); diff --git a/core/app/[locale]/(default)/(auth)/register/page.tsx b/core/app/[locale]/(default)/(auth)/register/page.tsx index a4a12e2963..e96d8bc52f 100644 --- a/core/app/[locale]/(default)/(auth)/register/page.tsx +++ b/core/app/[locale]/(default)/(auth)/register/page.tsx @@ -1,6 +1,6 @@ import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { Field } from '@/vibes/soul/form/dynamic-form/schema'; import { DynamicFormSection } from '@/vibes/soul/sections/dynamic-form-section'; @@ -24,10 +24,8 @@ interface Props { params: Promise<{ locale: string }>; } -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Auth.Register' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Auth.Register'); return { title: t('title'), @@ -48,8 +46,6 @@ function removeExlusiveOffersField(field: Field | Field[]): boolean { export default async function Register({ params }: Props) { const { locale } = await params; - setRequestLocale(locale); - const t = await getTranslations('Auth.Register'); const registerCustomerData = await getRegisterCustomerQuery(locale, { diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts index c7e0296921..16db07c61c 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -39,20 +39,20 @@ const BrandPageQuery = graphql(` } `); -const getCachedBrandPageData = unstable_cache( - async (locale: string, entityId: number) => { - const response = await client.fetch({ - document: BrandPageQuery, - variables: { entityId }, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedBrandPageData(locale: string, entityId: number) { + 'use cache'; - return response.data.site; - }, - ['get-brand-page-data'], - { revalidate }, -); + cacheLife({ revalidate }); + + const response = await client.fetch({ + document: BrandPageQuery, + variables: { entityId }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return response.data.site; +} export const getBrandPageData = cache( async (locale: string, entityId: number, customerAccessToken?: string) => { diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx index 8433461b2c..3657ddac1b 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getLocale, getTranslations } from 'next-intl/server'; import { createLoader, SearchParams } from 'nuqs/server'; import { cache } from 'react'; @@ -96,39 +96,55 @@ export async function generateMetadata(props: Props): Promise { }; } -export default async function Brand(props: Props) { - const [{ locale, slug }, customerAccessToken, t] = await Promise.all([ - props.params, - getSessionCustomerAccessToken(), - getTranslations('Faceted'), - ]); +export default async function Brand({ params, searchParams }: Props) { + const locale = await getLocale(); + const t = await getTranslations('Faceted'); - setRequestLocale(locale); + // Cached (guest) brand data for the static shell — always uses the cached path + // so title, settings, showRating resolve instantly from 'use cache' during PPR. + const streamableCachedBrandData = Streamable.from(async () => { + const { slug } = await params; - const brandId = Number(slug); + return getBrandPageData(locale, Number(slug)); + }); - const { brand, settings } = await getBrandPageData(locale, brandId, customerAccessToken); + const streamableTitle = Streamable.from(async () => { + const { brand } = await streamableCachedBrandData; - if (!brand) { - return notFound(); - } + if (!brand) { + return notFound(); + } - const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); + return brand.name; + }); + + const streamableShowRating = Streamable.from(async () => { + const { settings } = await streamableCachedBrandData; + + return Boolean(settings?.reviews.enabled && settings.display.showProductRating); + }); - const productComparisonsEnabled = - settings?.storefront.catalog?.productComparisonsEnabled ?? false; + const streamableShowCompare = Streamable.from(async () => { + const { settings } = await streamableCachedBrandData; + + return settings?.storefront.catalog?.productComparisonsEnabled ?? false; + }); const streamableFacetedSearch = Streamable.from(async () => { - const searchParams = await props.searchParams; - const currencyCode = await getPreferredCurrencyCode(); + const { slug } = await params; + const searchParamsResolved = await searchParams; + const [customerAccessToken, currencyCode] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + ]); const loadSearchParams = await createBrandSearchParamsLoader(locale, slug, customerAccessToken); - const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; + const parsedSearchParams = loadSearchParams?.(searchParamsResolved) ?? {}; const search = await fetchFacetedSearch( locale, { - ...searchParams, + ...searchParamsResolved, ...parsedSearchParams, brand: [slug], }, @@ -140,7 +156,7 @@ export default async function Brand(props: Props) { }); const streamableProducts = Streamable.from(async () => { - const format = await getFormatter(); + const [format, { settings }] = await Promise.all([getFormatter(), streamableCachedBrandData]); const search = await streamableFacetedSearch; const products = search.products.items; @@ -170,9 +186,12 @@ export default async function Brand(props: Props) { }); const streamableFilters = Streamable.from(async () => { - const searchParams = await props.searchParams; + const { slug } = await params; + const searchParamsResolved = await searchParams; + const customerAccessToken = await getSessionCustomerAccessToken(); + const loadSearchParams = await createBrandSearchParamsLoader(locale, slug, customerAccessToken); - const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; + const parsedSearchParams = loadSearchParams?.(searchParamsResolved) ?? {}; const cachedBrand = getCachedBrand(slug); const categorySearch = await fetchFacetedSearch( locale, @@ -192,20 +211,22 @@ export default async function Brand(props: Props) { const transformedFacets = await facetsTransformer({ refinedFacets, allFacets, - searchParams: { ...searchParams, ...parsedSearchParams }, + searchParams: { ...searchParamsResolved, ...parsedSearchParams }, }); return transformedFacets.filter((facet) => facet != null); }); const streamableCompareProducts = Streamable.from(async () => { - const searchParams = await props.searchParams; + const searchParamsResolved = await searchParams; + const showCompare = await streamableShowCompare; - if (!productComparisonsEnabled) { + if (!showCompare) { return []; } - const { compare } = compareLoader(searchParams); + const customerAccessToken = await getSessionCustomerAccessToken(); + const { compare } = compareLoader(searchParamsResolved); const compareIds = { entityIds: compare ? compare.map((id: string) => Number(id)) : [] }; @@ -237,8 +258,8 @@ export default async function Brand(props: Props) { rangeFilterApplyLabel={t('FacetedSearch.Range.apply')} removeLabel={t('Compare.remove')} resetFiltersLabel={t('FacetedSearch.resetFilters')} - showCompare={productComparisonsEnabled} - showRating={showRating} + showCompare={streamableShowCompare} + showRating={streamableShowRating} sortDefaultValue="featured" sortLabel={t('Search.title')} sortOptions={[ @@ -253,7 +274,7 @@ export default async function Brand(props: Props) { { value: 'relevance', label: t('SortBy.relevance') }, ]} sortParamName="sort" - title={brand.name} + title={streamableTitle} totalCount={streamableTotalCount} /> ); diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts index 2e49f085d5..c11eb16937 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -59,20 +59,20 @@ const CategoryPageQuery = graphql( [BreadcrumbsCategoryFragment], ); -const getCachedCategoryPageData = unstable_cache( - async (locale: string, entityId: number) => { - const response = await client.fetch({ - document: CategoryPageQuery, - variables: { entityId }, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedCategoryPageData(locale: string, entityId: number) { + 'use cache'; - return response.data.site; - }, - ['get-category-page-data'], - { revalidate }, -); + cacheLife({ revalidate }); + + const response = await client.fetch({ + document: CategoryPageQuery, + variables: { entityId }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return response.data.site; +} export const getCategoryPageData = cache( async (locale: string, entityId: number, customerAccessToken?: string) => { diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx index 4a88820965..3e38a61c68 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx @@ -1,7 +1,7 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getLocale, getTranslations } from 'next-intl/server'; import { createLoader, SearchParams } from 'nuqs/server'; import { cache } from 'react'; @@ -103,52 +103,82 @@ export async function generateMetadata(props: Props): Promise { }; } -export default async function Category(props: Props) { - const [{ slug, locale }, customerAccessToken, t] = await Promise.all([ - props.params, - getSessionCustomerAccessToken(), - getTranslations('Faceted'), - ]); +export default async function Category({ params, searchParams }: Props) { + const locale = await getLocale(); + const t = await getTranslations('Faceted'); - setRequestLocale(locale); + // Category data for the static shell. Uses precomputed auth flag: + // Cached (guest) category data for the static shell — always uses the cached path + // so title, breadcrumbs, settings resolve instantly from 'use cache' during PPR. + const streamableCachedCategoryData = Streamable.from(async () => { + const { slug } = await params; - const categoryId = Number(slug); + return getCategoryPageData(locale, Number(slug)); + }); - const { category, settings, categoryTree } = await getCategoryPageData( - locale, - categoryId, - customerAccessToken, - ); + // Auth-dependent category data for personalized content (analytics). + const streamableCategoryData = Streamable.from(async () => { + const { slug } = await params; + const customerAccessToken = await getSessionCustomerAccessToken(); - if (!category) { - return notFound(); - } + return getCategoryPageData(locale, Number(slug), customerAccessToken); + }); + + const streamableTitle = Streamable.from(async () => { + const { category } = await streamableCachedCategoryData; + + if (!category) { + return notFound(); + } - const breadcrumbs = removeEdgesAndNodes(category.breadcrumbs).map(({ name, path }) => ({ - label: name, - href: path ?? '#', - })); + return category.name; + }); + + const streamableBreadcrumbs = Streamable.from(async () => { + const { category } = await streamableCachedCategoryData; - const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); + if (!category) { + return []; + } + + return removeEdgesAndNodes(category.breadcrumbs).map(({ name, path }) => ({ + label: name, + href: path ?? '#', + })); + }); - const productComparisonsEnabled = - settings?.storefront.catalog?.productComparisonsEnabled ?? false; + const streamableShowRating = Streamable.from(async () => { + const { settings } = await streamableCachedCategoryData; + + return Boolean(settings?.reviews.enabled && settings.display.showProductRating); + }); + + const streamableShowCompare = Streamable.from(async () => { + const { settings } = await streamableCachedCategoryData; + + return settings?.storefront.catalog?.productComparisonsEnabled ?? false; + }); const streamableFacetedSearch = Streamable.from(async () => { - const searchParams = await props.searchParams; - const currencyCode = await getPreferredCurrencyCode(); + const { slug } = await params; + const categoryId = Number(slug); + const searchParamsResolved = await searchParams; + const [customerAccessToken, currencyCode] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + ]); const loadSearchParams = await createCategorySearchParamsLoader( locale, categoryId, customerAccessToken, ); - const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; + const parsedSearchParams = loadSearchParams?.(searchParamsResolved) ?? {}; const search = await fetchFacetedSearch( locale, { - ...searchParams, + ...searchParamsResolved, ...parsedSearchParams, category: categoryId, }, @@ -160,7 +190,10 @@ export default async function Category(props: Props) { }); const streamableProducts = Streamable.from(async () => { - const format = await getFormatter(); + const [format, { settings }] = await Promise.all([ + getFormatter(), + streamableCachedCategoryData, + ]); const search = await streamableFacetedSearch; const products = search.products.items; @@ -190,14 +223,18 @@ export default async function Category(props: Props) { }); const streamableFilters = Streamable.from(async () => { - const searchParams = await props.searchParams; + const { slug } = await params; + const categoryId = Number(slug); + const searchParamsResolved = await searchParams; + const customerAccessToken = await getSessionCustomerAccessToken(); + const { categoryTree } = await streamableCachedCategoryData; const loadSearchParams = await createCategorySearchParamsLoader( locale, categoryId, customerAccessToken, ); - const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; + const parsedSearchParams = loadSearchParams?.(searchParamsResolved) ?? {}; const cachedCategory = getCachedCategory(categoryId); const categorySearch = await fetchFacetedSearch( locale, @@ -217,7 +254,7 @@ export default async function Category(props: Props) { const transformedFacets = await facetsTransformer({ refinedFacets, allFacets, - searchParams: { ...searchParams, ...parsedSearchParams }, + searchParams: { ...searchParamsResolved, ...parsedSearchParams }, }); const filters = transformedFacets.filter((facet) => facet != null); @@ -241,13 +278,15 @@ export default async function Category(props: Props) { }); const streamableCompareProducts = Streamable.from(async () => { - const searchParams = await props.searchParams; + const searchParamsResolved = await searchParams; + const showCompare = await streamableShowCompare; - if (!productComparisonsEnabled) { + if (!showCompare) { return []; } - const { compare } = compareLoader(searchParams); + const customerAccessToken = await getSessionCustomerAccessToken(); + const { compare } = compareLoader(searchParamsResolved); const compareIds = { entityIds: compare ? compare.map((id: string) => Number(id)) : [] }; @@ -266,7 +305,7 @@ export default async function Category(props: Props) { return ( <> - - {(search) => } + + {({ category }) => ( + + {(search) => + category && + } + + )} ); diff --git a/core/app/[locale]/(default)/(faceted)/fetch-compare-products.ts b/core/app/[locale]/(default)/(faceted)/fetch-compare-products.ts index af4f0139ce..de24f05319 100644 --- a/core/app/[locale]/(default)/(faceted)/fetch-compare-products.ts +++ b/core/app/[locale]/(default)/(faceted)/fetch-compare-products.ts @@ -1,6 +1,6 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { VariablesOf } from 'gql.tada'; -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { z } from 'zod'; @@ -43,26 +43,26 @@ const CompareProductsQuery = graphql(` type Variables = VariablesOf; -const getCachedCompareProducts = unstable_cache( - async (locale: string, variables: Variables) => { - const parsedVariables = CompareProductsSchema.parse(variables); +async function getCachedCompareProducts(locale: string, variables: Variables) { + 'use cache'; - if (parsedVariables.entityIds.length === 0) { - return []; - } + cacheLife({ revalidate }); - const response = await client.fetch({ - document: CompareProductsQuery, - variables: { ...parsedVariables, first: MAX_COMPARE_LIMIT }, - locale, - fetchOptions: { cache: 'no-store' }, - }); + const parsedVariables = CompareProductsSchema.parse(variables); - return removeEdgesAndNodes(response.data.site.products); - }, - ['get-compare-products'], - { revalidate }, -); + if (parsedVariables.entityIds.length === 0) { + return []; + } + + const response = await client.fetch({ + document: CompareProductsQuery, + variables: { ...parsedVariables, first: MAX_COMPARE_LIMIT }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return removeEdgesAndNodes(response.data.site.products); +} export const getCompareProducts = cache( async (locale: string, variables: Variables, customerAccessToken?: string) => { diff --git a/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts b/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts index 019b33e35d..c909cbc3ca 100644 --- a/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts +++ b/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts @@ -1,5 +1,5 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { z } from 'zod'; @@ -179,72 +179,72 @@ interface ProductSearch { filters: SearchProductsFiltersInput; } -const getCachedProductSearchResults = unstable_cache( - async ( - locale: string, - { limit = 9, after, before, sort, filters }: ProductSearch, - currencyCode?: CurrencyCode, - ) => { - const filterArgs = { filters, sort }; - const paginationArgs = before ? { last: limit, before } : { first: limit, after }; - - const response = await client.fetch({ - document: GetProductSearchResultsQuery, - variables: { ...filterArgs, ...paginationArgs, currencyCode }, - locale, - fetchOptions: { cache: 'no-store' }, - }); - - const { site } = response.data; - const searchResults = site.search.searchProducts; - - const items = removeEdgesAndNodes(searchResults.products).map((product) => ({ - ...product, - })); - - return { - facets: { - items: removeEdgesAndNodes(searchResults.filters).map((node) => { - switch (node.__typename) { - case 'BrandSearchFilter': - return { - ...node, - brands: removeEdgesAndNodes(node.brands), - }; - - case 'CategorySearchFilter': - return { - ...node, - categories: removeEdgesAndNodes(node.categories), - }; - - case 'ProductAttributeSearchFilter': - return { - ...node, - attributes: removeEdgesAndNodes(node.attributes), - }; - - case 'RatingSearchFilter': - return { - ...node, - ratings: removeEdgesAndNodes(node.ratings), - }; - - default: - return node; - } - }), - }, - products: { - collectionInfo: searchResults.products.collectionInfo, - pageInfo: searchResults.products.pageInfo, - items, - }, - }; - }, - ['get-product-search-results'], - { revalidate: 300 }, -); +async function getCachedProductSearchResults( + locale: string, + { limit = 9, after, before, sort, filters }: ProductSearch, + currencyCode?: CurrencyCode, +) { + 'use cache'; + + cacheLife({ revalidate: 300 }); + + const filterArgs = { filters, sort }; + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; + + const response = await client.fetch({ + document: GetProductSearchResultsQuery, + variables: { ...filterArgs, ...paginationArgs, currencyCode }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + const { site } = response.data; + const searchResults = site.search.searchProducts; + + const items = removeEdgesAndNodes(searchResults.products).map((product) => ({ + ...product, + })); + + return { + facets: { + items: removeEdgesAndNodes(searchResults.filters).map((node) => { + switch (node.__typename) { + case 'BrandSearchFilter': + return { + ...node, + brands: removeEdgesAndNodes(node.brands), + }; + + case 'CategorySearchFilter': + return { + ...node, + categories: removeEdgesAndNodes(node.categories), + }; + + case 'ProductAttributeSearchFilter': + return { + ...node, + attributes: removeEdgesAndNodes(node.attributes), + }; + + case 'RatingSearchFilter': + return { + ...node, + ratings: removeEdgesAndNodes(node.ratings), + }; + + default: + return node; + } + }), + }, + products: { + collectionInfo: searchResults.products.collectionInfo, + pageInfo: searchResults.products.pageInfo, + items, + }, + }; +} const getProductSearchResults = cache( // We need to make sure the reference passed into this function is the same if we want it to be memoized. diff --git a/core/app/[locale]/(default)/(faceted)/search/page-data.ts b/core/app/[locale]/(default)/(faceted)/search/page-data.ts index 607832f973..0c5798b03b 100644 --- a/core/app/[locale]/(default)/(faceted)/search/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/search/page-data.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -30,19 +30,19 @@ const SearchPageQuery = graphql(` } `); -const getCachedSearchPageData = unstable_cache( - async (locale: string) => { - const response = await client.fetch({ - document: SearchPageQuery, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedSearchPageData(locale: string) { + 'use cache'; - return response.data.site; - }, - ['get-search-page-data'], - { revalidate }, -); + cacheLife({ revalidate }); + + const response = await client.fetch({ + document: SearchPageQuery, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return response.data.site; +} export const getSearchPageData = cache(async (locale: string) => { return getCachedSearchPageData(locale); diff --git a/core/app/[locale]/(default)/(faceted)/search/page.tsx b/core/app/[locale]/(default)/(faceted)/search/page.tsx index d5f8007a34..469086e6a5 100644 --- a/core/app/[locale]/(default)/(faceted)/search/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/search/page.tsx @@ -1,5 +1,5 @@ import { Metadata } from 'next'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; import { createLoader, SearchParams } from 'nuqs/server'; import { cache } from 'react'; @@ -59,10 +59,8 @@ interface Props { searchParams: Promise; } -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Faceted.Search' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Faceted.Search'); return { title: t('title'), @@ -72,8 +70,6 @@ export async function generateMetadata({ params }: Props): Promise { export default async function Search(props: Props) { const { locale } = await props.params; - setRequestLocale(locale); - const t = await getTranslations('Faceted'); const { settings } = await getSearchPageData(locale); diff --git a/core/app/[locale]/(default)/account/addresses/page.tsx b/core/app/[locale]/(default)/account/addresses/page.tsx index 865ba4f575..7495afc27d 100644 --- a/core/app/[locale]/(default)/account/addresses/page.tsx +++ b/core/app/[locale]/(default)/account/addresses/page.tsx @@ -1,6 +1,7 @@ import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; +import { Suspense } from 'react'; import { Address, AddressListSection } from '@/vibes/soul/sections/address-list-section'; import { getSessionCustomerAccessToken } from '~/auth'; @@ -27,21 +28,25 @@ interface Props { }>; } -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Account.Addresses' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Account.Addresses'); return { title: t('title'), }; } -export default async function Addresses({ params, searchParams }: Props) { - const { locale } = await params; - - setRequestLocale(locale); - +async function AddressesContent({ + locale, + searchParams, +}: { + locale: string; + searchParams: Promise<{ + [key: string]: string | string[] | undefined; + before?: string; + after?: string; + }>; +}) { const [customerAccessToken, t, { before, after }] = await Promise.all([ getSessionCustomerAccessToken(), getTranslations('Account.Addresses'), @@ -119,3 +124,13 @@ export default async function Addresses({ params, searchParams }: Props) { /> ); } + +export default async function Addresses(props: Props) { + const { locale } = await props.params; + + return ( + + + + ); +} diff --git a/core/app/[locale]/(default)/account/layout.tsx b/core/app/[locale]/(default)/account/layout.tsx index 5aa581dd47..d2713ffa42 100644 --- a/core/app/[locale]/(default)/account/layout.tsx +++ b/core/app/[locale]/(default)/account/layout.tsx @@ -1,18 +1,10 @@ -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { PropsWithChildren } from 'react'; import { SidebarMenu } from '@/vibes/soul/sections/sidebar-menu'; import { StickySidebarLayout } from '@/vibes/soul/sections/sticky-sidebar-layout'; -interface Props extends PropsWithChildren { - params: Promise<{ locale: string }>; -} - -export default async function Layout({ children, params }: Props) { - const { locale } = await params; - - setRequestLocale(locale); - +export default async function Layout({ children }: PropsWithChildren) { const t = await getTranslations('Account.Layout'); return ( diff --git a/core/app/[locale]/(default)/account/orders/[id]/page.tsx b/core/app/[locale]/(default)/account/orders/[id]/page.tsx index 4fbc166565..eb4d39b56e 100644 --- a/core/app/[locale]/(default)/account/orders/[id]/page.tsx +++ b/core/app/[locale]/(default)/account/orders/[id]/page.tsx @@ -1,5 +1,5 @@ import { notFound } from 'next/navigation'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; import { Streamable } from '@/vibes/soul/lib/streamable'; import { OrderDetailsSection } from '@/vibes/soul/sections/order-details-section'; @@ -15,15 +15,12 @@ interface Props { }>; } -export default async function OrderDetails(props: Props) { - const { id, locale } = await props.params; - - setRequestLocale(locale); - +export default async function OrderDetails({ params }: Props) { const t = await getTranslations('Account.Orders.Details'); const format = await getFormatter(); const streamableOrder = Streamable.from(async () => { + const { id } = await params; const customerAccessToken = await getSessionCustomerAccessToken(); const order = await getCustomerOrderDetails(Number(id), customerAccessToken); @@ -34,6 +31,12 @@ export default async function OrderDetails(props: Props) { return orderDetailsTransformer(order, t, format); }); + const streamableTitle = Streamable.from(async () => { + const { id } = await params; + + return t('title', { orderNumber: id }); + }); + return ( ); } diff --git a/core/app/[locale]/(default)/account/orders/page.tsx b/core/app/[locale]/(default)/account/orders/page.tsx index 45b7d14940..20aa2b8c63 100644 --- a/core/app/[locale]/(default)/account/orders/page.tsx +++ b/core/app/[locale]/(default)/account/orders/page.tsx @@ -1,5 +1,6 @@ -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; +import { Streamable } from '@/vibes/soul/lib/streamable'; import { Order, OrderList } from '@/vibes/soul/sections/order-list'; import { getSessionCustomerAccessToken } from '~/auth'; import { ordersTransformer } from '~/data-transformers/orders-transformer'; @@ -62,21 +63,29 @@ async function getPaginationInfo( export default async function Orders({ params, searchParams }: Props) { const { locale } = await params; - setRequestLocale(locale); - - const [{ before, after }, t, customerAccessToken] = await Promise.all([ - searchParams, - getTranslations('Account.Orders'), - getSessionCustomerAccessToken(), - ]); + const t = await getTranslations('Account.Orders'); return ( { + const [{ before, after }, customerAccessToken] = await Promise.all([ + searchParams, + getSessionCustomerAccessToken(), + ]); + + return getOrders(locale, after, before, customerAccessToken); + })} + paginationInfo={Streamable.from(async () => { + const [{ before, after }, customerAccessToken] = await Promise.all([ + searchParams, + getSessionCustomerAccessToken(), + ]); + + return getPaginationInfo(locale, after, before, customerAccessToken); + })} title={t('title')} totalLabel={t('totalPrice')} viewDetailsLabel={t('viewDetails')} diff --git a/core/app/[locale]/(default)/account/settings/page.tsx b/core/app/[locale]/(default)/account/settings/page.tsx index 26cdaad81e..f0fcc19705 100644 --- a/core/app/[locale]/(default)/account/settings/page.tsx +++ b/core/app/[locale]/(default)/account/settings/page.tsx @@ -1,7 +1,8 @@ /* eslint-disable react/jsx-no-bind */ import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; +import { Suspense } from 'react'; import { AccountSettingsSection } from '@/vibes/soul/sections/account-settings'; import { getSessionCustomerAccessToken } from '~/auth'; @@ -15,21 +16,15 @@ interface Props { params: Promise<{ locale: string }>; } -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Account.Settings' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Account.Settings'); return { title: t('title'), }; } -export default async function Settings({ params }: Props) { - const { locale } = await params; - - setRequestLocale(locale); - +async function SettingsContent() { const t = await getTranslations('Account.Settings'); const customerAccessToken = await getSessionCustomerAccessToken(); @@ -71,3 +66,13 @@ export default async function Settings({ params }: Props) { /> ); } + +export default async function Settings(props: Props) { + await props.params; + + return ( + + + + ); +} diff --git a/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx b/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx index b55be1d869..e2115c811e 100644 --- a/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx +++ b/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx @@ -1,5 +1,5 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getLocale, getTranslations } from 'next-intl/server'; import { SearchParams } from 'nuqs'; import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'; @@ -11,7 +11,7 @@ import { ExistingResultType } from '~/client/util'; import type { CurrencyCode } from '~/components/header/fragment'; import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { wishlistDetailsTransformer } from '~/data-transformers/wishlists-transformer'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { getPreferredCurrencyCode } from '~/lib/currency'; import { isMobileUser } from '~/lib/user-agent'; @@ -120,15 +120,11 @@ async function getPaginationInfo( } export default async function WishlistPage({ params, searchParams }: Props) { - const { locale, id } = await params; + const locale = await getLocale(); - setRequestLocale(locale); - - const [t, pt, customerAccessToken, currencyCode] = await Promise.all([ + const [t, pt] = await Promise.all([ getTranslations('Wishlist'), getTranslations('Product.ProductDetails'), - getSessionCustomerAccessToken(), - getPreferredCurrencyCode(), ]); const wishlistActions = (wishlist?: Wishlist) => { if (!wishlist) { @@ -138,7 +134,7 @@ export default async function WishlistPage({ params, searchParams }: Props) { return ( - getAnalyticsData(locale, id, searchParams, customerAccessToken, currencyCode), - )} + data={Streamable.from(async () => { + const { id } = await params; + const [customerAccessToken, currencyCode] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + ]); + + return getAnalyticsData(locale, id, searchParams, customerAccessToken, currencyCode); + })} > - getPaginationInfo(locale, id, searchParams, customerAccessToken, currencyCode), - )} + paginationInfo={Streamable.from(async () => { + const { id } = await params; + const [customerAccessToken, currencyCode] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + ]); + + return getPaginationInfo(locale, id, searchParams, customerAccessToken, currencyCode); + })} prevHref="/account/wishlists" removeAction={removeWishlistItem} removeButtonTitle={t('removeButtonTitle')} - wishlist={Streamable.from(() => - getWishlist(id, t, pt, searchParams, locale, customerAccessToken, currencyCode), - )} + wishlist={Streamable.from(async () => { + const { id } = await params; + const [customerAccessToken, currencyCode] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + ]); + + return getWishlist(id, t, pt, searchParams, locale, customerAccessToken, currencyCode); + })} /> ); diff --git a/core/app/[locale]/(default)/account/wishlists/page.tsx b/core/app/[locale]/(default)/account/wishlists/page.tsx index 26e9b738c0..0288b7730e 100644 --- a/core/app/[locale]/(default)/account/wishlists/page.tsx +++ b/core/app/[locale]/(default)/account/wishlists/page.tsx @@ -1,6 +1,7 @@ -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; import { SearchParams } from 'nuqs'; import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'; +import { Suspense } from 'react'; import { Streamable } from '@/vibes/soul/lib/streamable'; import { CursorPaginationInfo } from '@/vibes/soul/primitives/cursor-pagination'; @@ -78,11 +79,13 @@ async function getPaginationInfo( return pageInfoTransformer(wishlists?.pageInfo ?? defaultPageInfo); } -export default async function Wishlists({ params, searchParams }: Props) { - const { locale } = await params; - - setRequestLocale(locale); - +async function WishlistsContent({ + locale, + searchParams, +}: { + locale: string; + searchParams: Promise; +}) { const [t, isMobile, customerAccessToken, currencyCode] = await Promise.all([ getTranslations('Wishlist'), isMobileUser(), @@ -155,3 +158,13 @@ export default async function Wishlists({ params, searchParams }: Props) { /> ); } + +export default async function Wishlists(props: Props) { + const { locale } = await props.params; + + return ( + + + + ); +} diff --git a/core/app/[locale]/(default)/blog/[blogId]/page-data.ts b/core/app/[locale]/(default)/blog/[blogId]/page-data.ts index 3acda342f3..99287dd477 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page-data.ts +++ b/core/app/[locale]/(default)/blog/[blogId]/page-data.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -39,26 +39,26 @@ const BlogPageQuery = graphql(` type Variables = VariablesOf; -const getCachedBlogPageData = unstable_cache( - async (locale: string, variables: Variables) => { - const response = await client.fetch({ - document: BlogPageQuery, - variables, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedBlogPageData(locale: string, variables: Variables) { + 'use cache'; - const { blog } = response.data.site.content; + cacheLife({ revalidate }); - if (!blog?.post) { - return null; - } + const response = await client.fetch({ + document: BlogPageQuery, + variables, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + const { blog } = response.data.site.content; + + if (!blog?.post) { + return null; + } - return blog; - }, - ['get-blog-page-data'], - { revalidate }, -); + return blog; +} export const getBlogPageData = cache(async (locale: string, variables: Variables) => { return getCachedBlogPageData(locale, variables); diff --git a/core/app/[locale]/(default)/blog/[blogId]/page.tsx b/core/app/[locale]/(default)/blog/[blogId]/page.tsx index 94c4959405..14c8ca2cc2 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page.tsx +++ b/core/app/[locale]/(default)/blog/[blogId]/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; import { cache } from 'react'; import { BlogPostContent, BlogPostContentBlogPost } from '@/vibes/soul/sections/blog-post-content'; @@ -43,7 +43,8 @@ export async function generateMetadata({ params }: Props): Promise { } async function getBlogPost(props: Props): Promise { - const [format, { blogId, locale }] = await Promise.all([getFormatter(), props.params]); + const { blogId, locale } = await props.params; + const format = await getFormatter(); const variables = cachedBlogPageDataVariables(blogId); @@ -72,7 +73,8 @@ async function getBlogPost(props: Props): Promise { } async function getBlogPostBreadcrumbs(props: Props): Promise { - const [t, { blogId, locale }] = await Promise.all([getTranslations('Blog'), props.params]); + const { blogId, locale } = await props.params; + const t = await getTranslations('Blog'); const variables = cachedBlogPageDataVariables(blogId); @@ -99,11 +101,7 @@ async function getBlogPostBreadcrumbs(props: Props): Promise { ]; } -export default async function Blog(props: Props) { - const { locale } = await props.params; - - setRequestLocale(locale); - +export default function Blog(props: Props) { return ( ); diff --git a/core/app/[locale]/(default)/blog/page-data.ts b/core/app/[locale]/(default)/blog/page-data.ts index bd62608475..cac3016d25 100644 --- a/core/app/[locale]/(default)/blog/page-data.ts +++ b/core/app/[locale]/(default)/blog/page-data.ts @@ -1,5 +1,5 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { getFormatter } from 'next-intl/server'; import { cache } from 'react'; @@ -73,63 +73,66 @@ interface Pagination { after: string | null; } -const getCachedBlog = unstable_cache( - async (locale: string) => { - const response = await client.fetch({ - document: BlogQuery, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedBlog(locale: string) { + 'use cache'; - return response.data.site.content.blog; - }, - ['get-blog'], - { revalidate }, -); + cacheLife({ revalidate }); + + const response = await client.fetch({ + document: BlogQuery, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return response.data.site.content.blog; +} export const getBlog = cache(async (locale: string) => { return getCachedBlog(locale); }); -const getCachedBlogPosts = unstable_cache( - async (locale: string, { tag, limit = 9, before, after }: BlogPostsFiltersInput & Pagination) => { - const filterArgs = tag ? { filters: { tags: [tag] } } : {}; - const paginationArgs = before ? { last: limit, before } : { first: limit, after }; +async function getCachedBlogPosts( + locale: string, + { tag, limit = 9, before, after }: BlogPostsFiltersInput & Pagination, +) { + 'use cache'; - const response = await client.fetch({ - document: BlogPostsPageQuery, - variables: { ...filterArgs, ...paginationArgs }, - locale, - fetchOptions: { cache: 'no-store' }, - }); + cacheLife({ revalidate }); - const { blog } = response.data.site.content; + const filterArgs = tag ? { filters: { tags: [tag] } } : {}; + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; - if (!blog) { - return null; - } + const response = await client.fetch({ + document: BlogPostsPageQuery, + variables: { ...filterArgs, ...paginationArgs }, + locale, + fetchOptions: { cache: 'no-store' }, + }); - return { - pageInfo: blog.posts.pageInfo, - posts: removeEdgesAndNodes(blog.posts).map((post) => ({ - id: String(post.entityId), - author: post.author, - content: post.plainTextSummary, - dateUtc: post.publishedDate.utc, - image: post.thumbnailImage - ? { - src: post.thumbnailImage.url, - alt: post.thumbnailImage.altText, - } - : undefined, - href: post.path, - title: post.name, - })), - }; - }, - ['get-blog-posts'], - { revalidate }, -); + const { blog } = response.data.site.content; + + if (!blog) { + return null; + } + + return { + pageInfo: blog.posts.pageInfo, + posts: removeEdgesAndNodes(blog.posts).map((post) => ({ + id: String(post.entityId), + author: post.author, + content: post.plainTextSummary, + dateUtc: post.publishedDate.utc, + image: post.thumbnailImage + ? { + src: post.thumbnailImage.url, + alt: post.thumbnailImage.altText, + } + : undefined, + href: post.path, + title: post.name, + })), + }; +} export const getBlogPosts = cache( async (locale: string, { tag, limit = 9, before, after }: BlogPostsFiltersInput & Pagination) => { diff --git a/core/app/[locale]/(default)/blog/page.tsx b/core/app/[locale]/(default)/blog/page.tsx index f7b972f709..73828627bf 100644 --- a/core/app/[locale]/(default)/blog/page.tsx +++ b/core/app/[locale]/(default)/blog/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { SearchParams } from 'nuqs'; import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'; @@ -28,7 +28,7 @@ const searchParamsCache = createSearchParamsCache({ export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; - const t = await getTranslations({ locale, namespace: 'Blog' }); + const t = await getTranslations('Blog'); const blog = await getBlog(locale); const description = @@ -73,36 +73,38 @@ async function getPaginationInfo(locale: string, searchParamsPromise: Promise { + const searchParamsParsed = searchParamsCache.parse(await props.searchParams); + const { tag } = searchParamsParsed; + const tagCrumb = tag ? [{ label: tag, href: '#' }] : []; + + return [ + { + label: t('home'), + href: '/', + }, + { + label: blog.name, + href: tag ? blog.path : '#', + }, + ...tagCrumb, + ]; + }); return ( getEmptyStateSubtitle())} + emptyStateTitle={Streamable.from(() => getEmptyStateTitle())} paginationInfo={Streamable.from(() => getPaginationInfo(locale, props.searchParams))} placeholderCount={6} posts={Streamable.from(() => listBlogPosts(locale, props.searchParams))} diff --git a/core/app/[locale]/(default)/cart/loading.tsx b/core/app/[locale]/(default)/cart/loading.tsx index 1f0e533b4f..7704b74980 100644 --- a/core/app/[locale]/(default)/cart/loading.tsx +++ b/core/app/[locale]/(default)/cart/loading.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useTranslations } from 'next-intl'; import { CartSkeleton } from '@/vibes/soul/sections/cart'; diff --git a/core/app/[locale]/(default)/cart/page-data.ts b/core/app/[locale]/(default)/cart/page-data.ts index 19d9d58d6f..b20c686a9e 100644 --- a/core/app/[locale]/(default)/cart/page-data.ts +++ b/core/app/[locale]/(default)/cart/page-data.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -339,19 +339,19 @@ const SupportedShippingDestinationsQuery = graphql(` } `); -const getCachedShippingCountries = unstable_cache( - async (locale: string) => { - const { data } = await client.fetch({ - document: SupportedShippingDestinationsQuery, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedShippingCountries(locale: string) { + 'use cache'; - return data.site.settings?.shipping?.supportedShippingDestinations.countries ?? []; - }, - ['get-shipping-countries'], - { revalidate }, -); + cacheLife({ revalidate }); + + const { data } = await client.fetch({ + document: SupportedShippingDestinationsQuery, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.settings?.shipping?.supportedShippingDestinations.countries ?? []; +} export const getShippingCountries = cache(async (locale: string) => { return getCachedShippingCountries(locale); diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index 9967347516..6dba6f7a5f 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -1,5 +1,5 @@ import { Metadata } from 'next'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; import { Streamable } from '@/vibes/soul/lib/streamable'; import { Cart as CartComponent, CartEmptyState } from '@/vibes/soul/sections/cart'; @@ -23,10 +23,8 @@ interface Props { const CHECKOUT_URL = process.env.TRAILING_SLASH !== 'false' ? '/checkout/' : '/checkout'; -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Cart' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Cart'); return { title: t('title'), @@ -64,8 +62,6 @@ const getAnalyticsData = async (locale: string, cartId: string, customerAccessTo export default async function Cart({ params }: Props) { const { locale } = await params; - setRequestLocale(locale); - const [t, cartId] = await Promise.all([getTranslations('Cart'), getCartId()]); if (!cartId) { diff --git a/core/app/[locale]/(default)/checkout/route.ts b/core/app/[locale]/(default)/checkout/route.ts index f1e18dc9d2..c2fcecbb5d 100644 --- a/core/app/[locale]/(default)/checkout/route.ts +++ b/core/app/[locale]/(default)/checkout/route.ts @@ -7,7 +7,7 @@ import { getSessionCustomerAccessToken } from '~/auth'; import { getChannelIdFromLocale } from '~/channels.config'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { getVisitIdCookie, getVisitorIdCookie } from '~/lib/analytics/bigcommerce'; import { getCartId } from '~/lib/cart'; import { getConsentCookie } from '~/lib/consent-manager/cookies/server'; diff --git a/core/app/[locale]/(default)/compare/page-data.ts b/core/app/[locale]/(default)/compare/page-data.ts index 9b4348f9b9..5a91faeca6 100644 --- a/core/app/[locale]/(default)/compare/page-data.ts +++ b/core/app/[locale]/(default)/compare/page-data.ts @@ -1,5 +1,5 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -56,28 +56,32 @@ const ComparedProductsQuery = graphql( [ProductCardFragment], ); -const getCachedComparedProducts = unstable_cache( - async (locale: string, productIds: number[], currencyCode?: CurrencyCode) => { - if (productIds.length === 0) { - return []; - } +async function getCachedComparedProducts( + locale: string, + productIds: number[], + currencyCode?: CurrencyCode, +) { + 'use cache'; - const { data } = await client.fetch({ - document: ComparedProductsQuery, - variables: { - entityIds: productIds, - first: productIds.length ? MAX_COMPARE_LIMIT : 0, - currencyCode, - }, - locale, - fetchOptions: { cache: 'no-store' }, - }); + cacheLife({ revalidate }); - return removeEdgesAndNodes(data.site.products); - }, - ['get-compared-products'], - { revalidate }, -); + if (productIds.length === 0) { + return []; + } + + const { data } = await client.fetch({ + document: ComparedProductsQuery, + variables: { + entityIds: productIds, + first: productIds.length ? MAX_COMPARE_LIMIT : 0, + currencyCode, + }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return removeEdgesAndNodes(data.site.products); +} export const getComparedProducts = cache( async ( diff --git a/core/app/[locale]/(default)/compare/page.tsx b/core/app/[locale]/(default)/compare/page.tsx index 5f5866d492..f44be0e483 100644 --- a/core/app/[locale]/(default)/compare/page.tsx +++ b/core/app/[locale]/(default)/compare/page.tsx @@ -1,6 +1,6 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { Metadata } from 'next'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; import * as z from 'zod'; import { Streamable } from '@/vibes/soul/lib/streamable'; @@ -41,7 +41,7 @@ interface Props { export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; - const t = await getTranslations({ locale, namespace: 'Compare' }); + const t = await getTranslations('Compare'); return { title: t('title'), @@ -52,8 +52,6 @@ export async function generateMetadata({ params }: Props): Promise { export default async function Compare(props: Props) { const { locale } = await props.params; - setRequestLocale(locale); - const t = await getTranslations('Compare'); const streamableProducts = Streamable.from(async () => { diff --git a/core/app/[locale]/(default)/gift-certificates/balance/page.tsx b/core/app/[locale]/(default)/gift-certificates/balance/page.tsx index e6eca42d97..a5d552590b 100644 --- a/core/app/[locale]/(default)/gift-certificates/balance/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/balance/page.tsx @@ -1,8 +1,9 @@ import type { Metadata } from 'next'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; +import { Suspense } from 'react'; import { GiftCertificateCheckBalanceSection } from '@/vibes/soul/sections/gift-certificate-balance-section'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMetadataAlternates } from '~/lib/seo/canonical'; @@ -17,7 +18,7 @@ interface Props { export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; - const t = await getTranslations({ locale, namespace: 'GiftCertificates' }); + const t = await getTranslations('GiftCertificates'); return { title: t('title') || 'Gift certificates - Check balance', @@ -25,11 +26,7 @@ export async function generateMetadata({ params }: Props): Promise { }; } -export default async function GiftCertificates(props: Props) { - const { locale } = await props.params; - - setRequestLocale(locale); - +async function GiftCertificatesBalanceContent({ locale }: { locale: string }) { const t = await getTranslations('GiftCertificates'); const currencyCode = await getPreferredCurrencyCode(); const data = await getGiftCertificatesData(locale, currencyCode); @@ -63,3 +60,13 @@ export default async function GiftCertificates(props: Props) { /> ); } + +export default async function GiftCertificates(props: Props) { + const { locale } = await props.params; + + return ( + + + + ); +} diff --git a/core/app/[locale]/(default)/gift-certificates/page-data.ts b/core/app/[locale]/(default)/gift-certificates/page-data.ts index 5ece542997..50313b90e7 100644 --- a/core/app/[locale]/(default)/gift-certificates/page-data.ts +++ b/core/app/[locale]/(default)/gift-certificates/page-data.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -27,24 +27,24 @@ const GiftCertificatesRootQuery = graphql( [StoreLogoFragment], ); -const getCachedGiftCertificatesData = unstable_cache( - async (locale: string, currencyCode?: CurrencyCode) => { - const response = await client.fetch({ - document: GiftCertificatesRootQuery, - variables: { currencyCode }, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedGiftCertificatesData(locale: string, currencyCode?: CurrencyCode) { + 'use cache'; - return { - giftCertificatesEnabled: response.data.site.settings?.giftCertificates?.isEnabled ?? false, - defaultCurrency: response.data.site.settings?.currency.defaultCurrency ?? undefined, - logo: response.data.site.settings ? logoTransformer(response.data.site.settings) : '', - }; - }, - ['get-gift-certificates-data'], - { revalidate }, -); + cacheLife({ revalidate }); + + const response = await client.fetch({ + document: GiftCertificatesRootQuery, + variables: { currencyCode }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return { + giftCertificatesEnabled: response.data.site.settings?.giftCertificates?.isEnabled ?? false, + defaultCurrency: response.data.site.settings?.currency.defaultCurrency ?? undefined, + logo: response.data.site.settings ? logoTransformer(response.data.site.settings) : '', + }; +} export const getGiftCertificatesData = cache( async (locale: string, currencyCode?: CurrencyCode) => { diff --git a/core/app/[locale]/(default)/gift-certificates/page.tsx b/core/app/[locale]/(default)/gift-certificates/page.tsx index 67e62827c0..47fd3a3f11 100644 --- a/core/app/[locale]/(default)/gift-certificates/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/page.tsx @@ -1,8 +1,9 @@ import type { Metadata } from 'next'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; +import { Suspense } from 'react'; import { GiftCertificatesSection } from '@/vibes/soul/sections/gift-certificates-section'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMetadataAlternates } from '~/lib/seo/canonical'; @@ -15,7 +16,7 @@ interface Props { export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; - const t = await getTranslations({ locale, namespace: 'GiftCertificates' }); + const t = await getTranslations('GiftCertificates'); return { title: t('title') || 'Gift certificates', @@ -23,11 +24,7 @@ export async function generateMetadata({ params }: Props): Promise { }; } -export default async function GiftCertificates(props: Props) { - const { locale } = await props.params; - - setRequestLocale(locale); - +async function GiftCertificatesContent({ locale }: { locale: string }) { const t = await getTranslations('GiftCertificates'); const format = await getFormatter(); const currencyCode = await getPreferredCurrencyCode(); @@ -55,3 +52,13 @@ export default async function GiftCertificates(props: Props) { /> ); } + +export default async function GiftCertificates(props: Props) { + const { locale } = await props.params; + + return ( + + + + ); +} diff --git a/core/app/[locale]/(default)/gift-certificates/purchase/page-data.ts b/core/app/[locale]/(default)/gift-certificates/purchase/page-data.ts index 76b763f086..a3d10240b9 100644 --- a/core/app/[locale]/(default)/gift-certificates/purchase/page-data.ts +++ b/core/app/[locale]/(default)/gift-certificates/purchase/page-data.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -30,25 +30,25 @@ const GiftCertificatePurchaseSettingsQuery = graphql( [GiftCertificateSettingsFragment, StoreLogoFragment], ); -const getCachedGiftCertificatePurchaseData = unstable_cache( - async (locale: string, currencyCode?: CurrencyCode) => { - const response = await client.fetch({ - document: GiftCertificatePurchaseSettingsQuery, - variables: { currencyCode }, - locale, - fetchOptions: { cache: 'no-store' }, - }); - - return { - giftCertificateSettings: response.data.site.settings?.giftCertificates ?? null, - logo: response.data.site.settings ? logoTransformer(response.data.site.settings) : '', - storeName: response.data.site.settings?.storeName ?? undefined, - defaultCurrency: response.data.site.settings?.currency.defaultCurrency ?? undefined, - }; - }, - ['get-gift-certificate-purchase-data'], - { revalidate }, -); +async function getCachedGiftCertificatePurchaseData(locale: string, currencyCode?: CurrencyCode) { + 'use cache'; + + cacheLife({ revalidate }); + + const response = await client.fetch({ + document: GiftCertificatePurchaseSettingsQuery, + variables: { currencyCode }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return { + giftCertificateSettings: response.data.site.settings?.giftCertificates ?? null, + logo: response.data.site.settings ? logoTransformer(response.data.site.settings) : '', + storeName: response.data.site.settings?.storeName ?? undefined, + defaultCurrency: response.data.site.settings?.currency.defaultCurrency ?? undefined, + }; +} export const getGiftCertificatePurchaseData = cache( async (locale: string, currencyCode?: CurrencyCode) => { diff --git a/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx b/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx index 89feb6333f..26ad4c0d65 100644 --- a/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx @@ -1,12 +1,13 @@ import { ResultOf } from 'gql.tada'; import { Metadata } from 'next'; import { getFormatter, getTranslations } from 'next-intl/server'; +import { Suspense } from 'react'; import { Field, FieldGroup } from '@/vibes/soul/form/dynamic-form/schema'; import { GiftCertificatePurchaseSection } from '@/vibes/soul/sections/gift-certificate-purchase-section'; import { GiftCertificateSettingsFragment } from '~/app/[locale]/(default)/gift-certificates/purchase/fragment'; import { ExistingResultType } from '~/client/util'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMetadataAlternates } from '~/lib/seo/canonical'; @@ -147,12 +148,10 @@ function getExpiryDate( } } -export default async function GiftCertificatePurchasePage({ params }: Props) { - const { locale } = await params; - +async function GiftCertificatePurchaseContent({ locale }: { locale: string }) { const [t, format, currencyCode] = await Promise.all([ getTranslations({ locale, namespace: 'GiftCertificates' }), - getFormatter(), + getFormatter({ locale }), getPreferredCurrencyCode(), ]); const data = await getGiftCertificatePurchaseData(locale, currencyCode); @@ -194,3 +193,13 @@ export default async function GiftCertificatePurchasePage({ params }: Props) { /> ); } + +export default async function GiftCertificatePurchasePage(props: Props) { + const { locale } = await props.params; + + return ( + + + + ); +} diff --git a/core/app/[locale]/(default)/layout.tsx b/core/app/[locale]/(default)/layout.tsx index 95522f608e..6fa856a767 100644 --- a/core/app/[locale]/(default)/layout.tsx +++ b/core/app/[locale]/(default)/layout.tsx @@ -1,18 +1,9 @@ -import { setRequestLocale } from 'next-intl/server'; import { PropsWithChildren } from 'react'; import { Footer } from '~/components/footer'; import { Header } from '~/components/header'; -interface Props extends PropsWithChildren { - params: Promise<{ locale: string }>; -} - -export default async function DefaultLayout({ params, children }: Props) { - const { locale } = await params; - - setRequestLocale(locale); - +export default function DefaultLayout({ children }: PropsWithChildren) { return ( <>
diff --git a/core/app/[locale]/(default)/page-data.ts b/core/app/[locale]/(default)/page-data.ts index a35ef4b811..13f4985971 100644 --- a/core/app/[locale]/(default)/page-data.ts +++ b/core/app/[locale]/(default)/page-data.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -78,20 +78,20 @@ const HomePageQuery = graphql( [FeaturedProductsCarouselFragment, FeaturedProductsListFragment], ); -const getCachedPageData = unstable_cache( - async (locale: string, currencyCode?: CurrencyCode) => { - const { data } = await client.fetch({ - document: HomePageQuery, - variables: { currencyCode }, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedPageData(locale: string, currencyCode?: CurrencyCode) { + 'use cache'; - return data; - }, - ['get-page-data'], - { revalidate }, -); + cacheLife({ revalidate }); + + const { data } = await client.fetch({ + document: HomePageQuery, + variables: { currencyCode }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data; +} export const getPageData = cache( async (locale: string, currencyCode?: CurrencyCode, customerAccessToken?: string) => { diff --git a/core/app/[locale]/(default)/page.tsx b/core/app/[locale]/(default)/page.tsx index ebb7c52655..0368114ea8 100644 --- a/core/app/[locale]/(default)/page.tsx +++ b/core/app/[locale]/(default)/page.tsx @@ -1,6 +1,6 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { Metadata } from 'next'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; import { FeaturedProductCarousel } from '@/vibes/soul/sections/featured-product-carousel'; @@ -29,8 +29,6 @@ export async function generateMetadata({ params }: Props): Promise { export default async function Home({ params }: Props) { const { locale } = await params; - setRequestLocale(locale); - const t = await getTranslations('Home'); const format = await getFormatter(); diff --git a/core/app/[locale]/(default)/product/[slug]/_actions/wishlist-action.ts b/core/app/[locale]/(default)/product/[slug]/_actions/wishlist-action.ts index 385ab2d74f..e08e3f9354 100644 --- a/core/app/[locale]/(default)/product/[slug]/_actions/wishlist-action.ts +++ b/core/app/[locale]/(default)/product/[slug]/_actions/wishlist-action.ts @@ -19,7 +19,7 @@ import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { TAGS } from '~/client/tags'; import { WishlistMutationError } from '~/components/wishlist/error'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { serverToast } from '~/lib/server-toast'; const VariantIdFromSkuQuery = graphql(` diff --git a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx index a2f5b14f23..ddfdd7a068 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx @@ -1,12 +1,12 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { getFormatter, getTranslations } from 'next-intl/server'; import { createLoader, parseAsString, SearchParams } from 'nuqs/server'; import { cache } from 'react'; import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; import { Reviews as ReviewsSection } from '@/vibes/soul/sections/reviews'; -import { auth } from '~/auth'; +import { auth, getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { PaginationFragment } from '~/client/fragments/pagination'; import { graphql } from '~/client/graphql'; @@ -65,20 +65,20 @@ const ReviewsQuery = graphql( [ProductReviewSchemaFragment, PaginationFragment], ); -const getCachedReviews = unstable_cache( - async (locale: string, productId: number, paginationArgs: object) => { - const { data } = await client.fetch({ - document: ReviewsQuery, - variables: { ...paginationArgs, entityId: productId }, - locale, - fetchOptions: { next: { revalidate } }, - }); +async function getCachedReviews(locale: string, productId: number, paginationArgs: object) { + 'use cache'; - return data.site.product; - }, - ['get-reviews'], - { revalidate }, -); + cacheLife({ revalidate }); + + const { data } = await client.fetch({ + document: ReviewsQuery, + variables: { ...paginationArgs, entityId: productId }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; +} const getReviews = cache( async ( @@ -106,7 +106,6 @@ const getReviews = cache( interface Props { productId: number; locale: string; - customerAccessToken?: string; searchParams: Promise; streamableImages: Streamable<{ images: Array<{ src: string; alt: string }>; @@ -119,7 +118,6 @@ interface Props { export const Reviews = async ({ productId, locale, - customerAccessToken, searchParams, streamableProduct, streamableImages, @@ -136,6 +134,8 @@ export const Reviews = async ({ } = paginationSearchParams; const paginationArgs = before == null ? { first: 5, after } : { last: 5, before }; + const customerAccessToken = await getSessionCustomerAccessToken(); + return getReviews(locale, productId, paginationArgs, customerAccessToken); }); diff --git a/core/app/[locale]/(default)/product/[slug]/_components/search-params-router-refresh.tsx b/core/app/[locale]/(default)/product/[slug]/_components/search-params-router-refresh.tsx index 7e40e34e12..672b564cb1 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/search-params-router-refresh.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/search-params-router-refresh.tsx @@ -4,7 +4,7 @@ import { useSearchParams } from 'next/navigation'; import { SearchParams } from 'nuqs'; import { useEffect } from 'react'; -import { useRouter } from '~/i18n/routing'; +import { useRouter } from '~/i18n/navigation'; // Not-so-great workaround for https://github.com/vercel/next.js/issues/59407 export const SearchParamsRouterRefresh = ({ diff --git a/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/add-to-new-wishlist-modal.tsx b/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/add-to-new-wishlist-modal.tsx index 7278543728..63ddee6bde 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/add-to-new-wishlist-modal.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/add-to-new-wishlist-modal.tsx @@ -4,7 +4,7 @@ import { useSearchParams } from 'next/navigation'; import { Modal } from '~/components/modal'; import { NewWishlistModal } from '~/components/wishlist/modals/new'; -import { usePathname, useRouter } from '~/i18n/routing'; +import { usePathname, useRouter } from '~/i18n/navigation'; import { addToNewWishlist } from '../../_actions/wishlist-action'; diff --git a/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/dropdown.tsx b/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/dropdown.tsx index 2215181bdf..3dff281d0e 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/dropdown.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/dropdown.tsx @@ -5,7 +5,7 @@ import { CheckIcon, PlusIcon, XIcon } from 'lucide-react'; import { useSearchParams } from 'next/navigation'; import { DropdownMenu, DropdownMenuItem } from '@/vibes/soul/primitives/dropdown-menu'; -import { usePathname, useRouter } from '~/i18n/routing'; +import { usePathname, useRouter } from '~/i18n/navigation'; import { WishlistButtonWishlistInfo } from '.'; diff --git a/core/app/[locale]/(default)/product/[slug]/page-data.ts b/core/app/[locale]/(default)/product/[slug]/page-data.ts index ac6d3fcdda..9adf8663c4 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -155,20 +155,20 @@ const ProductPageMetadataQuery = graphql(` } `); -const getCachedProductPageMetadata = unstable_cache( - async (locale: string, entityId: number) => { - const { data } = await client.fetch({ - document: ProductPageMetadataQuery, - variables: { entityId }, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedProductPageMetadata(locale: string, entityId: number) { + 'use cache'; - return data.site.product; - }, - ['get-product-page-metadata'], - { revalidate }, -); + cacheLife({ revalidate }); + + const { data } = await client.fetch({ + document: ProductPageMetadataQuery, + variables: { entityId }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; +} export const getProductPageMetadata = cache( async (locale: string, entityId: number, customerAccessToken?: string) => { @@ -221,20 +221,20 @@ const ProductQuery = graphql( [ProductOptionsFragment], ); -const getCachedProduct = unstable_cache( - async (locale: string, entityId: number) => { - const { data } = await client.fetch({ - document: ProductQuery, - variables: { entityId }, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedProduct(locale: string, entityId: number) { + 'use cache'; - return data.site; - }, - ['get-product'], - { revalidate }, -); + cacheLife({ revalidate }); + + const { data } = await client.fetch({ + document: ProductQuery, + variables: { entityId }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site; +} export const getProduct = cache( async (locale: string, entityId: number, customerAccessToken?: string) => { @@ -292,20 +292,23 @@ const StreamableProductVariantInventoryBySkuQuery = graphql(` type VariantInventoryVariables = VariablesOf; -const getCachedStreamableProductVariantInventory = unstable_cache( - async (locale: string, variables: VariantInventoryVariables) => { - const { data } = await client.fetch({ - document: StreamableProductVariantInventoryBySkuQuery, - variables, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedStreamableProductVariantInventory( + locale: string, + variables: VariantInventoryVariables, +) { + 'use cache'; - return data.site.product?.variants; - }, - ['get-streamable-product-variant-inventory'], - { revalidate: 60 }, -); + cacheLife({ revalidate: 60 }); + + const { data } = await client.fetch({ + document: StreamableProductVariantInventoryBySkuQuery, + variables, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product?.variants; +} export const getStreamableProductVariantInventory = cache( async (locale: string, variables: VariantInventoryVariables, customerAccessToken?: string) => { @@ -385,20 +388,20 @@ const StreamableProductQuery = graphql( type Variables = VariablesOf; -const getCachedStreamableProduct = unstable_cache( - async (locale: string, variables: Variables) => { - const { data } = await client.fetch({ - document: StreamableProductQuery, - variables, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedStreamableProduct(locale: string, variables: Variables) { + 'use cache'; - return data.site.product; - }, - ['get-streamable-product'], - { revalidate }, -); + cacheLife({ revalidate }); + + const { data } = await client.fetch({ + document: StreamableProductQuery, + variables, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; +} export const getStreamableProduct = cache( async (locale: string, variables: Variables, customerAccessToken?: string) => { @@ -448,20 +451,23 @@ const StreamableProductInventoryQuery = graphql( type ProductInventoryVariables = VariablesOf; -const getCachedStreamableProductInventory = unstable_cache( - async (locale: string, variables: ProductInventoryVariables) => { - const { data } = await client.fetch({ - document: StreamableProductInventoryQuery, - variables, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedStreamableProductInventory( + locale: string, + variables: ProductInventoryVariables, +) { + 'use cache'; - return data.site.product; - }, - ['get-streamable-product-inventory'], - { revalidate: 60 }, -); + cacheLife({ revalidate: 60 }); + + const { data } = await client.fetch({ + document: StreamableProductInventoryQuery, + variables, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; +} export const getStreamableProductInventory = cache( async (locale: string, variables: ProductInventoryVariables, customerAccessToken?: string) => { @@ -512,20 +518,20 @@ const ProductPricingAndRelatedProductsQuery = graphql( [PricingFragment, FeaturedProductsCarouselFragment], ); -const getCachedProductPricingAndRelatedProducts = unstable_cache( - async (locale: string, variables: Variables) => { - const { data } = await client.fetch({ - document: ProductPricingAndRelatedProductsQuery, - variables, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedProductPricingAndRelatedProducts(locale: string, variables: Variables) { + 'use cache'; - return data.site.product; - }, - ['get-product-pricing-and-related-products'], - { revalidate }, -); + cacheLife({ revalidate }); + + const { data } = await client.fetch({ + document: ProductPricingAndRelatedProductsQuery, + variables, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; +} export const getProductPricingAndRelatedProducts = cache( async (locale: string, variables: Variables, customerAccessToken?: string) => { @@ -563,19 +569,19 @@ const InventorySettingsQuery = graphql(` } `); -const getCachedStreamableInventorySettingsQuery = unstable_cache( - async (locale: string) => { - const { data } = await client.fetch({ - document: InventorySettingsQuery, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedStreamableInventorySettingsQuery(locale: string) { + 'use cache'; - return data.site.settings?.inventory; - }, - ['get-streamable-inventory-settings'], - { revalidate }, -); + cacheLife({ revalidate }); + + const { data } = await client.fetch({ + document: InventorySettingsQuery, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.settings?.inventory; +} export const getStreamableInventorySettingsQuery = cache( async (locale: string, customerAccessToken?: string) => { diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index 2f728dbef4..974352c268 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -1,7 +1,7 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getLocale, getTranslations } from 'next-intl/server'; import { SearchParams } from 'nuqs/server'; import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; @@ -68,32 +68,36 @@ export async function generateMetadata({ params }: Props): Promise { } export default async function Product({ params, searchParams }: Props) { - const [{ locale, slug }, customerAccessToken, t, format] = await Promise.all([ - params, - getSessionCustomerAccessToken(), - getTranslations('Product'), - getFormatter(), - ]); + const locale = await getLocale(); + const [t, format] = await Promise.all([getTranslations('Product'), getFormatter()]); const detachedWishlistFormId = 'product-add-to-wishlist-form'; + const recaptchaSiteKey = await getRecaptchaSiteKey(); + + // Shared streamable that resolves params and fetches product data. + // Cached (guest) product data for the static shell — always uses the cached path + // so product name, options, settings resolve instantly from 'use cache' during PPR. + // Auth-dependent data uses the precompute flag in individual Streamable closures below. + const streamableBaseData = Streamable.from(async () => { + const { slug } = await params; + const productId = Number(slug); + const { product, settings } = await getProduct(locale, productId); + + return { productId, product, settings }; + }); - setRequestLocale(locale); - - const productId = Number(slug); - - const [{ product: baseProduct, settings }, recaptchaSiteKey] = await Promise.all([ - getProduct(locale, productId, customerAccessToken), - getRecaptchaSiteKey(), - ]); - - const reviewsEnabled = Boolean(settings?.reviews.enabled && !settings.display.showProductRating); - const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); + // Resolve productId from params inside a Streamable for components that need it. + const streamableProductId = Streamable.from(async () => { + const { productId } = await streamableBaseData; - if (!baseProduct) { - return notFound(); - } + return productId; + }); const streamableProduct = Streamable.from(async () => { - const options = await searchParams; + const { productId } = await streamableBaseData; + const [options, customerAccessToken] = await Promise.all([ + searchParams, + getSessionCustomerAccessToken(), + ]); const optionValueIds = Object.keys(options) .map((option) => ({ @@ -105,7 +109,7 @@ export default async function Product({ params, searchParams }: Props) { ); const variables = { - entityId: Number(productId), + entityId: productId, optionValueIds, useDefaultOptionSelections: true, }; @@ -122,8 +126,11 @@ export default async function Product({ params, searchParams }: Props) { const streamableProductSku = Streamable.from(async () => (await streamableProduct).sku); const streamableProductInventory = Streamable.from(async () => { + const { productId } = await streamableBaseData; + const customerAccessToken = await getSessionCustomerAccessToken(); + const variables = { - entityId: Number(productId), + entityId: productId, }; const product = await getStreamableProductInventory(locale, variables, customerAccessToken); @@ -136,12 +143,15 @@ export default async function Product({ params, searchParams }: Props) { }); const streamableProductVariantInventory = Streamable.from(async () => { + const { productId } = await streamableBaseData; const product = await streamableProductInventory; if (!product.inventory.hasVariantInventory) { return undefined; } + const customerAccessToken = await getSessionCustomerAccessToken(); + const variables = { productId, sku: product.sku, @@ -161,6 +171,7 @@ export default async function Product({ params, searchParams }: Props) { }); const streamableProductPricingAndRelatedProducts = Streamable.from(async () => { + const { productId } = await streamableBaseData; const options = await searchParams; const optionValueIds = Object.keys(options) @@ -175,12 +186,14 @@ export default async function Product({ params, searchParams }: Props) { const currencyCode = await getPreferredCurrencyCode(); const variables = { - entityId: Number(productId), + entityId: productId, optionValueIds, useDefaultOptionSelections: true, currencyCode, }; + const customerAccessToken = await getSessionCustomerAccessToken(); + return await getProductPricingAndRelatedProducts(locale, variables, customerAccessToken); }); @@ -249,6 +262,8 @@ export default async function Product({ params, searchParams }: Props) { }); const streamableInventorySettings = Streamable.from(async () => { + const customerAccessToken = await getSessionCustomerAccessToken(); + return await getStreamableInventorySettingsQuery(locale, customerAccessToken); }); @@ -552,45 +567,80 @@ export default async function Product({ params, searchParams }: Props) { return { email: session?.user?.email ?? '', name: obfuscatedName }; }); + const streamableFields = Streamable.from(async () => { + const { product: baseProduct } = await streamableBaseData; + + if (!baseProduct) { + return []; + } + + return productOptionsTransformer(baseProduct.productOptions); + }); + + const streamableProductDetail = Streamable.from(async () => { + const { product: baseProduct, settings } = await streamableBaseData; + + if (!baseProduct) { + return notFound(); + } + + const reviewsEnabled = Boolean( + settings?.reviews.enabled && !settings.display.showProductRating, + ); + const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); + + return { + id: baseProduct.entityId.toString(), + title: baseProduct.name, + description:
, + href: baseProduct.path, + images: streamableImages, + price: streamablePrices, + reviewsEnabled, + showRating, + numberOfReviews: baseProduct.reviewSummary.numberOfReviews, + subtitle: baseProduct.brand?.name, + rating: baseProduct.reviewSummary.averageRating, + accordions: streameableAccordions, + minQuantity: streamableMinQuantity, + maxQuantity: streamableMaxQuantity, + stockDisplayData: streamableStockDisplayData, + backorderDisplayData: streamableBackorderDisplayData, + }; + }); + + const streamableShowRating = Streamable.from(async () => { + const { settings } = await streamableBaseData; + + return Boolean(settings?.reviews.enabled && settings.display.showProductRating); + }); + return ( <> + + {(productId) => ( + + )} + } additionalInformationTitle={t('ProductDetails.additionalInformation')} ctaDisabled={streameableCtaDisabled} ctaLabel={streameableCtaLabel} decrementLabel={t('ProductDetails.decreaseQuantity')} emptySelectPlaceholder={t('ProductDetails.emptySelectPlaceholder')} - fields={productOptionsTransformer(baseProduct.productOptions)} + fields={streamableFields} incrementLabel={t('ProductDetails.increaseQuantity')} loadMoreImagesAction={getMoreProductImages} prefetch={true} - product={{ - id: baseProduct.entityId.toString(), - title: baseProduct.name, - description:
, - href: baseProduct.path, - images: streamableImages, - price: streamablePrices, - reviewsEnabled, - showRating, - numberOfReviews: baseProduct.reviewSummary.numberOfReviews, - subtitle: baseProduct.brand?.name, - rating: baseProduct.reviewSummary.averageRating, - accordions: streameableAccordions, - minQuantity: streamableMinQuantity, - maxQuantity: streamableMaxQuantity, - stockDisplayData: streamableStockDisplayData, - backorderDisplayData: streamableBackorderDisplayData, - }} + product={streamableProductDetail} quantityLabel={t('ProductDetails.quantity')} recaptchaSiteKey={recaptchaSiteKey} reviewFormAction={submitReview} @@ -610,19 +660,26 @@ export default async function Product({ params, searchParams }: Props) { title={t('RelatedProducts.title')} /> - {showRating && ( -
- -
- )} + + {(showRating) => + showRating ? ( +
+ + {(productId) => ( + + )} + +
+ ) : null + } +
- + + {(productId) => ( + + )} + ); } diff --git a/core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts b/core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts index ed90829f9d..9432f0f223 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts +++ b/core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts @@ -10,7 +10,7 @@ import { DynamicFormActionArgs } from '@/vibes/soul/form/dynamic-form'; import { Field, schema } from '@/vibes/soul/form/dynamic-form/schema'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha'; const inputSchema = z.object({ diff --git a/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts b/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts index c1952ca187..3ca5db5137 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts +++ b/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -32,20 +32,20 @@ const ContactPageQuery = graphql( type Variables = VariablesOf; -const getCachedWebpageData = unstable_cache( - async (locale: string, variables: Variables) => { - const { data } = await client.fetch({ - document: ContactPageQuery, - variables, - locale, - fetchOptions: { cache: 'no-store' }, - }); - - return data; - }, - ['get-contact-webpage-data'], - { revalidate }, -); +async function getCachedWebpageData(locale: string, variables: Variables) { + 'use cache'; + + cacheLife({ revalidate }); + + const { data } = await client.fetch({ + document: ContactPageQuery, + variables, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data; +} export const getWebpageData = cache(async (locale: string, variables: Variables) => { return getCachedWebpageData(locale, variables); diff --git a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx index c31d8c9c3a..98da566b38 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { cache } from 'react'; import { DynamicForm } from '@/vibes/soul/form/dynamic-form'; @@ -173,8 +173,6 @@ export default async function ContactPage({ params, searchParams }: Props) { const { id, locale } = await params; const { success } = await searchParams; - setRequestLocale(locale); - const t = await getTranslations('WebPages.ContactUs.Form'); if (success === 'true') { diff --git a/core/app/[locale]/(default)/webpages/[id]/layout.tsx b/core/app/[locale]/(default)/webpages/[id]/layout.tsx index a2633afd3e..7a995386fe 100644 --- a/core/app/[locale]/(default)/webpages/[id]/layout.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/layout.tsx @@ -1,8 +1,8 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; -import { unstable_cache } from 'next/cache'; -import { setRequestLocale } from 'next-intl/server'; -import { cache } from 'react'; +import { cacheLife } from 'next/cache'; +import { cache, Suspense } from 'react'; +import { Streamable } from '@/vibes/soul/lib/streamable'; import { SidebarMenu } from '@/vibes/soul/sections/sidebar-menu'; import { StickySidebarLayout } from '@/vibes/soul/sections/sticky-sidebar-layout'; import { client } from '~/client'; @@ -46,56 +46,62 @@ interface PageLink { href: string; } -const getCachedWebPageChildren = unstable_cache( - async (locale: string, id: string): Promise => { - const { data } = await client.fetch({ - document: WebPageChildrenQuery, - variables: { id: decodeURIComponent(id) }, - locale, - fetchOptions: { cache: 'no-store' }, - }); - - if (!data.node) { - return []; - } +async function getCachedWebPageChildren(locale: string, id: string): Promise { + 'use cache'; - if (!('children' in data.node)) { - return []; - } + cacheLife({ revalidate }); - const { children } = data.node; + const { data } = await client.fetch({ + document: WebPageChildrenQuery, + variables: { id: decodeURIComponent(id) }, + locale, + fetchOptions: { cache: 'no-store' }, + }); - return removeEdgesAndNodes(children).reduce((acc: PageLink[], child) => { - if ('path' in child) { - return [...acc, { label: child.name, href: child.path }]; - } + if (!data.node) { + return []; + } - if ('link' in child) { - return [...acc, { label: child.name, href: child.link }]; - } + if (!('children' in data.node)) { + return []; + } + + const { children } = data.node; - return acc; - }, []); - }, - ['get-webpage-children'], - { revalidate }, -); + return removeEdgesAndNodes(children).reduce((acc: PageLink[], child) => { + if ('path' in child) { + return [...acc, { label: child.name, href: child.path }]; + } + + if ('link' in child) { + return [...acc, { label: child.name, href: child.link }]; + } + + return acc; + }, []); +} const getWebPageChildren = cache(async (locale: string, id: string): Promise => { return getCachedWebPageChildren(locale, id); }); -export default async function WebPageLayout({ params, children }: Props) { +async function WebPageLayoutContent({ params, children }: Props) { const { locale, id } = await params; - setRequestLocale(locale); - return ( } + sidebar={ getWebPageChildren(locale, id))} />} sidebarSize="small" > {children} ); } + +export default function WebPageLayout(props: Props) { + return ( + + + + ); +} diff --git a/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts b/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts index 1a6bc521f1..9fe9e8b350 100644 --- a/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts +++ b/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -30,20 +30,20 @@ const NormalPageQuery = graphql( type Variables = VariablesOf; -const getCachedWebpageData = unstable_cache( - async (locale: string, variables: Variables) => { - const { data } = await client.fetch({ - document: NormalPageQuery, - variables, - locale, - fetchOptions: { cache: 'no-store' }, - }); - - return data; - }, - ['get-normal-webpage-data'], - { revalidate }, -); +async function getCachedWebpageData(locale: string, variables: Variables) { + 'use cache'; + + cacheLife({ revalidate }); + + const { data } = await client.fetch({ + document: NormalPageQuery, + variables, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data; +} export const getWebpageData = cache(async (locale: string, variables: Variables) => { return getCachedWebpageData(locale, variables); diff --git a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx index 3b55299d9e..c16bd37a68 100644 --- a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { cache } from 'react'; import { Streamable } from '@/vibes/soul/lib/streamable'; @@ -76,8 +76,6 @@ export async function generateMetadata({ params }: Props): Promise { export default async function WebPage({ params }: Props) { const { locale, id } = await params; - setRequestLocale(locale); - return ( getWebPageBreadcrumbs(locale, id))} diff --git a/core/app/[locale]/(default)/wishlist/[token]/page-data.ts b/core/app/[locale]/(default)/wishlist/[token]/page-data.ts index d03424ea59..56494de567 100644 --- a/core/app/[locale]/(default)/wishlist/[token]/page-data.ts +++ b/core/app/[locale]/(default)/wishlist/[token]/page-data.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife, cacheTag } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -51,24 +51,30 @@ interface Pagination { after?: string | null; } -const getCachedPublicWishlist = unstable_cache( - async (locale: string, token: string, pagination: Pagination, currencyCode?: CurrencyCode) => { - const { before, after, limit = 9 } = pagination; - const paginationArgs = before ? { last: limit, before } : { first: limit, after }; - const response = await client.fetch({ - document: PublicWishlistQuery, - variables: { ...paginationArgs, currencyCode, token }, - locale, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedPublicWishlist( + locale: string, + token: string, + pagination: Pagination, + currencyCode?: CurrencyCode, +) { + 'use cache'; - const wishlist = response.data.site.publicWishlist; + cacheLife({ revalidate }); + cacheTag(TAGS.customer); - return wishlist; - }, - ['get-public-wishlist'], - { revalidate, tags: [TAGS.customer] }, -); + const { before, after, limit = 9 } = pagination; + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; + const response = await client.fetch({ + document: PublicWishlistQuery, + variables: { ...paginationArgs, currencyCode, token }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + const wishlist = response.data.site.publicWishlist; + + return wishlist; +} export const getPublicWishlist = cache( async (locale: string, token: string, pagination: Pagination, currencyCode?: CurrencyCode) => { diff --git a/core/app/[locale]/(default)/wishlist/[token]/page.tsx b/core/app/[locale]/(default)/wishlist/[token]/page.tsx index 180e77e33e..42bca643da 100644 --- a/core/app/[locale]/(default)/wishlist/[token]/page.tsx +++ b/core/app/[locale]/(default)/wishlist/[token]/page.tsx @@ -1,7 +1,7 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getLocale, getTranslations } from 'next-intl/server'; import { SearchParams } from 'nuqs'; import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'; @@ -75,7 +75,7 @@ export async function generateMetadata({ params, searchParams }: Props): Promise // Even though we don't need paginated data during metadata generation, we should still pass the parameters // to make sure we aren't bypassing an existing cache just for the metadata generation. const searchParamsParsed = searchParamsCache.parse(await searchParams); - const t = await getTranslations({ locale, namespace: 'PublicWishlist' }); + const t = await getTranslations('PublicWishlist'); const currencyCode = await getPreferredCurrencyCode(); const wishlist = await getPublicWishlist(locale, token, searchParamsParsed, currencyCode); @@ -130,11 +130,8 @@ async function getBreadcrumbs( } export default async function PublicWishlist({ params, searchParams }: Props) { - const { locale, token } = await params; - - setRequestLocale(locale); + const locale = await getLocale(); - const currencyCode = await getPreferredCurrencyCode(); const t = await getTranslations('Wishlist'); const pwt = await getTranslations('PublicWishlist'); const pt = await getTranslations('Product.ProductDetails'); @@ -175,13 +172,21 @@ export default async function PublicWishlist({ params, searchParams }: Props) { return ( getAnalyticsData(locale, token, searchParams, currencyCode))} + data={Streamable.from(async () => { + const { token } = await params; + const currencyCode = await getPreferredCurrencyCode(); + + return getAnalyticsData(locale, token, searchParams, currencyCode); + })} > - getBreadcrumbs(locale, token, searchParams, currencyCode), - )} + breadcrumbs={Streamable.from(async () => { + const { token } = await params; + const currencyCode = await getPreferredCurrencyCode(); + + return getBreadcrumbs(locale, token, searchParams, currencyCode); + })} /> - getPaginationInfo(locale, token, searchParams, currencyCode), - )} - wishlist={Streamable.from(() => - getWishlist(locale, token, t, pt, searchParams, currencyCode), - )} + paginationInfo={Streamable.from(async () => { + const { token } = await params; + const currencyCode = await getPreferredCurrencyCode(); + + return getPaginationInfo(locale, token, searchParams, currencyCode); + })} + wishlist={Streamable.from(async () => { + const { token } = await params; + const currencyCode = await getPreferredCurrencyCode(); + + return getWishlist(locale, token, t, pt, searchParams, currencyCode); + })} /> diff --git a/core/app/[locale]/layout.tsx b/core/app/[locale]/layout.tsx index 1be02ded1c..34c084b464 100644 --- a/core/app/[locale]/layout.tsx +++ b/core/app/[locale]/layout.tsx @@ -1,13 +1,17 @@ import { Analytics } from '@vercel/analytics/react'; import { SpeedInsights } from '@vercel/speed-insights/next'; +import { clsx } from 'clsx'; import type { Metadata } from 'next'; -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { notFound } from 'next/navigation'; import { NextIntlClientProvider } from 'next-intl'; -import { setRequestLocale } from 'next-intl/server'; +import { getLocale } from 'next-intl/server'; import { NuqsAdapter } from 'nuqs/adapters/next/app'; -import { cache, PropsWithChildren } from 'react'; +import { cache, PropsWithChildren, Suspense } from 'react'; +import '~/globals.css'; + +import { fonts } from '~/app/fonts'; import { CookieNotifications } from '~/app/notifications'; import { Providers } from '~/app/providers'; import { client } from '~/client'; @@ -54,16 +58,16 @@ const RootLayoutMetadataQuery = graphql( [WebAnalyticsFragment, ScriptsFragment], ); -const getCachedRootLayoutMetadata = unstable_cache( - async () => { - return await client.fetch({ - document: RootLayoutMetadataQuery, - fetchOptions: { cache: 'no-store' }, - }); - }, - ['root-layout-metadata'], - { revalidate }, -); +async function getCachedRootLayoutMetadata() { + 'use cache'; + + cacheLife({ revalidate }); + + return await client.fetch({ + document: RootLayoutMetadataQuery, + fetchOptions: { cache: 'no-store' }, + }); +} const fetchRootLayoutMetadata = cache(async () => getCachedRootLayoutMetadata()); @@ -123,57 +127,65 @@ interface Props extends PropsWithChildren { params: Promise<{ locale: string }>; } +async function ToastNotifications() { + const toastNotificationCookieData = await getToastNotification(); + + if (!toastNotificationCookieData) { + return null; + } + + return ; +} + export default async function RootLayout({ params, children }: Props) { const { locale } = await params; + const localeFromIntl = await getLocale(); const rootData = await fetchRootLayoutMetadata(); - const toastNotificationCookieData = await getToastNotification(); if (!routing.locales.includes(locale)) { notFound(); } - // need to call this method everywhere where static rendering is enabled - // https://next-intl-docs.vercel.app/docs/getting-started/app-router#add-setRequestLocale-to-all-layouts-and-pages - setRequestLocale(locale); - const scripts = scriptsTransformer(rootData.data.site.content.scripts); const isCookieConsentEnabled = rootData.data.site.settings?.privacy?.cookieConsentEnabled ?? false; const privacyPolicyUrl = rootData.data.site.settings?.privacy?.privacyPolicyUrl; return ( - <> - - - - - - {toastNotificationCookieData && ( - - )} - {children} - - - - - - - - + f.variable))} lang={localeFromIntl}> + + + + + + + + + + {children} + + + + + + + + + + + ); } export function generateStaticParams() { return routing.locales.map((locale) => ({ locale })); } - -export const fetchCache = 'default-cache'; diff --git a/core/app/[locale]/maintenance/page.tsx b/core/app/[locale]/maintenance/page.tsx index d5bc75e4f5..340b2374ab 100644 --- a/core/app/[locale]/maintenance/page.tsx +++ b/core/app/[locale]/maintenance/page.tsx @@ -1,10 +1,12 @@ import { Metadata } from 'next'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { cacheLife } from 'next/cache'; +import { getTranslations } from 'next-intl/server'; import { ReactNode } from 'react'; import { Maintenance as MaintenanceSection } from '@/vibes/soul/sections/maintenance'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; import { StoreLogoFragment } from '~/components/store-logo/fragment'; import { logoTransformer } from '~/data-transformers/logo-transformer'; @@ -26,14 +28,21 @@ const MaintenancePageQuery = graphql( [StoreLogoFragment], ); -interface Props { - params: Promise<{ locale: string }>; -} +async function getCachedMaintenancePageData() { + 'use cache'; -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; + cacheLife({ revalidate }); - const t = await getTranslations({ locale, namespace: 'Maintenance' }); + const { data } = await client.fetch({ + document: MaintenancePageQuery, + fetchOptions: { cache: 'no-store' }, + }); + + return data; +} + +export async function generateMetadata(): Promise { + const t = await getTranslations('Maintenance'); return { title: t('title'), @@ -46,16 +55,10 @@ const Container = ({ children }: { children: ReactNode }) => ( ); -export default async function Maintenance({ params }: Props) { - const { locale } = await params; - - setRequestLocale(locale); - +export default async function Maintenance() { const t = await getTranslations('Maintenance'); - const { data } = await client.fetch({ - document: MaintenancePageQuery, - }); + const data = await getCachedMaintenancePageData(); const storeSettings = data.site.settings; diff --git a/core/app/admin/route.ts b/core/app/admin/route.ts index f573d84ff7..53e982699e 100644 --- a/core/app/admin/route.ts +++ b/core/app/admin/route.ts @@ -1,5 +1,5 @@ import { defaultLocale } from '~/i18n/locales'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; const canonicalDomain: string = process.env.BIGCOMMERCE_GRAPHQL_API_DOMAIN ?? 'mybigcommerce.com'; const BIGCOMMERCE_STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH; diff --git a/core/app/favicon.ico/route.ts b/core/app/favicon.ico/route.ts index 49df80d88d..37c70d820b 100644 --- a/core/app/favicon.ico/route.ts +++ b/core/app/favicon.ico/route.ts @@ -24,7 +24,9 @@ const GetFaviconQuery = graphql(` } `); -export const GET = async () => { +async function getFaviconData() { + 'use cache'; + const { data } = await client.fetch({ document: GetFaviconQuery, channelId: getChannelIdFromLocale(defaultLocale), @@ -33,19 +35,24 @@ export const GET = async () => { const faviconUrl = data.site.settings?.faviconUrl; if (!faviconUrl) { - return new Response(null, { - status: 404, - }); + return null; } - // fetch the favicon URL and return the data directly (will be statically cached at build time) - const faviconData = await fetch(faviconUrl).then((res) => res.arrayBuffer()); + const faviconBuffer = await fetch(faviconUrl).then((res) => + res.arrayBuffer().then((buf) => Buffer.from(buf).toString('base64')), + ); + + return faviconBuffer; +} + +export const GET = async () => { + const faviconData = await getFaviconData(); + + if (!faviconData) { + return new Response(null, { status: 404 }); + } - return new Response(faviconData, { - headers: { - 'Content-Type': 'image/x-icon', - }, + return new Response(Buffer.from(faviconData, 'base64'), { + headers: { 'Content-Type': 'image/x-icon' }, }); }; - -export const dynamic = 'force-static'; diff --git a/core/app/layout.tsx b/core/app/layout.tsx deleted file mode 100644 index ea754b0fb4..0000000000 --- a/core/app/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { clsx } from 'clsx'; -import { PropsWithChildren } from 'react'; - -import '../globals.css'; - -import { fonts } from '~/app/fonts'; - -export default function RootLayout({ children }: PropsWithChildren) { - return ( - f.variable))} lang="en"> - {children} - - ); -} diff --git a/core/app/robots.txt/route.ts b/core/app/robots.txt/route.ts index 24b6b1d3c3..1abbb7482e 100644 --- a/core/app/robots.txt/route.ts +++ b/core/app/robots.txt/route.ts @@ -40,20 +40,23 @@ const baseUrl = parseUrl( process.env.NEXTAUTH_URL || process.env.VERCEL_PROJECT_PRODUCTION_URL || '', ); -export const GET = async () => { +async function getRobotsTxtContent() { + 'use cache'; + const { data } = await client.fetch({ document: RobotsTxtQuery, channelId: getChannelIdFromLocale(defaultLocale), - fetchOptions: { cache: 'no-store' }, // disable caching to get the latest robots.txt at build time + fetchOptions: { cache: 'no-store' }, }); - const robotsTxt = `${data.site.settings?.robotsTxt ?? ''}\nSitemap: ${baseUrl.origin}/sitemap.xml\n`; + return data.site.settings?.robotsTxt ?? ''; +} + +export const GET = async () => { + const robotsTxtContent = await getRobotsTxtContent(); + const robotsTxt = `${robotsTxtContent}\nSitemap: ${baseUrl.origin}/sitemap.xml\n`; return new Response(robotsTxt, { - headers: { - 'Content-Type': 'text/plain; charset=UTF-8', - }, + headers: { 'Content-Type': 'text/plain; charset=UTF-8' }, }); }; - -export const dynamic = 'force-static'; diff --git a/core/app/xmlsitemap.php/route.ts b/core/app/xmlsitemap.php/route.ts index 03c3cc2c16..231acb3139 100644 --- a/core/app/xmlsitemap.php/route.ts +++ b/core/app/xmlsitemap.php/route.ts @@ -1,6 +1,6 @@ /* eslint-disable check-file/folder-naming-convention */ import { defaultLocale } from '~/i18n/locales'; -import { permanentRedirect } from '~/i18n/routing'; +import { permanentRedirect } from '~/i18n/navigation'; /* * This route is used to redirect the legacy Stencil sitemap that lives on /xmlsitemap.php diff --git a/core/auth/handle-login-cart.ts b/core/auth/handle-login-cart.ts new file mode 100644 index 0000000000..666eab2fd2 --- /dev/null +++ b/core/auth/handle-login-cart.ts @@ -0,0 +1,20 @@ +import { getTranslations } from 'next-intl/server'; + +import { setCartId } from '~/lib/cart'; +import { serverToast } from '~/lib/server-toast'; + +export async function handleLoginCart(guestCartId?: string, loginResultCartId?: string) { + const t = await getTranslations('Cart'); + + if (guestCartId === undefined && loginResultCartId !== undefined) { + await serverToast.info(t('cartRestored'), { position: 'top-center' }); + } + + if (loginResultCartId && guestCartId && loginResultCartId !== guestCartId) { + await serverToast.info(t('cartCombined'), { position: 'top-center' }); + } + + if (loginResultCartId) { + await setCartId(loginResultCartId); + } +} diff --git a/core/auth/index.ts b/core/auth/index.ts index bf902fa2a8..ad24894f18 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -2,14 +2,12 @@ import { decodeJwt } from 'jose'; import NextAuth, { type NextAuthConfig, User } from 'next-auth'; import 'next-auth/jwt'; import CredentialsProvider from 'next-auth/providers/credentials'; -import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; import { anonymousSignIn, clearAnonymousSession } from '~/auth/anonymous-session'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { clearCartId, setCartId } from '~/lib/cart'; -import { serverToast } from '~/lib/server-toast'; const LoginMutation = graphql(` mutation LoginMutation($email: String!, $password: String!, $cartEntityId: String) { @@ -86,21 +84,18 @@ const SessionUpdate = z.object({ }), }); -async function handleLoginCart(guestCartId?: string, loginResultCartId?: string) { - const t = await getTranslations('Cart'); +// handleLoginCart is in a separate file to avoid pulling next-intl/server +// (and next/root-params) into the middleware bundle. +// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func, @typescript-eslint/consistent-type-assertions +const importHandleLoginCart = new Function('return import("./handle-login-cart")') as () => Promise< + typeof import('./handle-login-cart') +>; - if (guestCartId === undefined && loginResultCartId !== undefined) { - await serverToast.info(t('cartRestored'), { position: 'top-center' }); - } - - if (loginResultCartId && guestCartId && loginResultCartId !== guestCartId) { - await serverToast.info(t('cartCombined'), { position: 'top-center' }); - } +const handleLoginCart = async (guestCartId?: string, loginResultCartId?: string) => { + const { handleLoginCart: fn } = await importHandleLoginCart(); - if (loginResultCartId) { - await setCartId(loginResultCartId); - } -} + return fn(guestCartId, loginResultCartId); +}; async function loginWithPassword(credentials: unknown): Promise { const { email, password, cartId } = PasswordCredentials.parse(credentials); diff --git a/core/client/correlation-id.ts b/core/client/correlation-id.ts index f3ce208e1a..d75eee25cb 100644 --- a/core/client/correlation-id.ts +++ b/core/client/correlation-id.ts @@ -1,8 +1,14 @@ -import { cache } from 'react'; +let counter = 0; /** - * Returns a stable correlation ID for the current request. - * React.cache ensures the same UUID is returned for all fetches within a - * single page render, while being unique across renders/requests. + * Returns a correlation ID for tracing requests. + * Uses a simple counter to avoid crypto.randomUUID() and Date.now() which + * trigger Next.js cacheComponents prerender errors for accessing dynamic + * values before uncached data. + * + * @returns {string} A unique correlation ID string. */ -export const getCorrelationId = cache((): string => crypto.randomUUID()); +export function getCorrelationId(): string { + // eslint-disable-next-line no-plusplus + return `req-${(counter++).toString(36)}`; +} diff --git a/core/client/index.ts b/core/client/index.ts index 7dc470cf48..32f2fa02c9 100644 --- a/core/client/index.ts +++ b/core/client/index.ts @@ -16,25 +16,15 @@ export const client = createClient({ getChannelId: (defaultChannelId: string, locale?: string) => { return getChannelIdFromLocale(locale) ?? defaultChannelId; }, + // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars beforeRequest: async (fetchOptions) => { // We can't serialize a `Headers` object within this method so we have to opt into using a plain object const requestHeaders: Record = {}; - if (fetchOptions?.cache && ['no-store', 'no-cache'].includes(fetchOptions.cache)) { - try { - // headers() is a dynamic API unavailable inside unstable_cache; skip IP forwarding in that context - 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; - } - } catch { - // Not in a request context (e.g. inside unstable_cache); IP forwarding not available - } - } + // Note: IP forwarding via headers() was removed because headers() cannot be called inside + // 'use cache' contexts (throws an uncatchable error in Next.js 16 with cacheComponents). + // Since cached responses are shared across users, IP forwarding is not meaningful there. + // For authenticated (non-cached) requests, IP forwarding should be handled at the middleware level. requestHeaders['X-Correlation-ID'] = getCorrelationId(); diff --git a/core/components/footer/index.tsx b/core/components/footer/index.tsx index 5316ee1dbb..ce433e88ce 100644 --- a/core/components/footer/index.tsx +++ b/core/components/footer/index.tsx @@ -6,9 +6,9 @@ import { SiX, SiYoutube, } from '@icons-pack/react-simple-icons'; -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { getTranslations } from 'next-intl/server'; -import { cache, JSX } from 'react'; +import { cache, JSX, Suspense } from 'react'; import { Streamable } from '@/vibes/soul/lib/streamable'; import { Footer as FooterSection } from '@/vibes/soul/sections/footer'; @@ -29,6 +29,8 @@ import { MastercardIcon } from './payment-icons/mastercard'; import { PayPalIcon } from './payment-icons/paypal'; import { VisaIcon } from './payment-icons/visa'; +const currentYear = new Date().getFullYear(); + const paymentIcons = [ , , @@ -47,22 +49,22 @@ const socialIcons: Record = { YouTube: { icon: }, }; -const getCachedFooterSections = unstable_cache( - async (currencyCode?: CurrencyCode) => { - const { data: response } = await client.fetch({ - document: GetLinksAndSectionsQuery, - 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: { cache: 'no-store' }, - }); - - return readFragment(FooterSectionsFragment, response).site; - }, - ['get-footer-sections'], - { revalidate }, -); +async function getCachedFooterSections(currencyCode?: CurrencyCode) { + 'use cache'; + + cacheLife({ revalidate }); + + const { data: response } = await client.fetch({ + document: GetLinksAndSectionsQuery, + 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: { cache: 'no-store' }, + }); + + return readFragment(FooterSectionsFragment, response).site; +} const getFooterSections = cache( async (customerAccessToken?: string, currencyCode?: CurrencyCode) => { @@ -82,18 +84,18 @@ const getFooterSections = cache( }, ); -const getCachedFooterData = unstable_cache( - async () => { - const { data: response } = await client.fetch({ - document: LayoutQuery, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedFooterData() { + 'use cache'; - return readFragment(FooterFragment, response).site; - }, - ['get-footer-data'], - { revalidate }, -); + cacheLife({ revalidate }); + + const { data: response } = await client.fetch({ + document: LayoutQuery, + fetchOptions: { cache: 'no-store' }, + }); + + return readFragment(FooterFragment, response).site; +} const getFooterData = cache(async () => getCachedFooterData()); @@ -103,7 +105,7 @@ export const Footer = async () => { const logo = data.settings ? logoTransformer(data.settings) : ''; - const copyright = `© ${new Date().getFullYear()} ${data.settings?.storeName} – Powered by BigCommerce`; + const copyright = `© ${currentYear} ${data.settings?.storeName} – Powered by BigCommerce`; const contactInformation = data.settings?.contact ? { @@ -160,16 +162,18 @@ export const Footer = async () => { }); return ( - + + + ); }; diff --git a/core/components/force-refresh/index.tsx b/core/components/force-refresh/index.tsx index d6a032546e..163eb57b1c 100644 --- a/core/components/force-refresh/index.tsx +++ b/core/components/force-refresh/index.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; -import { useRouter } from '~/i18n/routing'; +import { useRouter } from '~/i18n/navigation'; import { FORCE_REFRESH_COOKIE, getCookieValue, setCookie } from '~/lib/client-cookies'; export const ForceRefresh = () => { diff --git a/core/components/header/index.tsx b/core/components/header/index.tsx index 1f0839e6be..d61c0950db 100644 --- a/core/components/header/index.tsx +++ b/core/components/header/index.tsx @@ -1,6 +1,6 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { getLocale, getTranslations } from 'next-intl/server'; -import { cache } from 'react'; +import { cache, Suspense } from 'react'; import { Streamable } from '@/vibes/soul/lib/streamable'; import { HeaderSection } from '@/vibes/soul/sections/header-section'; @@ -48,22 +48,22 @@ const getCartCount = cache(async (cartId: string, customerAccessToken?: string) return response.data.site.cart?.lineItems.totalQuantity ?? null; }); -const getCachedHeaderLinks = unstable_cache( - async (currencyCode?: CurrencyCode) => { - const { data: response } = await client.fetch({ - document: GetLinksAndSectionsQuery, - 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: { cache: 'no-store' }, - }); +async function getCachedHeaderLinks(currencyCode?: CurrencyCode) { + 'use cache'; - return readFragment(HeaderLinksFragment, response).site; - }, - ['get-header-links'], - { revalidate }, -); + cacheLife({ revalidate }); + + const { data: response } = await client.fetch({ + document: GetLinksAndSectionsQuery, + 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: { cache: 'no-store' }, + }); + + return readFragment(HeaderLinksFragment, response).site; +} const getHeaderLinks = cache(async (customerAccessToken?: string, currencyCode?: CurrencyCode) => { if (customerAccessToken) { @@ -81,24 +81,24 @@ const getHeaderLinks = cache(async (customerAccessToken?: string, currencyCode?: return getCachedHeaderLinks(currencyCode); }); -const getCachedHeaderData = unstable_cache( - async () => { - const { data: response } = await client.fetch({ - document: LayoutQuery, - fetchOptions: { cache: 'no-store' }, - }); +async function getCachedHeaderData() { + 'use cache'; - return readFragment(HeaderFragment, response).site; - }, - ['get-header-data'], - { revalidate }, -); + cacheLife({ revalidate }); + + const { data: response } = await client.fetch({ + document: LayoutQuery, + fetchOptions: { cache: 'no-store' }, + }); + + return readFragment(HeaderFragment, response).site; +} const getHeaderData = cache(async () => getCachedHeaderData()); export const Header = async () => { - const t = await getTranslations('Components.Header'); const locale = await getLocale(); + const t = await getTranslations('Components.Header'); const data = await getHeaderData(); @@ -180,33 +180,35 @@ export const Header = async () => { }); return ( - + + + ); }; diff --git a/core/components/link/index.tsx b/core/components/link/index.tsx index 714337767f..cf1db4d4a3 100644 --- a/core/components/link/index.tsx +++ b/core/components/link/index.tsx @@ -1,8 +1,12 @@ 'use client'; -import { ComponentPropsWithRef, ComponentRef, forwardRef, useReducer } from 'react'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import NextLink from 'next/link'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { useRouter } from 'next/navigation'; +import { ComponentPropsWithRef, ComponentRef, forwardRef, Suspense, useReducer } from 'react'; -import { Link as NavLink, useRouter } from '../../i18n/routing'; +import { Link as NavLink } from '../../i18n/navigation'; type NextLinkProps = Omit, 'prefetch'>; @@ -13,17 +17,7 @@ interface PrefetchOptions { type Props = NextLinkProps & PrefetchOptions; -/** - * This custom `Link` is based on Next-Intl's `Link` component - * https://next-intl-docs.vercel.app/docs/routing/navigation#link - * which adds automatically prefixes for the href with the current locale as necessary - * and extends with additional prefetching controls, making navigation - * prefetching more adaptable to different use cases. By offering `prefetch` and `prefetchKind` - * props, it grants explicit management over when and how prefetching occurs, defaulting to 'hover' for - * prefetch behavior and 'auto' for prefetch kind. This approach provides a balance between optimizing - * page load performance and resource usage. https://nextjs.org/docs/app/api-reference/components/link#prefetch - */ -export const Link = forwardRef, Props>( +const InnerLink = forwardRef, Props>( ({ href, prefetch = 'hover', prefetchKind = 'auto', children, className, ...rest }, ref) => { const router = useRouter(); const [prefetched, setPrefetched] = useReducer(() => true, false); @@ -65,6 +59,34 @@ export const Link = forwardRef, Props>( }, ); +InnerLink.displayName = 'InnerLink'; + +/** + * This custom `Link` wraps Next-Intl's `Link` component in a Suspense boundary + * to support PPR (Partial Prerendering) with cacheComponents. During prerender, + * next-intl's Link accesses locale context which is dynamic. The Suspense boundary + * provides a static fallback using next/link directly. + */ +export const Link = forwardRef, Props>(({ children, ...props }, ref) => { + const hrefString = typeof props.href === 'string' ? props.href : (props.href.href ?? '#'); + + return ( + + {children} + + } + > + + {children} + + + ); +}); + +Link.displayName = 'Link'; + function computePrefetchProp({ prefetch, prefetchKind, diff --git a/core/i18n/navigation.ts b/core/i18n/navigation.ts new file mode 100644 index 0000000000..bc9e8ea8c1 --- /dev/null +++ b/core/i18n/navigation.ts @@ -0,0 +1,10 @@ +import { createNavigation } from 'next-intl/navigation'; + +import { routing } from './routing'; + +// Lightweight wrappers around Next.js' navigation APIs +// that will consider the routing configuration +// Redirect will append locale prefix even when in default locale +// More info: https://github.com/amannn/next-intl/issues/1335 +export const { Link, redirect, usePathname, useRouter, permanentRedirect } = + createNavigation(routing); diff --git a/core/i18n/request.ts b/core/i18n/request.ts index c5cc74a36f..d9b1997a36 100644 --- a/core/i18n/request.ts +++ b/core/i18n/request.ts @@ -1,5 +1,7 @@ import deepmerge from 'deepmerge'; import { notFound } from 'next/navigation'; +import * as rootParams from 'next/root-params'; +import { hasLocale } from 'next-intl'; import { getRequestConfig } from 'next-intl/server'; import { locales } from './locales'; @@ -7,11 +9,21 @@ import { locales } from './locales'; // The language to fall back to if the requested message string is not available. const fallbackLocale = 'en'; -export default getRequestConfig(async ({ requestLocale }) => { - const locale = await requestLocale; +export default getRequestConfig(async ({ locale: inputLocale }) => { + // When locale is not provided, resolve from root-params. + // rootParams.locale() reads from the URL path, not headers(), + // so it's safe inside 'use cache' and cacheComponents. + let locale = inputLocale; - if (!locale || !locales.includes(locale)) { - notFound(); + if (!locale) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + const paramValue = await rootParams.locale(); + + if (hasLocale(locales, paramValue)) { + locale = paramValue; + } else { + notFound(); + } } if (locale === fallbackLocale) { diff --git a/core/i18n/routing.ts b/core/i18n/routing.ts index 60517b1884..e3087bb365 100644 --- a/core/i18n/routing.ts +++ b/core/i18n/routing.ts @@ -1,4 +1,3 @@ -import { createNavigation } from 'next-intl/navigation'; import { defineRouting } from 'next-intl/routing'; import { defaultLocale, locales } from './locales'; @@ -18,10 +17,3 @@ export const routing = defineRouting({ defaultLocale, localePrefix, }); - -// Lightweight wrappers around Next.js' navigation APIs -// that will consider the routing configuration -// Redirect will append locale prefix even when in default locale -// More info: https://github.com/amannn/next-intl/issues/1335 -export const { Link, redirect, usePathname, useRouter, permanentRedirect } = - createNavigation(routing); diff --git a/core/lib/recaptcha.ts b/core/lib/recaptcha.ts index 77d5aa1518..e0836e0187 100644 --- a/core/lib/recaptcha.ts +++ b/core/lib/recaptcha.ts @@ -1,5 +1,6 @@ import 'server-only'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -24,10 +25,14 @@ export const ReCaptchaSettingsQuery = graphql(` } `); -export const getReCaptchaSettings = cache(async (): Promise => { +async function getCachedReCaptchaSettings(): Promise { + 'use cache'; + + cacheLife({ revalidate }); + const { data } = await client.fetch({ document: ReCaptchaSettingsQuery, - fetchOptions: { next: { revalidate } }, + fetchOptions: { cache: 'no-store' }, }); const reCaptcha = data.site.settings?.reCaptcha; @@ -40,7 +45,9 @@ export const getReCaptchaSettings = cache(async (): Promise => { const settings = await getReCaptchaSettings(); diff --git a/core/lib/seo/canonical.ts b/core/lib/seo/canonical.ts index a21e82b2f0..2252e758e1 100644 --- a/core/lib/seo/canonical.ts +++ b/core/lib/seo/canonical.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/cache'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -46,24 +46,24 @@ const VanityUrlQuery = graphql(` } `); -const getCachedVanityUrl = unstable_cache( - async () => { - const { data } = await client.fetch({ - document: VanityUrlQuery, - fetchOptions: { next: { revalidate } }, - }); +async function getCachedVanityUrl() { + 'use cache'; - const vanityUrl = data.site.settings?.url.vanityUrl; + cacheLife({ revalidate }); - if (!vanityUrl) { - throw new Error('Vanity URL not found in site settings'); - } + const { data } = await client.fetch({ + document: VanityUrlQuery, + fetchOptions: { cache: 'no-store' }, + }); + + const vanityUrl = data.site.settings?.url.vanityUrl; - return vanityUrl; - }, - ['get-vanity-url'], - { revalidate }, -); + if (!vanityUrl) { + throw new Error('Vanity URL not found in site settings'); + } + + return vanityUrl; +} const getVanityUrl = cache(getCachedVanityUrl); diff --git a/core/next.config.ts b/core/next.config.ts index 1f39548d60..1e6706e669 100644 --- a/core/next.config.ts +++ b/core/next.config.ts @@ -62,8 +62,10 @@ export default async (): Promise => { let nextConfig: NextConfig = { reactStrictMode: true, + cacheComponents: true, experimental: { optimizePackageImports: ['@icons-pack/react-simple-icons'], + rootParams: true, }, typescript: { ignoreBuildErrors: !!process.env.CI, diff --git a/core/package.json b/core/package.json index d9b6cd3a34..bb0c57e57c 100644 --- a/core/package.json +++ b/core/package.json @@ -49,18 +49,18 @@ "clsx": "^2.1.1", "content-security-policy-builder": "^2.3.0", "deepmerge": "^4.3.1", + "dompurify": "^3.3.1", "embla-carousel": "9.0.0-rc01", "embla-carousel-autoplay": "9.0.0-rc01", "embla-carousel-fade": "9.0.0-rc01", "embla-carousel-react": "9.0.0-rc01", "gql.tada": "^1.8.10", "graphql": "^16.11.0", - "dompurify": "^3.3.1", "jose": "^5.10.0", "lodash.debounce": "^4.0.8", "lru-cache": "^11.1.0", "lucide-react": "^0.474.0", - "next": "~16.1.6", + "next": "~16.2.3", "next-auth": "5.0.0-beta.30", "next-intl": "^4.6.1", "nuqs": "^2.4.3", diff --git a/core/vibes/soul/lib/streamable.tsx b/core/vibes/soul/lib/streamable.tsx index ad98eeadc6..8cf7077f43 100644 --- a/core/vibes/soul/lib/streamable.tsx +++ b/core/vibes/soul/lib/streamable.tsx @@ -1,12 +1,12 @@ import PLazy from 'p-lazy'; import { Suspense, use } from 'react'; -import { v4 as uuid } from 'uuid'; export type Streamable = T | Promise; // eslint-disable-next-line func-names const stableKeys = (function () { const cache = new WeakMap(); + let counter = 0; function getObjectKey(obj: object): string { const key = cache.get(obj); @@ -15,7 +15,8 @@ const stableKeys = (function () { return key; } - const keyValue = uuid(); + // eslint-disable-next-line no-plusplus + const keyValue = `sk_${(counter++).toString(36)}`; cache.set(obj, keyValue); diff --git a/core/vibes/soul/primitives/compare-card/add-to-cart-form.tsx b/core/vibes/soul/primitives/compare-card/add-to-cart-form.tsx index 0b7f993b1b..21e9879979 100644 --- a/core/vibes/soul/primitives/compare-card/add-to-cart-form.tsx +++ b/core/vibes/soul/primitives/compare-card/add-to-cart-form.tsx @@ -7,7 +7,7 @@ import { requestFormReset } from 'react-dom'; import { Button } from '@/vibes/soul/primitives/button'; import { toast } from '@/vibes/soul/primitives/toaster'; import { useEvents } from '~/components/analytics/events'; -import { useRouter } from '~/i18n/routing'; +import { useRouter } from '~/i18n/navigation'; type Action = (state: Awaited, payload: P) => S | Promise; diff --git a/core/vibes/soul/primitives/navigation/index.tsx b/core/vibes/soul/primitives/navigation/index.tsx index f539252327..e5cf36cea6 100644 --- a/core/vibes/soul/primitives/navigation/index.tsx +++ b/core/vibes/soul/primitives/navigation/index.tsx @@ -35,7 +35,7 @@ import { Logo } from '@/vibes/soul/primitives/logo'; import { Price } from '@/vibes/soul/primitives/price-label'; import { ProductCard } from '@/vibes/soul/primitives/product-card'; import { Link } from '~/components/link'; -import { usePathname, useRouter } from '~/i18n/routing'; +import { usePathname, useRouter } from '~/i18n/navigation'; import { useSearch } from '~/lib/search'; interface Link { diff --git a/core/vibes/soul/primitives/wishlist-item-card/wishlist-item-add-to-cart.tsx b/core/vibes/soul/primitives/wishlist-item-card/wishlist-item-add-to-cart.tsx index 969f45ff7a..e3eb2f8fea 100644 --- a/core/vibes/soul/primitives/wishlist-item-card/wishlist-item-add-to-cart.tsx +++ b/core/vibes/soul/primitives/wishlist-item-card/wishlist-item-add-to-cart.tsx @@ -7,7 +7,7 @@ import { requestFormReset, useFormStatus } from 'react-dom'; import { Button } from '@/vibes/soul/primitives/button'; import { toast } from '@/vibes/soul/primitives/toaster'; import { useEvents } from '~/components/analytics/events'; -import { useRouter } from '~/i18n/routing'; +import { useRouter } from '~/i18n/navigation'; import { WishlistItem } from '.'; diff --git a/core/vibes/soul/sections/order-details-section/index.tsx b/core/vibes/soul/sections/order-details-section/index.tsx index 1989b50f79..33d8e15ae0 100644 --- a/core/vibes/soul/sections/order-details-section/index.tsx +++ b/core/vibes/soul/sections/order-details-section/index.tsx @@ -95,7 +95,7 @@ export interface Order { export interface OrderDetailsSectionProps { order: Streamable; - title?: string; + title?: Streamable; orderSummaryLabel?: string; shipmentAddressLabel?: string; shipmentMethodLabel?: string; @@ -141,9 +141,9 @@ export function OrderDetailsSection({
} - value={streamableOrder} + value={Streamable.all([streamableOrder, title ?? ''])} > - {(order) => ( + {([order, resolvedTitle]) => ( <>
{prevHref !== '' && ( @@ -154,7 +154,7 @@ export function OrderDetailsSection({

- {title ?? `Order #${order.id}`} + {resolvedTitle || `Order #${order.id}`}

{order.status}
@@ -538,11 +538,7 @@ function OrderDetailsSectionSkeleton({ return (
- {prevHref != null && prevHref !== '' && ( - - - - )} + {prevHref != null && prevHref !== '' && }
diff --git a/core/vibes/soul/sections/product-detail/product-detail-form.tsx b/core/vibes/soul/sections/product-detail/product-detail-form.tsx index dace2f9dd0..1a527d07b0 100644 --- a/core/vibes/soul/sections/product-detail/product-detail-form.tsx +++ b/core/vibes/soul/sections/product-detail/product-detail-form.tsx @@ -31,7 +31,7 @@ import { Textarea } from '@/vibes/soul/form/textarea'; import { Button } from '@/vibes/soul/primitives/button'; import { toast } from '@/vibes/soul/primitives/toaster'; import { useEvents } from '~/components/analytics/events'; -import { usePathname, useRouter } from '~/i18n/routing'; +import { usePathname, useRouter } from '~/i18n/navigation'; import { revalidateCart } from './actions/revalidate-cart'; import { Field, schema, SchemaRawShape } from './schema'; diff --git a/core/vibes/soul/sections/product-list/index.tsx b/core/vibes/soul/sections/product-list/index.tsx index 9439010a6f..45fd4f2688 100644 --- a/core/vibes/soul/sections/product-list/index.tsx +++ b/core/vibes/soul/sections/product-list/index.tsx @@ -11,7 +11,7 @@ import * as Skeleton from '@/vibes/soul/primitives/skeleton'; interface ProductListProps { products: Streamable; - showRating?: boolean; + showRating?: Streamable; compareProducts?: Streamable; className?: string; colorScheme?: 'light' | 'dark'; @@ -72,6 +72,7 @@ export function ProductList({ streamableCompareProducts, streamableRemoveLabel, streamableMaxCompareLimitMessage, + showRating ?? false, ])} > {([ @@ -81,6 +82,7 @@ export function ProductList({ compareProducts, removeLabel, maxCompareLimitMessage, + resolvedShowRating, ]) => { if (products.length === 0) { return ( @@ -110,7 +112,7 @@ export function ProductList({ key={product.id} product={product} showCompare={showCompare} - showRating={showRating} + showRating={resolvedShowRating} /> ))}
diff --git a/core/vibes/soul/sections/products-list-section/index.tsx b/core/vibes/soul/sections/products-list-section/index.tsx index 6efc97fd94..3e16dc8acf 100644 --- a/core/vibes/soul/sections/products-list-section/index.tsx +++ b/core/vibes/soul/sections/products-list-section/index.tsx @@ -30,7 +30,7 @@ interface Props { filterLabel?: string; filtersPanelTitle?: Streamable; resetFiltersLabel?: Streamable; - showRating?: boolean; + showRating?: Streamable; rangeFilterApplyLabel?: Streamable; sortLabel?: Streamable; sortPlaceholder?: Streamable; diff --git a/core/vibes/soul/sections/sidebar-menu/sidebar-menu-link.tsx b/core/vibes/soul/sections/sidebar-menu/sidebar-menu-link.tsx index 0c5e544099..771f576bca 100644 --- a/core/vibes/soul/sections/sidebar-menu/sidebar-menu-link.tsx +++ b/core/vibes/soul/sections/sidebar-menu/sidebar-menu-link.tsx @@ -4,7 +4,7 @@ import { clsx } from 'clsx'; import React from 'react'; import { Link } from '~/components/link'; -import { usePathname } from '~/i18n/routing'; +import { usePathname } from '~/i18n/navigation'; export function SidebarMenuLink({ className, diff --git a/core/vibes/soul/sections/sidebar-menu/sidebar-menu-select.tsx b/core/vibes/soul/sections/sidebar-menu/sidebar-menu-select.tsx index 3d0407c00a..f862857fc5 100644 --- a/core/vibes/soul/sections/sidebar-menu/sidebar-menu-select.tsx +++ b/core/vibes/soul/sections/sidebar-menu/sidebar-menu-select.tsx @@ -1,7 +1,7 @@ 'use client'; import { Select } from '@/vibes/soul/form/select'; -import { usePathname, useRouter } from '~/i18n/routing'; +import { usePathname, useRouter } from '~/i18n/navigation'; export function SidebarMenuSelect({ links }: { links: Array<{ href: string; label: string }> }) { const pathname = usePathname(); diff --git a/core/vibes/soul/sections/wishlist-details/index.tsx b/core/vibes/soul/sections/wishlist-details/index.tsx index 5301a30895..a9dbd48d21 100644 --- a/core/vibes/soul/sections/wishlist-details/index.tsx +++ b/core/vibes/soul/sections/wishlist-details/index.tsx @@ -221,15 +221,9 @@ function WishlistDetailSkeleton({
- {prevHref != null && - prevHref !== '' && - (prevHref ? ( - - - - ) : ( - - ))} + {prevHref != null && prevHref !== '' && ( + + )}
= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.1.6': - resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} + '@next/swc-darwin-x64@16.2.3': + resolution: {integrity: sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.1.6': - resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} + '@next/swc-linux-arm64-gnu@16.2.3': + resolution: {integrity: sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.1.6': - resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} + '@next/swc-linux-arm64-musl@16.2.3': + resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.1.6': - resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} + '@next/swc-linux-x64-gnu@16.2.3': + resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.1.6': - resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} + '@next/swc-linux-x64-musl@16.2.3': + resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.1.6': - resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} + '@next/swc-win32-arm64-msvc@16.2.3': + resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.1.6': - resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} + '@next/swc-win32-x64-msvc@16.2.3': + resolution: {integrity: sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -5832,14 +5832,6 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} - dedent@1.5.3: - resolution: {integrity: sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - dedent@1.7.0: resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==} peerDependencies: @@ -8057,8 +8049,8 @@ packages: typescript: optional: true - next@16.1.6: - resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} + next@16.2.3: + resolution: {integrity: sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -8842,7 +8834,6 @@ packages: puppeteer@24.10.0: resolution: {integrity: sha512-Oua9VkGpj0S2psYu5e6mCer6W9AU9POEQh22wRgSXnLXASGH+MwLUVWgLCLeP9QPHHcJ7tySUlg4Sa9OJmaLpw==} engines: {node: '>=18'} - deprecated: < 24.15.0 is no longer supported hasBin: true pure-rand@6.1.0: @@ -10412,8 +10403,8 @@ snapshots: '@ampproject/remapping@2.3.0': dependencies: - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@asamuzakjp/css-color@3.2.0': dependencies: @@ -11477,8 +11468,8 @@ snapshots: dependencies: '@babel/parser': 7.28.0 '@babel/types': 7.28.0 - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 '@babel/helper-compilation-targets@7.27.2': @@ -11667,9 +11658,9 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-config-prettier: 9.1.0(eslint@8.57.1) - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-gettext: 1.2.0 - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jest: 28.11.0(@typescript-eslint/eslint-plugin@8.28.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(jest@29.7.0(@types/node@22.15.30)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(typescript@5.8.3) eslint-plugin-jest-dom: 5.5.0(eslint@8.57.1) eslint-plugin-jest-formatting: 3.1.0(eslint@8.57.1) @@ -11774,11 +11765,11 @@ snapshots: neverthrow: 8.2.0 picocolors: 1.1.1 - '@c15t/nextjs@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2)': + '@c15t/nextjs@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2)': dependencies: '@c15t/react': 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) '@c15t/translations': 1.8.0 - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) react: 19.1.5 react-dom: 19.1.5(react@19.1.5) transitivePeerDependencies: @@ -13132,7 +13123,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 '@types/node': 22.15.30 chalk: 4.1.2 collect-v8-coverage: 1.0.2 @@ -13160,7 +13151,7 @@ snapshots: '@jest/source-map@29.6.3': dependencies: - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 callsites: 3.1.0 graceful-fs: 4.2.11 @@ -13182,7 +13173,7 @@ snapshots: dependencies: '@babel/core': 7.27.4 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 babel-plugin-istanbul: 6.1.1 chalk: 4.1.2 convert-source-map: 2.0.0 @@ -13304,7 +13295,7 @@ snapshots: - bufferutil - utf-8-validate - '@next/env@16.1.6': {} + '@next/env@16.2.3': {} '@next/eslint-plugin-next@15.3.3': dependencies: @@ -13314,28 +13305,28 @@ snapshots: dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.1.6': + '@next/swc-darwin-arm64@16.2.3': optional: true - '@next/swc-darwin-x64@16.1.6': + '@next/swc-darwin-x64@16.2.3': optional: true - '@next/swc-linux-arm64-gnu@16.1.6': + '@next/swc-linux-arm64-gnu@16.2.3': optional: true - '@next/swc-linux-arm64-musl@16.1.6': + '@next/swc-linux-arm64-musl@16.2.3': optional: true - '@next/swc-linux-x64-gnu@16.1.6': + '@next/swc-linux-x64-gnu@16.2.3': optional: true - '@next/swc-linux-x64-musl@16.1.6': + '@next/swc-linux-x64-musl@16.2.3': optional: true - '@next/swc-win32-arm64-msvc@16.1.6': + '@next/swc-win32-arm64-msvc@16.2.3': optional: true - '@next/swc-win32-x64-msvc@16.1.6': + '@next/swc-win32-x64-msvc@16.2.3': optional: true '@noble/ciphers@1.3.0': {} @@ -16211,9 +16202,9 @@ snapshots: dependencies: uncrypto: 0.1.3 - '@vercel/analytics@1.5.0(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': + '@vercel/analytics@1.5.0(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': optionalDependencies: - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) react: 19.1.5 svelte: 5.1.15 vue: 3.5.16(typescript@5.8.3) @@ -16239,9 +16230,9 @@ snapshots: '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - '@vercel/speed-insights@1.2.0(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': + '@vercel/speed-insights@1.2.0(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': optionalDependencies: - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) react: 19.1.5 svelte: 5.1.15 vue: 3.5.16(typescript@5.8.3) @@ -16340,7 +16331,7 @@ snapshots: '@vue/compiler-ssr': 3.5.16 '@vue/shared': 3.5.16 estree-walker: 2.0.2 - magic-string: 0.30.17 + magic-string: 0.30.21 postcss: 8.5.6 source-map-js: 1.2.1 @@ -17302,8 +17293,6 @@ snapshots: dependencies: mimic-response: 3.1.0 - dedent@1.5.3: {} - dedent@1.7.0: {} deep-eql@5.0.2: {} @@ -17709,8 +17698,8 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.4(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -17737,6 +17726,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.1 + eslint: 8.57.1 + get-tsconfig: 4.10.0 + is-bun-module: 1.3.0 + rspack-resolver: 1.2.2 + stable-hash: 0.0.5 + tinyglobby: 0.2.14 + optionalDependencies: + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + transitivePeerDependencies: + - supports-color + eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -17759,7 +17763,7 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -17773,6 +17777,35 @@ snapshots: dependencies: gettext-parser: 4.2.0 + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.8 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 @@ -19018,7 +19051,7 @@ snapshots: '@types/node': 22.15.30 chalk: 4.1.2 co: 4.6.0 - dedent: 1.5.3 + dedent: 1.7.0 is-generator-fn: 2.1.0 jest-each: 29.7.0 jest-matcher-utils: 29.7.0 @@ -19312,7 +19345,7 @@ snapshots: jest-util: 29.7.0 natural-compare: 1.4.0 pretty-format: 29.7.0 - semver: 7.7.2 + semver: 7.7.4 transitivePeerDependencies: - supports-color @@ -19763,7 +19796,6 @@ snapshots: magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - optional: true magicast@0.3.5: dependencies: @@ -19777,7 +19809,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.4 make-error@1.3.6: optional: true @@ -19956,22 +19988,22 @@ snapshots: optionalDependencies: '@rollup/rollup-linux-x64-gnu': 4.44.2 - next-auth@5.0.0-beta.30(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5): + next-auth@5.0.0-beta.30(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5): dependencies: '@auth/core': 0.41.0 - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) react: 19.1.5 next-intl-swc-plugin-extractor@4.8.3: {} - next-intl@4.8.3(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(typescript@5.8.3): + next-intl@4.8.3(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(typescript@5.8.3): dependencies: '@formatjs/intl-localematcher': 0.8.1 '@parcel/watcher': 2.5.1 '@swc/core': 1.15.18 icu-minify: 4.8.3 negotiator: 1.0.0 - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) next-intl-swc-plugin-extractor: 4.8.3 po-parser: 2.1.1 react: 19.1.5 @@ -19981,9 +20013,9 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5): + next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5): dependencies: - '@next/env': 16.1.6 + '@next/env': 16.2.3 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.7 caniuse-lite: 1.0.30001721 @@ -19992,14 +20024,14 @@ snapshots: react-dom: 19.1.5(react@19.1.5) styled-jsx: 5.1.6(@babel/core@7.27.4)(react@19.1.5) optionalDependencies: - '@next/swc-darwin-arm64': 16.1.6 - '@next/swc-darwin-x64': 16.1.6 - '@next/swc-linux-arm64-gnu': 16.1.6 - '@next/swc-linux-arm64-musl': 16.1.6 - '@next/swc-linux-x64-gnu': 16.1.6 - '@next/swc-linux-x64-musl': 16.1.6 - '@next/swc-win32-arm64-msvc': 16.1.6 - '@next/swc-win32-x64-msvc': 16.1.6 + '@next/swc-darwin-arm64': 16.2.3 + '@next/swc-darwin-x64': 16.2.3 + '@next/swc-linux-arm64-gnu': 16.2.3 + '@next/swc-linux-arm64-musl': 16.2.3 + '@next/swc-linux-x64-gnu': 16.2.3 + '@next/swc-linux-x64-musl': 16.2.3 + '@next/swc-win32-arm64-msvc': 16.2.3 + '@next/swc-win32-x64-msvc': 16.2.3 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.52.0 sharp: 0.34.5 @@ -20050,12 +20082,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.4.3(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5): + nuqs@2.4.3(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5): dependencies: mitt: 3.0.1 react: 19.1.5 optionalDependencies: - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) nwsapi@2.2.20: optional: true @@ -22175,7 +22207,7 @@ snapshots: v8-to-istanbul@9.2.0: dependencies: - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0