Skip to content
Merged
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/wise-ads-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst-core": minor
---

Add support for Scripts API/Script Manager scripts rendering via next/script
13 changes: 12 additions & 1 deletion core/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { revalidate } from '~/client/revalidate-target';
import { WebAnalyticsFragment } from '~/components/analytics/fragment';
import { AnalyticsProvider } from '~/components/analytics/provider';
import { ContainerQueryPolyfill } from '~/components/polyfills/container-query';
import { ScriptManagerScripts, ScriptsFragment } from '~/components/scripts';
import { routing } from '~/i18n/routing';
import { getToastNotification } from '~/lib/server-toast';

Expand All @@ -35,13 +36,16 @@ const RootLayoutMetadataQuery = graphql(
}
...WebAnalyticsFragment
}
content {
...ScriptsFragment
}
}
channel {
entityId
}
}
`,
[WebAnalyticsFragment],
[WebAnalyticsFragment, ScriptsFragment],
);

const fetchRootLayoutMetadata = cache(async () => {
Expand Down Expand Up @@ -109,6 +113,12 @@ export default async function RootLayout({ params, children }: Props) {

return (
<html className={clsx(fonts.map((f) => f.variable))} lang={locale}>
<head>

Copilot AI Jul 14, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] In the Next.js App Router, it’s conventional to use a self-closing <head /> tag and manage head contents in a dedicated head.tsx file. Consider moving script injections into that file to align with framework conventions.

Copilot uses AI. Check for mistakes.
<ScriptManagerScripts
scripts={data.site.content.headerScripts}
strategy="afterInteractive"
/>
</head>
<body className="flex min-h-screen flex-col">
<NextIntlClientProvider>
<NuqsAdapter>
Expand All @@ -124,6 +134,7 @@ export default async function RootLayout({ params, children }: Props) {
</NextIntlClientProvider>
<VercelComponents />
<ContainerQueryPolyfill />
<ScriptManagerScripts scripts={data.site.content.footerScripts} strategy="lazyOnload" />
</body>
</html>
);
Expand Down
1 change: 1 addition & 0 deletions core/client/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const graphql = initGraphQLTada<{
DateTime: string;
Long: number;
BigDecimal: number;
UUID: string;
};
disableMasking: true;
}>();
Expand Down
66 changes: 66 additions & 0 deletions core/components/scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Script Components

This directory contains components for rendering BigCommerce scripts using Next.js Script component.

## Components

### ScriptManagerScripts
A single component that renders scripts with configurable strategy. Location filtering is now handled at the GraphQL level, simplifying the component logic.

## Usage

```tsx
import { ScriptManagerScripts } from '~/components/scripts';

// In your layout component:
<head>
<ScriptManagerScripts
scripts={data.site.content.headerScripts}
strategy="afterInteractive"
/>
</head>
<body>
{children}
<ScriptManagerScripts
scripts={data.site.content.footerScripts}
strategy="lazyOnload"
/>
</body>
```

## Architecture

The component accepts:
- `scripts`: Pre-filtered GraphQL scripts data (headerScripts or footerScripts)
- `strategy`: 'afterInteractive', 'lazyOnload', or other Next.js Script strategies

## Script Processing

The component handles both types of BigCommerce scripts:

### External Scripts (SrcScript)
```tsx
<Script
id="bc-script-123"
src="https://example.com/script.js"
strategy="afterInteractive"
integrity="sha256-abc123..."
crossOrigin="anonymous"
/>
```

### Inline Scripts (InlineScript)
Inline scripts are rendered using `dangerouslySetInnerHTML` for reliable execution:
```tsx
<Script
id="bc-script-456"
strategy="lazyOnload"
dangerouslySetInnerHTML={{ __html: scriptContent }}
/>
```

## Performance & Security

- Header scripts use `afterInteractive` strategy for critical functionality
- Footer scripts use `lazyOnload` strategy for better performance
- Integrity hashes are included when available for security
39 changes: 39 additions & 0 deletions core/components/scripts/fragment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { graphql } from '~/client/graphql';

export const ScriptsFragment = graphql(`
fragment ScriptsFragment on Content {
headerScripts: scripts(
first: 50
filters: { visibilities: [ALL_PAGES, STOREFRONT], location: HEAD }
) {
...ScriptTypeConnectionFragment
}
footerScripts: scripts(
first: 50
Comment on lines +3 to +12

Copilot AI Jul 14, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The hardcoded limit of first: 50 may truncate scripts if more are added. Consider extracting this into a named constant or documenting the rationale for using 50.

Suggested change
export const ScriptsFragment = graphql(`
fragment ScriptsFragment on Content {
headerScripts: scripts(
first: 50
filters: { visibilities: [ALL_PAGES, STOREFRONT], location: HEAD }
) {
...ScriptTypeConnectionFragment
}
footerScripts: scripts(
first: 50
// Default limit for the number of scripts to fetch. Adjust this value as needed.
const DEFAULT_SCRIPT_LIMIT = 50;
export const ScriptsFragment = graphql(`
fragment ScriptsFragment on Content {
headerScripts: scripts(
first: DEFAULT_SCRIPT_LIMIT
filters: { visibilities: [ALL_PAGES, STOREFRONT], location: HEAD }
) {
...ScriptTypeConnectionFragment
}
footerScripts: scripts(
first: DEFAULT_SCRIPT_LIMIT

Copilot uses AI. Check for mistakes.
filters: { visibilities: [ALL_PAGES, STOREFRONT], location: FOOTER }
) {
...ScriptTypeConnectionFragment
}
}

fragment ScriptTypeConnectionFragment on ScriptTypeConnection {
edges {
node {
__typename
integrityHashes {
hash
}
entityId
consentCategory
location
visibility
... on InlineScript {
scriptTag
}
... on SrcScript {
src
}
}
}
}
`);
2 changes: 2 additions & 0 deletions core/components/scripts/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ScriptManagerScripts } from './scripts';
export { ScriptsFragment } from './fragment';
68 changes: 68 additions & 0 deletions core/components/scripts/scripts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client';
import Script from 'next/script';
import { ComponentProps } from 'react';

import { FragmentOf } from '~/client/graphql';

import { ScriptsFragment } from './fragment';

type ScriptsData = FragmentOf<typeof ScriptsFragment>;

interface ScriptRendererProps {
scripts: ScriptsData['headerScripts'] | null;
strategy: ComponentProps<typeof Script>['strategy'];
}

export const ScriptManagerScripts = ({ scripts, strategy }: ScriptRendererProps) => {
if (!scripts?.edges) return null;

const scriptNodes = removeEdgesAndNodes(scripts);

return (
<>
{scriptNodes
.map((script) => {
const scriptProps: ComponentProps<typeof Script> = {
strategy,
};

// Handle external scripts (SrcScript)
if (script.__typename === 'SrcScript' && script.src) {
scriptProps.src = script.src;

// Add integrity hashes if provided
if (script.integrityHashes.length > 0) {
scriptProps.integrity = script.integrityHashes
.map((hashObj) => hashObj.hash)
.join(' ');
scriptProps.crossOrigin = 'anonymous';
}
}

// Handle inline scripts (InlineScript)
if (script.__typename === 'InlineScript' && script.scriptTag) {
const scriptMatch = /<script[^>]*>([\s\S]*?)<\/script>/i.exec(script.scriptTag);

if (scriptMatch?.[1]) {
scriptProps.dangerouslySetInnerHTML = {
__html: scriptMatch[1],
};
}
}

scriptProps.id = `bc-script-${script.entityId}`;

// Return null for invalid scripts (will be filtered out)
if (!scriptProps.src && !scriptProps.dangerouslySetInnerHTML) {
return null;
}

Comment thread
bookernath marked this conversation as resolved.
return scriptProps;
})
.filter((scriptProps): scriptProps is ComponentProps<typeof Script> => scriptProps !== null)
.map((scriptProps) => {
return <Script key={scriptProps.id} {...scriptProps} />;
})}
</>
);
};