Skip to content
Draft
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
112 changes: 112 additions & 0 deletions .changeset/curly-hats-greet.md
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};

- 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);
+ },
);
```
40 changes: 40 additions & 0 deletions .changeset/plain-results-happen.md
Original file line number Diff line number Diff line change
@@ -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> | string;
+ getChannelId?: (defaultChannelId: string, locale?: string) => Promise<string> | 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;
+ },
```
13 changes: 11 additions & 2 deletions core/app/[locale]/(default)/(auth)/change-password/page-data.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { cacheLife } from 'next/cache';
import { cache } from 'react';

import { client } from '~/client';
Expand All @@ -24,10 +25,14 @@ const ChangePasswordQuery = graphql(`
}
`);

export const getChangePasswordQuery = cache(async () => {
async function getCachedChangePasswordQuery(locale: string) {
'use cache';

cacheLife({ revalidate });

const response = await client.fetch({
document: ChangePasswordQuery,
fetchOptions: { next: { revalidate } },
locale,
});

const passwordComplexitySettings =
Expand All @@ -36,4 +41,8 @@ export const getChangePasswordQuery = cache(async () => {
return {
passwordComplexitySettings,
};
}

export const getChangePasswordQuery = cache(async (locale: string) => {
return getCachedChangePasswordQuery(locale);
});
14 changes: 5 additions & 9 deletions core/app/[locale]/(default)/(auth)/change-password/page.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -16,10 +16,8 @@ interface Props {
}>;
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;

const t = await getTranslations({ locale, namespace: 'Auth.ChangePassword' });
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('Auth.ChangePassword');

return {
title: t('title'),
Expand All @@ -29,16 +27,14 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
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');

if (!customerEntityId || !token) {
return redirect({ href: '/login', locale });
}

const { passwordComplexitySettings } = await getChangePasswordQuery();
const { passwordComplexitySettings } = await getChangePasswordQuery(locale);

return (
<ResetPasswordSection
Expand Down
14 changes: 11 additions & 3 deletions core/app/[locale]/(default)/(auth)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -17,3 +17,11 @@ export default async function Layout({ children, params }: Props) {

return children;
}

export default function Layout({ children, params }: Props) {
return (
<Suspense>
<AuthGuard params={params}>{children}</AuthGuard>
</Suspense>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
const { locale } = await params;

const t = await getTranslations({ locale, namespace: 'Auth.Login.ForgotPassword' });
export async function generateMetadata(): Promise<Metadata> {
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 (
Expand Down
13 changes: 4 additions & 9 deletions core/app/[locale]/(default)/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,22 +17,17 @@ interface Props {
}>;
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;

const t = await getTranslations({ locale, namespace: 'Auth.Login' });
export async function generateMetadata(): Promise<Metadata> {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,3 @@ export async function GET(_: Request, { params }: { params: Promise<{ token: str
redirect(`/login?error=InvalidToken`);
}
}

export const dynamic = 'force-dynamic';
2 changes: 1 addition & 1 deletion core/app/[locale]/(default)/(auth)/logout/route.ts
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Loading
Loading