Skip to content
Closed
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;
+ },
```
29 changes: 19 additions & 10 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 { unstable_cache } from 'next/cache';
import { cache } from 'react';

import { client } from '~/client';
Expand All @@ -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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ResetPasswordSection
Expand Down
63 changes: 35 additions & 28 deletions core/app/[locale]/(default)/(auth)/register/page-data.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
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';
import { FormFieldsFragment } from '~/data-transformers/form-field-transformer/fragment';

const RegisterCustomerQuery = graphql(
Expand Down Expand Up @@ -61,35 +62,41 @@ interface Props {
};
}

export const getRegisterCustomerQuery = cache(async ({ address, customer }: Props) => {
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);
});
2 changes: 1 addition & 1 deletion core/app/[locale]/(default)/(auth)/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
});
Expand Down
41 changes: 32 additions & 9 deletions core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { unstable_cache } from 'next/cache';
import { cache } from 'react';

import { client } from '~/client';
Expand Down Expand Up @@ -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);
},
);
Loading
Loading