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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dynamic-imports-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst-core": minor
---

Use dynamic imports for next/headers, next/navigation, and next-intl/server in the client module to avoid AsyncLocalStorage poisoning during next.config.ts resolution
12 changes: 12 additions & 0 deletions .changeset/upgrade-nextjs-16.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@bigcommerce/catalyst-core": minor
---

Upgrade Next.js to v16 and align peer dependencies.

To migrate your Catalyst storefront to Next.js 16:

- Update `next` to `^16.0.0` in your `package.json` and install dependencies.
- Replace any usage of `unstable_expireTag` with `revalidateTag` and `unstable_expirePath` with `revalidatePath` from `next/cache`.
- Update `tsconfig.json` to use `"moduleResolution": "bundler"` and `"module": "nodenext"` as required by Next.js 16.
- Address Next.js 16 deprecation lint errors (e.g. legacy `<img>` elements, missing `rel="noopener noreferrer"` on external links).
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BigCommerceAPIError, BigCommerceGQLError } from '@bigcommerce/catalyst-client';
import { parseWithZod } from '@conform-to/zod';
import { unstable_expireTag as expireTag } from 'next/cache';
import { revalidateTag } from 'next/cache';
import { getTranslations } from 'next-intl/server';
import { z } from 'zod';

Expand Down Expand Up @@ -234,7 +234,7 @@ export async function createAddress(
};
}

expireTag(TAGS.customer);
revalidateTag(TAGS.customer, { expire: 0 });

return {
addresses: [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BigCommerceGQLError } from '@bigcommerce/catalyst-client';
import { parseWithZod } from '@conform-to/zod';
import { unstable_expireTag as expireTag } from 'next/cache';
import { revalidateTag } from 'next/cache';
import { getTranslations } from 'next-intl/server';
import { z } from 'zod';

Expand Down Expand Up @@ -78,7 +78,7 @@ export async function deleteAddress(prevState: Awaited<State>, formData: FormDat
};
}

expireTag(TAGS.customer);
revalidateTag(TAGS.customer, { expire: 0 });

return {
addresses: prevState.addresses.filter(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BigCommerceGQLError } from '@bigcommerce/catalyst-client';
import { parseWithZod } from '@conform-to/zod';
import { unstable_expireTag as expireTag } from 'next/cache';
import { revalidateTag } from 'next/cache';
import { getTranslations } from 'next-intl/server';
import { z } from 'zod';

Expand Down Expand Up @@ -247,7 +247,7 @@ export async function updateAddress(
};
}

expireTag(TAGS.customer);
revalidateTag(TAGS.customer, { expire: 0 });

return {
addresses: prevState.addresses.map((address) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { BigCommerceGQLError } from '@bigcommerce/catalyst-client';
import { parseWithZod } from '@conform-to/zod';
import { unstable_expireTag } from 'next/cache';
import { revalidateTag } from 'next/cache';
import { getTranslations } from 'next-intl/server';

import { updateAccountSchema } from '@/vibes/soul/sections/account-settings/schema';
Expand Down Expand Up @@ -75,7 +75,7 @@ export const updateCustomer: UpdateAccountAction = async (prevState, formData) =
};
}

unstable_expireTag(TAGS.customer);
revalidateTag(TAGS.customer, { expire: 0 });

return {
account: submission.value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { BigCommerceGQLError } from '@bigcommerce/catalyst-client';
import { SubmissionResult } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { unstable_expireTag } from 'next/cache';
import { revalidateTag } from 'next/cache';
import { getTranslations } from 'next-intl/server';
import { z } from 'zod';

Expand Down Expand Up @@ -120,7 +120,7 @@ export const updateNewsletterSubscription = async (
};
}

unstable_expireTag(TAGS.customer);
revalidateTag(TAGS.customer, { expire: 0 });

return {
lastResult: submission.reply(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export async function toggleWishlistVisibility(
};
}

revalidateTag(TAGS.customer);
revalidateTag(TAGS.customer, { expire: 0 });

return {
lastResult: submission.reply(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export async function deleteWishlist(
};
}

revalidateTag(TAGS.customer);
revalidateTag(TAGS.customer, { expire: 0 });

// Server toast has to be used here since the item is being deleted. When revalidateTag is called,
// the wishlist items will update, and the element node containing the useEffect will be removed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export async function newWishlist(prevState: Awaited<State>, formData: FormData)
};
}

revalidateTag(TAGS.customer);
revalidateTag(TAGS.customer, { expire: 0 });

return {
lastResult: submission.reply(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export async function removeWishlistItem(
};
}

revalidateTag(TAGS.customer);
revalidateTag(TAGS.customer, { expire: 0 });

// Server toast has to be used here since the item is being deleted. When revalidateTag is called,
// the wishlist items will update, and the element node containing the useEffect will be removed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export async function renameWishlist(
};
}

revalidateTag(TAGS.customer);
revalidateTag(TAGS.customer, { expire: 0 });

return {
lastResult: submission.reply(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const addShippingCost = async ({

const result = response.data.checkout.selectCheckoutShippingOption?.checkout;

revalidateTag(TAGS.checkout);
revalidateTag(TAGS.checkout, { expire: 0 });

return result;
};
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const addCheckoutShippingConsignments = async ({
fetchOptions: { cache: 'no-store' },
});

revalidateTag(TAGS.checkout);
revalidateTag(TAGS.checkout, { expire: 0 });

return response.data.checkout.addCheckoutShippingConsignments?.checkout;
};
Expand Down Expand Up @@ -135,7 +135,7 @@ export const updateCheckoutShippingConsignment = async ({
fetchOptions: { cache: 'no-store' },
});

revalidateTag(TAGS.checkout);
revalidateTag(TAGS.checkout, { expire: 0 });

return response.data.checkout.updateCheckoutShippingConsignment?.checkout;
};
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const applyCouponCode = async ({ checkoutEntityId, couponCode }: Props) =

const checkout = response.data.checkout.applyCheckoutCoupon?.checkout;

revalidateTag(TAGS.checkout);
revalidateTag(TAGS.checkout, { expire: 0 });

return checkout;
};
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const applyGiftCertificate = async ({ checkoutEntityId, giftCertificateCo

const checkout = response.data.checkout.applyCheckoutGiftCertificate?.checkout;

revalidateTag(TAGS.checkout);
revalidateTag(TAGS.checkout, { expire: 0 });

return checkout;
};
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const removeCouponCode = async ({ checkoutEntityId, couponCode }: Props)

const checkout = response.data.checkout.unapplyCheckoutCoupon?.checkout;

revalidateTag(TAGS.checkout);
revalidateTag(TAGS.checkout, { expire: 0 });

return checkout;
};
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const removeGiftCertificate = async ({ checkoutEntityId, giftCertificateC

const checkout = response.data.checkout.unapplyCheckoutGiftCertificate?.checkout;

revalidateTag(TAGS.checkout);
revalidateTag(TAGS.checkout, { expire: 0 });

return checkout;
};
4 changes: 2 additions & 2 deletions core/app/[locale]/(default)/cart/_actions/remove-item.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use server';

import { unstable_expireTag } from 'next/cache';
import { revalidateTag } from 'next/cache';
import { getTranslations } from 'next-intl/server';

import { getSessionCustomerAccessToken } from '~/auth';
Expand Down Expand Up @@ -62,7 +62,7 @@ export async function removeItem({
await clearCartId();
}

unstable_expireTag(TAGS.cart);
revalidateTag(TAGS.cart, { expire: 0 });

return cart;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use server';

import { unstable_expirePath } from 'next/cache';
import { revalidatePath } from 'next/cache';
import { getTranslations } from 'next-intl/server';

import { getSessionCustomerAccessToken } from '~/auth';
Expand Down Expand Up @@ -87,7 +87,7 @@ export const updateQuantity = async ({
throw new Error(t('failedToUpdateQuantity'));
}

unstable_expirePath('/cart');
revalidatePath('/cart');

return cart;
};
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ export async function wishlistAction(payload: FormData): Promise<void> {
}
}

revalidateTag(TAGS.customer);
revalidateTag(TAGS.customer, { expire: 0 });
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
Expand Down
25 changes: 18 additions & 7 deletions core/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
import { BigCommerceAuthError, createClient } from '@bigcommerce/catalyst-client';
import { headers } from 'next/headers';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { redirect } from 'next/navigation';
import { getLocale as getServerLocale } from 'next-intl/server';

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 locale = await getServerLocale();
const { getLocale: getServerLocale } = await import('next-intl/server');

return locale;
return await getServerLocale();
} catch {
/**
* Next-intl `getLocale` only works on the server, and when middleware has run.
*
* Instances when `getLocale` will not work:
* - Requests during next.config.ts resolution
* - Requests in middlewares
* - Requests in `generateStaticParams`
* - Request in api routes
Expand Down Expand Up @@ -45,6 +53,7 @@ export const client = createClient({
const locale = await getLocale();

if (fetchOptions?.cache && ['no-store', 'no-cache'].includes(fetchOptions.cache)) {
const { headers } = await import('next/headers');
const ipAddress = (await headers()).get('X-Forwarded-For');

if (ipAddress) {
Expand All @@ -61,8 +70,10 @@ export const client = createClient({
headers: requestHeaders,
};
},
onError: (error, queryType) => {
onError: async (error, queryType) => {
if (error instanceof BigCommerceAuthError && queryType === 'query') {
const { redirect } = await import('next/navigation');

redirect('/api/auth/signout');
}
},
Expand Down
2 changes: 1 addition & 1 deletion core/components/header/_actions/switch-currency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export const switchCurrency = async (_prevState: SubmissionResult | null, payloa
if (cartId) {
await updateCartCurrency(cartId, submission.value.id)
.then(() => {
revalidateTag(TAGS.cart);
revalidateTag(TAGS.cart, { expire: 0 });
})
.catch((error: unknown) => {
// eslint-disable-next-line no-console
Expand Down
6 changes: 3 additions & 3 deletions core/lib/cart/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use server';

import { unstable_expireTag } from 'next/cache';
import { revalidateTag } from 'next/cache';

import { auth, getAnonymousSession, updateAnonymousSession, updateSession } from '~/auth';
import { TAGS } from '~/client/tags';
Expand Down Expand Up @@ -59,7 +59,7 @@ export async function addToOrCreateCart(
throw new MissingCartError();
}

unstable_expireTag(TAGS.cart);
revalidateTag(TAGS.cart, { expire: 0 });

return;
}
Expand All @@ -72,5 +72,5 @@ export async function addToOrCreateCart(

await setCartId(createResponse.data.cart.createCart.cart.entityId);

unstable_expireTag(TAGS.cart);
revalidateTag(TAGS.cart, { expire: 0 });
}
6 changes: 3 additions & 3 deletions core/middlewares/compose-middlewares.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { type NextMiddleware, NextResponse } from 'next/server';
import { type NextProxy, NextResponse } from 'next/server';

export type MiddlewareFactory = (middleware: NextMiddleware) => NextMiddleware;
export type MiddlewareFactory = (middleware: NextProxy) => NextProxy;

export const composeMiddlewares = (
firstMiddlewareWrapper: MiddlewareFactory,
...otherMiddlewareWrappers: MiddlewareFactory[]
): NextMiddleware => {
): NextProxy => {
const middlewares = otherMiddlewareWrappers.reduce(
(accumulatedMiddlewares, nextMiddleware) => (middleware) =>
accumulatedMiddlewares(nextMiddleware(middleware)),
Expand Down
6 changes: 3 additions & 3 deletions core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@
"lodash.debounce": "^4.0.8",
"lru-cache": "^11.1.0",
"lucide-react": "^0.474.0",
"next": "15.5.10",
"next": "^16.1.6",
"next-auth": "5.0.0-beta.30",
"next-intl": "^4.1.0",
"next-intl": "^4.6.1",
"nuqs": "^2.4.3",
"p-lazy": "^5.0.0",
"react": "19.1.5",
Expand All @@ -79,7 +79,7 @@
"@bigcommerce/eslint-config-catalyst": "workspace:^",
"@faker-js/faker": "^9.8.0",
"@gql.tada/cli-utils": "^1.6.3",
"@next/bundle-analyzer": "15.5.10",
"@next/bundle-analyzer": "^16.1.6",
"@playwright/test": "^1.52.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16",
Expand Down
3 changes: 2 additions & 1 deletion core/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
Expand Down Expand Up @@ -48,6 +48,7 @@
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"tests/**/*"
],
"exclude": ["node_modules", ".next"]
Expand Down
Loading
Loading