Skip to content

feat(scripts): filter script manager gql response based on cookie consent#2651

Closed
matthewvolk wants to merge 1 commit intocanaryfrom
feat/consent-manager-scripts-api
Closed

feat(scripts): filter script manager gql response based on cookie consent#2651
matthewvolk wants to merge 1 commit intocanaryfrom
feat/consent-manager-scripts-api

Conversation

@matthewvolk
Copy link
Copy Markdown
Contributor

@matthewvolk matthewvolk commented Oct 27, 2025

Warning

Depends on #2650

What/Why?

Filters BigCommerce scripts based on cookie consent. Reads consent from c15t-consent on the server, maps C15T consent categories (which cannot be changed, unfortunately) to BigCommerce Script Consent Categories as follows:

  • C15T measurement → BigCommerce ANALYTICS
  • C15T necessary → BigCommerce ESSENTIAL
  • C15T functionality → BigCommerce FUNCTIONAL
  • C15T experience → BigCommerce FUNCTIONAL (BigCommerce does not have a fifth category, C15T does)
  • C15T marketing → BigCommerce TARGETING

These categories are passed to the GraphQL query filter, which does not fetch scripts from BC until the user consents.

Testing

  1. Created a script for each category of consent BigCommerce offers (even "unknown", which is the default for scripts created via API that do not specify "consent_category". For these scripts, our API states Scripts with an unknown consent category do not display on stores with customer cookie consent banners enabled, so I followed that and only display "unknown" scripts when full consent is provided)
  2. Shows storefront loading only ESSENTIAL scripts when consent has not been provided
  3. Shows storefront loading only ESSENTIAL scripts when "Reject All" (only ESSENTIAL) consent has been provided
  4. Shows storefront loading ALL scripts when "Accept All" consent has been provided (including UNKNOWN)
  5. Shows storefront loading selective scripts based on customized consent settings (and notably, not loading UNKNOWN in the case where some consent categories have been rejected)

Demo of the testing above:
https://github.com/user-attachments/assets/be44cb40-9077-40a0-9775-0b5170de68f8

Migration

  1. New files:
    • core/lib/consent-manager/action.ts - Server action for consent updates
  2. Modified files:
    • core/lib/consent-manager/cookies.ts - Adds experience, exports ConsentStateSchema, adds getConsentCategoriesFromCookie mapping
    • core/lib/consent-manager/handlers.ts - setConsent is async; uses server action with revalidation
    • core/app/[locale]/layout.tsx - Reads cookie, maps categories, passes to GraphQL
    • core/components/scripts/fragment.ts - Adds consentCategories filter to header/footer queries
  3. Breaking changes:
    • setConsent is async; update callers to await it
    • Adds experience consent type; UI components already support it
  4. Category mapping:
    • measurementANALYTICS
    • necessaryESSENTIAL
    • functionality, experienceFUNCTIONAL
    • marketingTARGETING
    • If all categories accepted, includes UNKNOWN
    • No consent defaults to ESSENTIAL only
  5. Cache revalidation:
    • Uses revalidateTag('layout') so layout data updates after consent changes
  6. GraphQL variables:
    • RootLayoutMetadataQuery now requires $consentCategories
    • Layout reads c15t-consent and derives categories before the query

@matthewvolk matthewvolk requested a review from a team October 27, 2025 20:06
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Oct 27, 2025

🦋 Changeset detected

Latest commit: 15ae436

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@bigcommerce/catalyst-core Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel bot commented Oct 27, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
catalyst-b2b Ready Ready Preview Comment Oct 29, 2025 6:20pm
catalyst-canary Ready Ready Preview Comment Oct 29, 2025 6:20pm
1 Skipped Deployment
Project Deployment Preview Comments Updated (UTC)
catalyst Ignored Ignored Oct 29, 2025 6:20pm

const enabledCategories = entries
.filter(([, value]) => value)
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
.map(([key]) => mapping[key as keyof ConsentState]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Might want to add a .filter and validate the key as ConsentNamesSchema so we don't have to do type assertions.

Comment on lines 56 to 65
const fetchRootLayoutMetadata = cache(async () => {
const consentCookie = (await consentCookieServer()).get();
const consentCategories = getConsentCategoriesFromCookie(consentCookie);

return await client.fetch({
document: RootLayoutMetadataQuery,
variables: { consentCategories },
fetchOptions: { next: { revalidate } },
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We need to rethink how we pass consentCategories to this query (or maybe create a new query just for scripts).

The problem with this implementation is that you're reading from cookies inside the cached function. If we want to enable composable caching at some other point, consentCategories needs to be a param passed to fetchRootLayoutMetadata (which is simple enough).

However, to read from cookies in layout.tsx without wrapping it in a Streamable, will make this entire layout dynamic, and we want to keep it as static as possible.

I believe we need to figure out a way to stream in what scripts are loaded per selected consent, and render those scripts after the request has streamed in.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great catch! Thanks for that, it slipped my mind. Just pushed up a fix, though I think it'd be worthwhile to talk through it a bit more. There's something smelly about my two separate streamableHeaderScripts and streamableFooterScripts fn's.

Copy link
Copy Markdown
Contributor

@jorgemoya jorgemoya Oct 29, 2025

Choose a reason for hiding this comment

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

I see what you say!

What if we just have one streamableScripts and then <Stream /> the script components.

<Stream fallback={null} value={streamableScripts}>
  {(scripts) => <ScriptManagerScripts scripts={scripts. headerScripts} strategy="afterInteractive" />}
</Stream>

...

<Stream fallback={null} value={streamableScripts}>
  {(scripts) => <ScriptManagerScripts scripts={scripts.footerScripts} strategy="lazyOnload" />}
</Stream>

I have no clue how this conflicts with the strategy tho.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I guess it is not recommended to Stream afterInteractive scripts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Follow up question, what scripts are loaded in the header vs footer? Are header scripts essential and should always load? Or is it user choice? My thinking is that maybe header scripts are essential and we always load them in the initial layout request, while scripts requiring consent are loaded in the footer and Streamed in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants