Skip to content

feat(tracking): CMS-configurable analytics and cookie consent - #149

Open
arnespremberg wants to merge 11 commits into
mainfrom
feat/analytics-tracking
Open

feat(tracking): CMS-configurable analytics and cookie consent#149
arnespremberg wants to merge 11 commits into
mainfrom
feat/analytics-tracking

Conversation

@arnespremberg

@arnespremberg arnespremberg commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Adds a CMS-configurable analytics layer wired into the existing vanilla-cookieconsent banner. Ported from the implementation in backendforth/mammalsandcomputers (PR #38), adapted to starter paths and conventions.

What this adds

A siteAnalyticsSettings singleton with per-provider configuration for:

  • Google Analytics
  • Matomo
  • Microsoft Clarity
  • PostHog
  • Plausible

Each provider has a cookie-free mode and its own cookie-banner labels. Cookie-based trackers wait for analytics consent; cookie-free trackers load immediately when the provider is set to respect the banner.

How it wires up

  • StudiositeAnalyticsSettings singleton, one tracker* object per provider, structure item, open-cookie-preferences link function
  • WebSiteTracking / SiteTrackingShell mounted in [locale]/layout.tsx, with the loader and runtime under components/tracking/lib/
  • Consent — cookie-banner sections are generated from the configured trackers, so enabling a provider in the CMS surfaces it in the preferences dialog automatically
  • Revalidation — cache tags added for the analytics and cookie-banner fetches, plus the matching webhook cases in api/revalidate

Verification

pnpm run format, pnpm run typecheck, and pnpm run check:wiring all pass locally (Node 24, pnpm 10.21). No dependency changes — the starter already ships vanilla-cookieconsent@^3.1.0.

Notes for review

  • PostHog init retries are capped, SPA navigations are tracked, and the tracker unloads on consent revoke.
  • Self-hosted providers need CSP entries; netlify.toml documents which.
  • Cookie-free providers still appear in the preferences dialog, marked as such, so the disclosure stays complete.

Note

Medium Risk
Third-party scripts and consent logic affect every page load and privacy behavior; CSP broadening and Clarity’s reload-on-revoke are operational edge cases, but defaults favor respecting the banner.

Overview
Introduces CMS-configurable analytics via a new siteAnalyticsSettings singleton (five tracker types, load mode, cookie-free flags, banner copy) and wires it into the locale layout through SiteTrackingShell / SiteTracking, which load or unload scripts based on consent, config changes, and SPA navigations.

The cookie banner is tightened for compliance: required privacy policy URL and reject buttons, escaped consent copy with a safe policy link, preference sections built from enabled trackers (with a synthetic analytics section if missing), and cross-document Studio warnings when the banner is off or load mode bypasses consent.

Netlify CSP expands script-src, connect-src, and img-src for Google Tag Manager/Analytics, Clarity, PostHog, and Plausible. Revalidation adds site-analytics-settings (and existing cookie-banner tag usage) on Sanity webhooks.

Reviewed by Cursor Bugbot for commit 76a27e4. Bugbot is set up for automated code reviews on this repo. Configure here.

…egration

Add siteAnalyticsSettings singleton for Google Analytics, Matomo, Microsoft
Clarity, PostHog, and Plausible with per-provider cookie-free modes and
cookie-banner labels. Integrate with the existing vanilla-cookieconsent
banner: cookie-based trackers wait for analytics consent; cookie-free
trackers load immediately when respecting the banner.
Copilot AI balanced review requested due to automatic review settings August 6, 2026 14:26
@netlify

netlify Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploy Preview for bef-next-sanity-starter ready!

Name Link
🔨 Latest commit 76a27e4
🔍 Latest deploy log https://app.netlify.com/projects/bef-next-sanity-starter/deploys/6a7583a2b10b220007ccc043
😎 Deploy Preview https://deploy-preview-149--bef-next-sanity-starter.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds CMS-configurable analytics providers, consent gating, disclosure, cache revalidation, and deployment CSP support.

Changes:

  • Adds Studio schemas and singleton configuration for five analytics providers.
  • Loads trackers according to CMS settings and cookie consent.
  • Integrates analytics settings with the global layout, cookie dialog, and revalidation.

Reviewed changes

Copilot reviewed 28 out of 28 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
web/src/components/tracking/SiteTrackingShell.tsx Fetches tracking configuration.
web/src/components/tracking/SiteTracking.tsx Applies consent-based loading.
web/src/components/tracking/lib/trackingRuntime.ts Manages tracker runtime state.
web/src/components/tracking/lib/trackingConfig.ts Filters and classifies trackers.
web/src/components/tracking/lib/loadTrackers.ts Loads provider scripts.
web/src/components/tracking/lib/defaultCookieSections.ts Defines fallback consent sections.
web/src/components/tracking/lib/cookieConsentConfig.ts Adds trackers to consent disclosures.
web/src/components/cookies/cookieConsentApi.ts Publishes consent changes.
web/src/components/cookies/CookieConsent.tsx Connects trackers to the banner.
web/src/app/api/revalidate/route.ts Revalidates analytics settings.
web/src/app/[locale]/layout.tsx Mounts tracking globally.
web/sanity/types/siteAnalyticsSettings.ts Defines analytics data types.
web/sanity/types/index.ts Exports the settings type.
web/sanity/queries/snippets/settings.ts Queries analytics configuration.
web/sanity/queries/index.ts Exports the analytics query.
web/sanity/fetchSanityData.ts Fetches cached analytics settings.
web/sanity/cachedSanityQuery.ts Adds the analytics cache tag.
studio/schemas/settings/siteAnalyticsSettings.ts Defines the analytics singleton.
studio/schemas/objects/analytics/trackerPostHog.ts Defines PostHog settings.
studio/schemas/objects/analytics/trackerPlausible.ts Defines Plausible settings.
studio/schemas/objects/analytics/trackerMicrosoftClarity.ts Defines Clarity settings.
studio/schemas/objects/analytics/trackerMatomo.ts Defines Matomo settings.
studio/schemas/objects/analytics/trackerGoogleAnalytics.ts Defines GA4 settings.
studio/schemas/index.ts Registers analytics schemas.
studio/config/structure/items/siteAnalyticsSettings.ts Adds the Studio structure item.
studio/config/structure/index.ts Registers the structure item.
studio/config/singletons.ts Registers singleton behavior.
netlify.toml Expands analytics CSP origins.
Suppressed comments (1)

web/src/components/tracking/SiteTracking.tsx:54

  • The consent-change callback is used for both grants and revocations, but loadAllowed(false) only computes an empty pending set; it never unloads trackers that were loaded after a prior grant. Revoking analytics therefore leaves GA, Matomo, Clarity, and PostHog running. Handle the false transition by unloading every consent-required tracker and clearing its loaded key, with loader-specific re-enable behavior for a later grant.
		const loadAllowed = (analyticsAccepted: boolean) => {
			const allowed = trackersForConsent(trackers, config, analyticsAccepted);

config.cookieBanner?.useCookieBanner === true &&
config.analytics?.loadMode === "respectCookieBanner";

useEffect(() => {
Comment on lines +23 to +27
const raw = doc.preferencesModal?.sections;
if (typeof raw === "string" && raw.trim().length > 0) {
try {
JSON.parse(raw);
return buildCookieSections(doc, trackers);
Comment on lines +95 to +96
const scriptHost = apiHost.replace(/\/$/, "");
const script = appendScript(`${scriptHost}/static/array.js`);
};
}
).posthog;
if (posthog?.init) {
Comment on lines +83 to +85
return sections.map((section) => {
const sectionId = (section as Section & { id?: string }).id;
if (section.linkedCategory !== "analytics" && sectionId !== "analytics") {
Comment on lines +5 to +7
export const siteAnalyticsSettings = defineType({
name: "siteAnalyticsSettings",
type: "document",

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Consent revoke leaves trackers running
    • SiteTracking now calls unloadTrackers on consent revoke and removes keys from consentLoaded so trackers can be re-loaded on re-accept.
  • ✅ Fixed: SPA navigations skip pageviews
    • Added a pathname/searchParams effect that calls trackPageView on client-side route changes after the initial load.
  • ✅ Fixed: CMS sections never reach builder
    • parseSections now uses the exported sectionsJsonFromSanity helper to read both string and Sanity code-object section fields.
  • ✅ Fixed: PostHog init can run twice
    • tryInit now returns early when isTrackerLoaded is true, preventing duplicate posthog.init calls from overlapping retries.

Create PR

Or push these changes by commenting:

@cursor push 217275a946
Preview (217275a946)
diff --git a/web/src/components/cookies/CookieConsent.tsx b/web/src/components/cookies/CookieConsent.tsx
--- a/web/src/components/cookies/CookieConsent.tsx
+++ b/web/src/components/cookies/CookieConsent.tsx
@@ -5,7 +5,10 @@
 
 import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings";
 import type { SiteCookieBannerDocument } from "@/sanity/types/siteCookieBanner";
-import { buildCookieSections } from "@/src/components/tracking/lib/cookieConsentConfig";
+import {
+	buildCookieSections,
+	sectionsJsonFromSanity,
+} from "@/src/components/tracking/lib/cookieConsentConfig";
 import { notifyAnalyticsConsentChange } from "./cookieConsentApi";
 import { defaultSectionsFor } from "./defaultSections";
 
@@ -20,10 +23,10 @@
 	locale: string,
 	trackers: AnalyticsTracker[],
 ): CookieConsent.Section[] {
-	const raw = doc.preferencesModal?.sections;
-	if (typeof raw === "string" && raw.trim().length > 0) {
+	const sectionsJson = sectionsJsonFromSanity(doc.preferencesModal?.sections);
+	if (sectionsJson) {
 		try {
-			JSON.parse(raw);
+			JSON.parse(sectionsJson);
 			return buildCookieSections(doc, trackers);
 		} catch (err) {
 			console.warn("[CookieConsent] Failed to parse `sections` JSON:", err);

diff --git a/web/src/components/tracking/SiteTracking.tsx b/web/src/components/tracking/SiteTracking.tsx
--- a/web/src/components/tracking/SiteTracking.tsx
+++ b/web/src/components/tracking/SiteTracking.tsx
@@ -1,5 +1,6 @@
 "use client";
 
+import { usePathname, useSearchParams } from "next/navigation";
 import { useEffect, useMemo, useRef } from "react";
 
 import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings";
@@ -13,6 +14,10 @@
 	type TrackingConfig,
 	trackerRequiresConsent,
 } from "@/src/components/tracking/lib/trackingConfig";
+import {
+	trackPageView,
+	unloadTrackers,
+} from "@/src/components/tracking/lib/trackingRuntime";
 
 type Props = {
 	config: TrackingConfig;
@@ -36,6 +41,9 @@
 export function SiteTracking({ config }: Props) {
 	const initialized = useRef(false);
 	const consentLoaded = useRef(new Set<string>());
+	const skipNextPageView = useRef(true);
+	const pathname = usePathname();
+	const searchParams = useSearchParams();
 
 	const trackers = useMemo(
 		() => getEnabledTrackers(config.analytics),
@@ -50,8 +58,22 @@
 		if (initialized.current || trackers.length === 0) return;
 		initialized.current = true;
 
-		const loadAllowed = (analyticsAccepted: boolean) => {
+		const syncTrackers = (analyticsAccepted: boolean) => {
 			const allowed = trackersForConsent(trackers, config, analyticsAccepted);
+			const allowedKeys = new Set(allowed.map((tracker) => tracker._key));
+
+			const toUnload = trackers.filter(
+				(tracker) =>
+					consentLoaded.current.has(tracker._key) &&
+					!allowedKeys.has(tracker._key),
+			);
+			if (toUnload.length > 0) {
+				unloadTrackers(toUnload);
+				for (const tracker of toUnload) {
+					consentLoaded.current.delete(tracker._key);
+				}
+			}
+
 			const pending = allowed.filter(
 				(tracker) => !consentLoaded.current.has(tracker._key),
 			);
@@ -63,12 +85,21 @@
 		};
 
 		if (showBanner) {
-			loadAllowed(hasConsent("analytics"));
-			return subscribeAnalyticsConsent(loadAllowed);
+			syncTrackers(hasConsent("analytics"));
+			return subscribeAnalyticsConsent(syncTrackers);
 		}
 
-		loadAllowed(true);
+		syncTrackers(true);
 	}, [config, showBanner, trackers]);
 
+	useEffect(() => {
+		if (skipNextPageView.current) {
+			skipNextPageView.current = false;
+			return;
+		}
+		const search = searchParams.toString();
+		trackPageView(pathname, search ? `?${search}` : "");
+	}, [pathname, searchParams]);
+
 	return null;
 }

diff --git a/web/src/components/tracking/lib/cookieConsentConfig.ts b/web/src/components/tracking/lib/cookieConsentConfig.ts
--- a/web/src/components/tracking/lib/cookieConsentConfig.ts
+++ b/web/src/components/tracking/lib/cookieConsentConfig.ts
@@ -9,7 +9,7 @@
 const DEFAULT_CONSENT_DESCRIPTION =
 	"Our website uses essential cookies for basic operation. Cookie-based analytics run only after you accept them; privacy-friendly analytics without cookies may load earlier.";
 
-function sectionsJsonFromSanity(
+export function sectionsJsonFromSanity(
 	sections: string | { code?: string | null } | null | undefined,
 ): string | null {
 	if (typeof sections === "string") return sections;

diff --git a/web/src/components/tracking/lib/loadTrackers.ts b/web/src/components/tracking/lib/loadTrackers.ts
--- a/web/src/components/tracking/lib/loadTrackers.ts
+++ b/web/src/components/tracking/lib/loadTrackers.ts
@@ -97,6 +97,7 @@
 
 	let attempts = 0;
 	const tryInit = () => {
+		if (isTrackerLoaded(tracker._key)) return;
 		attempts += 1;
 		const posthog = (
 			window as Window & {

You can send follow-ups to the cloud agent here.

Comment thread web/src/components/tracking/SiteTracking.tsx Outdated
Comment thread web/src/components/tracking/SiteTracking.tsx
Comment thread web/src/components/cookies/CookieConsent.tsx
Comment thread web/src/components/tracking/lib/loadTrackers.ts
arnespremberg and others added 2 commits August 6, 2026 16:43
`studio/schema.json` and `studio/sanity.types.gen.ts` are tracked and CI
gates them with `git diff --exit-code` after running typegen. The previous
commit added the analytics document and five tracker objects without
regenerating either, so the schema-typegen job failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An editor choosing providers in the Studio cannot see which of them sends
personal data before the visitor answers the banner, which needs an
agreement signed elsewhere, or which one records what visitors type. Put
that at the point of the decision instead of in a document nobody opens.

Field-level notes:

- Each tracker gains a type description covering its specific risk —
  Clarity's session recordings and the DPIA question, PostHog's replay
  being configured outside the CMS, GA's US transfer, Matomo being
  self-hosted and lowest-risk, Plausible's server-side IP hashing.
- "Cookie-free" is explained as not meaning consent-free: it removes the
  ePrivacy cookie trigger, not the GDPR basis for the data still sent.
- GA's cookie-free note records that `anonymize_ip` is a no-op in GA4
  (a Universal Analytics setting) so the flag is not read as a safeguard.
- Clarity's cookie-free toggle explains why it is locked off, and its
  `enabled` field documents that consent withdrawal does not tear down
  the already-loaded script until the visitor reloads.
- The generated cookie table's limits are noted on the sections field:
  provider domains rather than the real cookie host, and no durations.

Warnings where the unsafe state is invisible from one document:

- Selecting "Load on page load" with trackers enabled.
- Enabling trackers while the cookie banner is off, and the same warning
  from the banner side — the two halves live in separate singletons, so
  either document alone looks fine.
- A consent description with no link to a privacy policy.

The reject-button labels become required: vanilla-cookieconsent only
renders that button when the label is non-empty, so clearing the field
silently leaves Accept as the only option.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 6, 2026 14:44
Companion to the studio typegen fix: `web/sanity/sanity.types.gen.ts` is
tracked and gated the same way, and `siteAnalyticsSettingsQuery` was added
without regenerating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated 2 comments.

Suppressed comments (3)

web/src/components/tracking/lib/cookieConsentConfig.ts:86

  • Tracker rows are appended only when the editor-provided JSON already contains an analytics section. Because sections is freely editable, removing or renaming that section means enabled providers are not disclosed and no analytics category is created for users to accept, so cookie-based trackers can never load. Add the fallback analytics section whenever trackers exist and the configured sections omit it.
	return sections.map((section) => {
		const sectionId = (section as Section & { id?: string }).id;
		if (section.linkedCategory !== "analytics" && sectionId !== "analytics") {
			return section;

web/src/components/tracking/lib/trackingRuntime.ts:21

  • This is the only definition of trackPageView; it is never invoked, so Next.js client-side route changes do not emit pageviews for these providers despite the PR's SPA-navigation claim. Wire it to pathname/search changes in the client tracking component, taking care not to duplicate the initial pageview already emitted by provider initialization.
    web/sanity/queries/snippets/settings.ts:134
  • cookieBannerLabel and cookieBannerDescription are visitor-facing copy, but they are projected as scalar strings for every provider. Every locale route therefore displays the same disclosure, unlike localized visitor-facing labels such as studio/schemas/objects/link.ts:35. Model and project these fields as internationalized arrays, then resolve them with the active locale and site fallback before building the cookie rows.
    cookieBannerLabel,
    cookieBannerDescription,

Comment thread web/src/components/tracking/SiteTracking.tsx Outdated
Comment on lines +65 to +67
if (showBanner) {
loadAllowed(hasConsent("analytics"));
return subscribeAnalyticsConsent(loadAllowed);
Copilot AI review requested due to automatic review settings August 6, 2026 14:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated no new comments.

Suppressed comments (5)

web/src/components/tracking/SiteTracking.tsx:54

  • Revoking analytics consent only calls this loader with false; it never unloads trackers that were loaded after a previous acceptance. Those providers therefore continue collecting after withdrawal, and the new unloadTrackers path is never called anywhere. On rejection, unload every consent-required tracker, clear its loaded key so re-consent can work, and cancel any pending asynchronous initialization.
		const loadAllowed = (analyticsAccepted: boolean) => {
			const allowed = trackersForConsent(trackers, config, analyticsAccepted);

web/src/components/tracking/SiteTracking.tsx:50

  • This effect only reacts to configuration, so it cannot report App Router navigations. trackPageView has no caller anywhere in the repository, which contradicts the PR's claim that SPA navigations are tracked and leaves at least Matomo client-side route changes unreported. Add a pathname/search-aware effect that invokes the runtime after navigation without duplicating the providers' initial page view.
	useEffect(() => {
		if (initialized.current || trackers.length === 0) return;

web/src/components/cookies/CookieConsent.tsx:24

  • Sanity code fields arrive as a Code object containing code (as shown by the generated query type), not as a raw string. This check therefore rejects every CMS-authored sections value and replaces it with defaults, even though buildCookieSections already knows how to unwrap the object. Update the hand-maintained banner type and validate the nested code string so configured sections are retained.
	const raw = doc.preferencesModal?.sections;
	if (typeof raw === "string" && raw.trim().length > 0) {

web/src/components/tracking/lib/loadTrackers.ts:100

  • tryInit runs both from the polling timer and the script's load event. In the normal asynchronous-load path, the load handler initializes PostHog, then the already-scheduled timer initializes it again, potentially installing duplicate capture handlers. Stop immediately once this tracker has been registered.
    web/src/components/tracking/lib/cookieConsentConfig.ts:37
  • These CMS-authored labels and descriptions are rendered directly as scalar strings, so every locale receives the same provider disclosure. Visitor-facing Sanity content elsewhere uses localized arrays and render-time resolution (for example, studio/schemas/settings/error.ts:21 and web/src/components/modules/ModuleCarousel.tsx:40). Project localized values and resolve them with the current locale/site-locale configuration before building rows.
function trackerCookieRow(tracker: AnalyticsTracker): CookieTableRow {
	const label = tracker.cookieBannerLabel?.trim() || tracker._type;
	const baseDescription =
		tracker.cookieBannerDescription?.trim() ||
		"Analytics provider configured in Sanity.";

arnespremberg and others added 2 commits August 6, 2026 17:02
…tHog

Consent withdrawal did not stop collection. The consent callback only ever
computed which trackers to *add*, so revoking analytics left GA, Matomo,
Clarity, and PostHog running until a reload — consent has to be as easy to
withdraw as to give. `syncConsent` now unloads anything running that consent
no longer covers and clears its key so a later grant reloads it.

Dropped the `initialized` one-shot ref. The effect owns the consent
subscription, so returning early on re-run (Strict Mode remount, or any
`config` change) unsubscribed permanently and consent never reached the
loaders again. `consentLoaded` already makes loads idempotent.

`trackPageView` was dead code — nothing observed route changes, so App
Router navigations emitted no pageviews. Wired to `pathname`, skipping the
first run because providers emit their own view on init.

PostHog: the SDK is served from PostHog Cloud's assets host, not the ingest
host, so the default EU config never loaded `/static/array.js`. Rewrites the
two known cloud hosts and leaves self-hosted URLs alone. Also guards
`tryInit` with the runtime registry — it ran immediately, on script load,
and on a retry timer, so a pending timer could re-init after the load
handler had already succeeded.

Cookie sections: the query returned `preferencesModal` whole, so `sections`
arrived as the Studio `code` object while the type claimed `string` — every
CMS-authored section list fell through to the locale defaults. Projects
`sections.code` and normalises in `parseSections` as well. Separately, rows
were only appended when the authored JSON already contained an analytics
section; if an editor renamed or removed it there was no disclosure *and* no
`analytics` category to grant, so gated trackers could never load. One is
now synthesised when trackers exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first pass read like privacy consulting. Editors need the risk named,
not the reasoning: cut the how and why, keep what they cannot see from the
CMS — what loads before consent, what leaves the EEA, what needs signing
elsewhere. Validation messages shortened to one clause each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 6, 2026 15:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated 3 comments.

Suppressed comments (4)

web/src/components/tracking/SiteTracking.tsx:106

  • This effect misses client-side navigations that change only the query string because usePathname() remains unchanged. Although the call reads window.location.search, the effect never reruns, so those SPA page views are not forwarded. Subscribe to useSearchParams() (or an equivalent route-change signal) and include the serialized search value in the dependency list.
		trackPageView(pathname, window.location.search);
	}, [pathname]);

web/src/components/tracking/SiteTracking.tsx:73

  • Deleting only the local key allows the loader to run again after re-consent, but provider opt-out state remains. In particular, Google is left with ga-disable-<id> = true and Matomo is left opted out, while neither loader clears those states, so accepting analytics again does not resume collection. Add provider-specific re-enable logic (ga-disable reset, Matomo forgetUserOptOut, and corresponding opt-in handling for other providers) before marking them loaded again.
			if (revoked.length > 0) {
				unloadTrackers(revoked);
				for (const tracker of revoked) {
					consentLoaded.current.delete(tracker._key);
				}

studio/schemas/settings/siteAnalyticsSettings.ts:100

  • The schema says “one entry per provider,” but this array permits duplicate _type values. Several loaders share one provider-global (_paq, window.posthog, window.clarity), so duplicate entries can overwrite configuration or double-count page views. Reject duplicate provider types in array validation.
      of: [
        defineArrayMember({ type: "trackerGoogleAnalytics" }),
        defineArrayMember({ type: "trackerMatomo" }),
        defineArrayMember({ type: "trackerMicrosoftClarity" }),
        defineArrayMember({ type: "trackerPostHog" }),
        defineArrayMember({ type: "trackerPlausible" }),

web/sanity/types/siteAnalyticsSettings.ts:16

  • These values are rendered in the locale-specific cookie preferences UI, but the shared contract stores only one plain string, so every locale receives identical provider copy. Model the fields with the repository's internationalized-array types, project the full arrays, and resolve them with the locale/site-locale utilities before building tracker rows.
	cookieBannerLabel?: string | null;
	cookieBannerDescription?: string | null;

Comment on lines +51 to +53
const showBanner =
config.cookieBanner?.useCookieBanner === true &&
config.analytics?.loadMode === "respectCookieBanner";
Comment on lines +136 to +137
if (attempts < POSTHOG_INIT_MAX_ATTEMPTS) {
window.setTimeout(tryInit, 50);
Comment on lines +101 to +105
if (clarity) {
clarity.q = [];
(
window as Window & { clarity?: (...args: unknown[]) => void }
).clarity = () => {};

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Consent re-grant fails after unload
    • Added reEnableTracker to clear GA/Matomo/PostHog opt-out flags and updated loaders to call it when the SDK is already present instead of appending duplicate scripts.
  • ✅ Fixed: PostHog can reload after revoke
    • Unload now bumps a per-tracker load generation that in-flight PostHog tryInit callbacks check before initializing, aborting stale retries after consent revocation.

Create PR

Or push these changes by commenting:

@cursor push 4ae368c5cd
Preview (4ae368c5cd)
diff --git a/web/src/components/tracking/lib/loadTrackers.ts b/web/src/components/tracking/lib/loadTrackers.ts
--- a/web/src/components/tracking/lib/loadTrackers.ts
+++ b/web/src/components/tracking/lib/loadTrackers.ts
@@ -1,6 +1,8 @@
 import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings";
 import {
+	getTrackerLoadGeneration,
 	isTrackerLoaded,
+	reEnableTracker,
 	registerLoadedTracker,
 } from "@/src/components/tracking/lib/trackingRuntime";
 
@@ -25,6 +27,11 @@
 	const id = tracker.measurementId?.trim();
 	if (!id || isTrackerLoaded(tracker._key)) return;
 
+	if ((window as Window & { gtag?: (...args: unknown[]) => void }).gtag) {
+		reEnableTracker(tracker);
+		return;
+	}
+
 	appendScript(
 		`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,
 	);
@@ -47,6 +54,11 @@
 	const siteId = tracker.siteId?.trim();
 	if (!baseUrl || !siteId || isTrackerLoaded(tracker._key)) return;
 
+	if ((window as Window & { _paq?: unknown[][] })._paq) {
+		reEnableTracker(tracker);
+		return;
+	}
+
 	const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
 	const cookieFree = tracker.cookieFree === true;
 
@@ -106,11 +118,23 @@
 	const apiHost = tracker.apiHost?.trim();
 	if (!apiKey || !apiHost || isTrackerLoaded(tracker._key)) return;
 
+	const existingPostHog = (
+		window as Window & {
+			posthog?: { init?: (...args: unknown[]) => void };
+		}
+	).posthog;
+	if (existingPostHog?.init) {
+		reEnableTracker(tracker);
+		return;
+	}
+
 	const cookieFree = tracker.cookieFree === true;
+	const generation = getTrackerLoadGeneration(tracker._key);
 	const script = appendScript(`${posthogAssetHost(apiHost)}/static/array.js`);
 
 	let attempts = 0;
 	const tryInit = () => {
+		if (getTrackerLoadGeneration(tracker._key) !== generation) return;
 		// tryInit runs immediately, on script load, and on a retry timer — without
 		// this the pending timer re-initialises after the load handler succeeded.
 		if (isTrackerLoaded(tracker._key)) return;
@@ -151,6 +175,13 @@
 	const scriptUrl = tracker.scriptUrl?.trim();
 	if (!domain || !scriptUrl || isTrackerLoaded(tracker._key)) return;
 
+	if (
+		(window as Window & { plausible?: (...args: unknown[]) => void }).plausible
+	) {
+		reEnableTracker(tracker);
+		return;
+	}
+
 	appendScript(scriptUrl, {
 		defer: "",
 		"data-domain": domain,

diff --git a/web/src/components/tracking/lib/trackingRuntime.ts b/web/src/components/tracking/lib/trackingRuntime.ts
--- a/web/src/components/tracking/lib/trackingRuntime.ts
+++ b/web/src/components/tracking/lib/trackingRuntime.ts
@@ -5,11 +5,20 @@
 };
 
 const loadedTrackers = new Map<string, LoadedTracker>();
+const trackerLoadGenerations = new Map<string, number>();
 
 export function isTrackerLoaded(key: string): boolean {
 	return loadedTrackers.has(key);
 }
 
+export function getTrackerLoadGeneration(key: string): number {
+	return trackerLoadGenerations.get(key) ?? 0;
+}
+
+export function invalidateTrackerLoad(key: string): void {
+	trackerLoadGenerations.set(key, (trackerLoadGenerations.get(key) ?? 0) + 1);
+}
+
 export function registerLoadedTracker(tracker: AnalyticsTracker): void {
 	loadedTrackers.set(tracker._key, { tracker });
 }
@@ -71,7 +80,44 @@
 	}
 }
 
+export function reEnableTracker(tracker: AnalyticsTracker): void {
+	switch (tracker._type) {
+		case "trackerGoogleAnalytics": {
+			const id = tracker.measurementId?.trim();
+			if (id) {
+				(window as unknown as Record<string, boolean | undefined>)[
+					`ga-disable-${id}`
+				] = false;
+			}
+			break;
+		}
+		case "trackerMatomo": {
+			const _paq = (
+				window as Window & {
+					_paq?: unknown[][];
+				}
+			)._paq;
+			_paq?.push(["forgetUserOptOut"]);
+			break;
+		}
+		case "trackerPostHog": {
+			const posthog = (
+				window as Window & {
+					posthog?: { opt_in_capturing?: () => void };
+				}
+			).posthog;
+			posthog?.opt_in_capturing?.();
+			break;
+		}
+		default:
+			break;
+	}
+
+	registerLoadedTracker(tracker);
+}
+
 export function unloadTracker(tracker: AnalyticsTracker): void {
+	invalidateTrackerLoad(tracker._key);
 	switch (tracker._type) {
 		case "trackerGoogleAnalytics": {
 			const id = tracker.measurementId?.trim();

You can send follow-ups to the cloud agent here.

Comment thread web/src/components/tracking/SiteTracking.tsx
Comment thread web/src/components/tracking/lib/loadTrackers.ts
Four fail-open paths, all of which loaded or kept running a tracker the
visitor had not agreed to:

- Clarity could load before consent. `isCookieFreeTracker` trusted the
  stored `cookieFree` flag, and Studio `readOnly` is a UI affordance rather
  than a data invariant — an imported or API-written document could set it
  true. Clarity is now hard-coded as never cookie-free.
- A document missing `loadMode` failed open. `showBanner` compared for
  equality with "respectCookieBanner" while `shouldRespectCookieBanner`
  treats everything except "onPageLoad" as gated, so legacy or API-written
  content skipped the banner and loaded cookie trackers. Both now derive
  from the same helper.
- Withdrawal during PostHog's async init did nothing. `unloadTracker` found
  no instance yet, then the pending timer initialised it anyway. Loads now
  carry a generation that `unregisterLoadedTracker` invalidates.
- Re-granting consent produced a dead tracker. `ga-disable-<id>`,
  `optUserOut`, and `opt_out_capturing` all outlive the script, so a
  reloaded provider stayed silently opted out. Each loader now clears its
  own opt-out.

Also: withdrawal deletes the first-party cookies each provider leaves behind
(GA `_ga*`/`_gac_*`, Clarity `_clck`/`_clsk`, PostHog `ph_*`), and revoking
Clarity reloads the page — its recorder holds its own listeners, so
replacing the global never stopped collection, only new API calls. No loop
risk: consent is already withdrawn when the page comes back.

Schema copy notes the CSP entry self-hosted PostHog and Plausible need, and
Clarity's field now describes the reload instead of the old limitation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 6, 2026 15:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated 2 comments.

Suppressed comments (1)

web/src/components/tracking/SiteTracking.tsx:112

  • Search-only App Router navigations are not tracked because this effect depends only on pathname. For example, navigating from /search?q=a to /search?q=b leaves pathname unchanged, so no provider receives the new page view. Subscribe with useSearchParams() and include the serialized search string in the effect dependencies.
		trackPageView(pathname, window.location.search);
	}, [pathname]);

const showBanner = bannerGatesLoading(config);

useEffect(() => {
if (trackers.length === 0) return;
Comment on lines +104 to +105
const { hostname } = window.location;
const scopes = ["", `; domain=${hostname}`, `; domain=.${hostname}`];

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Cookie deletion skips parent domains
    • Added a cookieDomainScopes helper that deletes cookies across the host and all plausible parent domain suffixes, not just the current hostname.
  • ✅ Fixed: Re-grant reinjects tracker scripts
    • GA, Matomo, and PostHog loaders now detect existing vendor globals after unload and only clear opt-out state instead of re-appending scripts.

Create PR

Or push these changes by commenting:

@cursor push 0781ba1a3a
Preview (0781ba1a3a)
diff --git a/web/src/components/tracking/lib/loadTrackers.ts b/web/src/components/tracking/lib/loadTrackers.ts
--- a/web/src/components/tracking/lib/loadTrackers.ts
+++ b/web/src/components/tracking/lib/loadTrackers.ts
@@ -33,6 +33,16 @@
 		`ga-disable-${id}`
 	] = false;
 
+	const gtag = (
+		window as Window & {
+			gtag?: (...args: unknown[]) => void;
+		}
+	).gtag;
+	if (gtag) {
+		registerLoadedTracker(tracker);
+		return;
+	}
+
 	appendScript(
 		`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,
 	);
@@ -58,6 +68,18 @@
 	const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
 	const cookieFree = tracker.cookieFree === true;
 
+	const _paq = (
+		window as Window & {
+			_paq?: unknown[][];
+		}
+	)._paq;
+	if (_paq) {
+		_paq.push(["forgetUserOptOut"]);
+		if (cookieFree) _paq.push(["disableCookies"]);
+		registerLoadedTracker(tracker);
+		return;
+	}
+
 	const inline = document.createElement("script");
 	inline.textContent = `
 var _paq = window._paq = window._paq || [];
@@ -116,6 +138,21 @@
 	if (!apiKey || !apiHost || isTrackerLoaded(tracker._key)) return;
 
 	const cookieFree = tracker.cookieFree === true;
+
+	const existingPostHog = (
+		window as Window & {
+			posthog?: {
+				capture?: (event: string) => void;
+				opt_in_capturing?: () => void;
+			};
+		}
+	).posthog;
+	if (existingPostHog?.capture) {
+		existingPostHog.opt_in_capturing?.();
+		registerLoadedTracker(tracker);
+		return;
+	}
+
 	const generation = beginTrackerLoad(tracker._key);
 	const script = appendScript(`${posthogAssetHost(apiHost)}/static/array.js`);
 

diff --git a/web/src/components/tracking/lib/trackingRuntime.ts b/web/src/components/tracking/lib/trackingRuntime.ts
--- a/web/src/components/tracking/lib/trackingRuntime.ts
+++ b/web/src/components/tracking/lib/trackingRuntime.ts
@@ -95,14 +95,31 @@
 	}
 }
 
+function cookieDomainScopes(hostname: string): string[] {
+	const scopes = new Set(["", `; domain=${hostname}`, `; domain=.${hostname}`]);
+	if (
+		hostname !== "localhost" &&
+		!hostname.includes(":") &&
+		!/^\d+\.\d+\.\d+\.\d+$/.test(hostname)
+	) {
+		const parts = hostname.split(".");
+		for (let index = 1; index < parts.length; index += 1) {
+			const parent = parts.slice(index).join(".");
+			if (!parent.includes(".")) continue;
+			scopes.add(`; domain=${parent}`);
+			scopes.add(`; domain=.${parent}`);
+		}
+	}
+	return [...scopes];
+}
+
 /**
  * Best-effort cookie removal. The same name can exist on both the host and the
  * dot-prefixed domain, and we cannot know which the provider used, so clear
  * every plausible scope.
  */
 function deleteCookies(matches: (name: string) => boolean): void {
-	const { hostname } = window.location;
-	const scopes = ["", `; domain=${hostname}`, `; domain=.${hostname}`];
+	const scopes = cookieDomainScopes(window.location.hostname);
 	for (const entry of document.cookie.split(";")) {
 		const name = entry.split("=")[0]?.trim();
 		if (!name || !matches(name)) continue;

You can send follow-ups to the cloud agent here.

Comment thread web/src/components/tracking/lib/trackingRuntime.ts
Comment thread web/src/components/tracking/lib/loadTrackers.ts Outdated
The schema seeded "Tracking is only activated after consent", but cookie-free
providers load before the visitor answers — and the code's own
DEFAULT_CONSENT_DESCRIPTION already said so. A banner that overstates the
gating is worse than no banner, so the two now match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 6, 2026 15:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated 2 comments.

Suppressed comments (4)

web/src/components/tracking/lib/trackingRuntime.ts:105

  • On a subdomain such as www.example.com, GA commonly writes cookies for .example.com. These scopes only target www.example.com, so consent withdrawal leaves the parent-domain GA, Clarity, and PostHog cookies intact. Generate deletion scopes for each parent domain as well.
    studio/schemas/settings/siteAnalyticsSettings.ts:100
  • The array permits multiple entries with the same _type, despite the field contract saying one entry per provider. Because loading is keyed by each Sanity _key, duplicate GA/Matomo/etc. entries inject the provider more than once and can double-count traffic. Add array validation that rejects duplicate provider _type values (and handle any existing duplicates).
      of: [
        defineArrayMember({ type: "trackerGoogleAnalytics" }),
        defineArrayMember({ type: "trackerMatomo" }),
        defineArrayMember({ type: "trackerMicrosoftClarity" }),
        defineArrayMember({ type: "trackerPostHog" }),
        defineArrayMember({ type: "trackerPlausible" }),
      ],

web/sanity/queries/snippets/settings.ts:140

  • These labels and descriptions are visitor-facing content, but they are projected as scalar strings and rendered unchanged for every locale. The repository's localized settings pattern uses internationalizedArrayString (for example, studio/schemas/settings/error.ts:21,35) and resolves at render time. Store/project the full localized arrays and resolve them with the requested locale and site locale before building the cookie rows.
    cookieBannerLabel,
    cookieBannerDescription,

web/src/components/tracking/lib/trackingRuntime.ts:89

  • Plausible's manual pageview override expects a complete page URL; this relative path is sent as its u value on SPA navigation and may be rejected or attributed incorrectly. Resolve it against the current origin before dispatching.

Comment on lines +107 to +109
const withRows = sections.map((section) =>
isAnalyticsSection(section) ? withTrackerRows(section, trackers) : section,
);
Comment on lines +15 to +32
export function isTrackerEnabled(tracker: AnalyticsTracker): boolean {
if (tracker.enabled === false) return false;

switch (tracker._type) {
case "trackerGoogleAnalytics":
return Boolean(tracker.measurementId?.trim());
case "trackerMatomo":
return Boolean(tracker.url?.trim() && tracker.siteId?.trim());
case "trackerMicrosoftClarity":
return Boolean(tracker.projectId?.trim());
case "trackerPostHog":
return Boolean(tracker.apiKey?.trim() && tracker.apiHost?.trim());
case "trackerPlausible":
return Boolean(tracker.domain?.trim() && tracker.scriptUrl?.trim());
default:
return false;
}
}
…truction

Round three of review. Five issues, all reachable from ordinary CMS edits:

- Disabling or deleting a tracker in Sanity left it collecting. The effect
  compared consent against the *current* document, so a tracker that had
  vanished from it was never considered for teardown — and an empty list
  returned before doing anything at all. Now reconciles against what is
  running, and compares the value too, so editing a tracker in place (same
  `_key`) reloads it instead of silently keeping the old config.
- Cookie deletion missed the domain that actually set the cookie. On
  `www.example.com` GA writes `_ga` for `.example.com`, which was never in
  the attempted scopes, so it survived withdrawal. Walks the hostname
  suffixes down to two labels.
- Re-granting consent stacked duplicate script tags: unload cleared the
  registry but left the DOM alone. Injected tags are tracked per key and
  removed on unload; Matomo's and Clarity's snippets no longer inject their
  own script so those are tracked too.
- A malformed or relative URL in the CMS threw from `new URL` while building
  the banner, and the throw happens before `CookieConsent.run` returns a
  promise — so the catch never fired and the banner never rendered, leaving
  no way to give or withdraw consent. Hostname extraction now falls back.
- A section authored with `id: "analytics"` but no `linkedCategory` matched
  as the analytics section, skipping the synthesised fallback, while
  `buildConfig` derives categories from `linkedCategory` alone — so there
  was no category to grant. Matched sections get the link applied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 6, 2026 16:15

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Label edits force Clarity reload
    • Stale tracker detection now compares only runtime injection fields via trackerRuntimeConfigEqual, so cookie banner label/description edits no longer trigger Clarity reloads.

Create PR

Or push these changes by commenting:

@cursor push 8e798c1a71
Preview (8e798c1a71)
diff --git a/web/src/components/tracking/SiteTracking.tsx b/web/src/components/tracking/SiteTracking.tsx
--- a/web/src/components/tracking/SiteTracking.tsx
+++ b/web/src/components/tracking/SiteTracking.tsx
@@ -14,6 +14,7 @@
 	getEnabledTrackers,
 	type TrackingConfig,
 	trackerRequiresConsent,
+	trackerRuntimeConfigEqual,
 } from "@/src/components/tracking/lib/trackingConfig";
 import {
 	trackPageView,
@@ -64,7 +65,7 @@
 			// edited in place keeps its `_key`, so compare the value too.
 			const stale = Array.from(loaded.current.values()).filter((tracker) => {
 				const next = allowedByKey.get(tracker._key);
-				return !next || JSON.stringify(next) !== JSON.stringify(tracker);
+				return !next || !trackerRuntimeConfigEqual(next, tracker);
 			});
 			if (stale.length > 0) {
 				const needsReload = unloadTrackers(stale);

diff --git a/web/src/components/tracking/lib/trackingConfig.ts b/web/src/components/tracking/lib/trackingConfig.ts
--- a/web/src/components/tracking/lib/trackingConfig.ts
+++ b/web/src/components/tracking/lib/trackingConfig.ts
@@ -95,3 +95,28 @@
 	if (!shouldRespectCookieBanner(loadMode, cookieBanner)) return false;
 	return !isCookieFreeTracker(tracker);
 }
+
+/** Whether two tracker configs would load the same runtime integration. */
+export function trackerRuntimeConfigEqual(
+	a: AnalyticsTracker,
+	b: AnalyticsTracker,
+): boolean {
+	return trackerRuntimeFingerprint(a) === trackerRuntimeFingerprint(b);
+}
+
+function trackerRuntimeFingerprint(tracker: AnalyticsTracker): string {
+	const cookieFree = tracker.cookieFree === true;
+
+	switch (tracker._type) {
+		case "trackerGoogleAnalytics":
+			return `${tracker._type}|${cookieFree}|${tracker.measurementId?.trim() ?? ""}`;
+		case "trackerMatomo":
+			return `${tracker._type}|${cookieFree}|${tracker.url?.trim() ?? ""}|${tracker.siteId?.trim() ?? ""}`;
+		case "trackerMicrosoftClarity":
+			return `${tracker._type}|${cookieFree}|${tracker.projectId?.trim() ?? ""}`;
+		case "trackerPostHog":
+			return `${tracker._type}|${cookieFree}|${tracker.apiKey?.trim() ?? ""}|${tracker.apiHost?.trim() ?? ""}`;
+		case "trackerPlausible":
+			return `${tracker._type}|${cookieFree}|${tracker.domain?.trim() ?? ""}|${tracker.scriptUrl?.trim() ?? ""}`;
+	}
+}

You can send follow-ups to the cloud agent here.

Comment thread web/src/components/tracking/SiteTracking.tsx

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated 1 comment.

Suppressed comments (4)

web/src/components/tracking/lib/trackingRuntime.ts:1

  • Plausible's SPA pageview API (plausible('pageview', { u: ... })) is documented to take a full URL. Here url is only pathname + search (e.g. /foo?bar), which can lead to incorrect/ambiguous reporting. Prefer passing an absolute URL (e.g. window.location.origin + url or window.location.href) for Plausible.
    web/src/components/tracking/lib/loadTrackers.ts:1
  • appendScript always sets script.async = true, but some loaders also set defer via attributes (e.g. Plausible). In HTML, having both async and defer means async takes precedence, so the intended defer behavior is not achieved. Consider letting callers control async/defer explicitly (e.g. an options parameter) or automatically disabling async when a defer attribute is requested.
    web/src/app/[locale]/layout.tsx:60
  • analytics (and cookieBanner) are already fetched in the layout, but <SiteTrackingShell /> fetches them again, causing redundant server requests/work per render. Consider passing the already-fetched analytics/cookieBanner into SiteTrackingShell (or directly rendering the client <SiteTracking /> with the config) to avoid duplicate fetching.
	const [siteLocale, siteNav, cookieBanner, siteBrand, analytics] =
		await Promise.all([
			fetchSiteLanguageSettings(),
			fetchSiteNavMenus(),
			fetchSiteCookieBanner(),
			fetchSiteSettingsTitle(),
			fetchSiteAnalyticsSettings(),
		]);

web/src/app/[locale]/layout.tsx:96

  • analytics (and cookieBanner) are already fetched in the layout, but <SiteTrackingShell /> fetches them again, causing redundant server requests/work per render. Consider passing the already-fetched analytics/cookieBanner into SiteTrackingShell (or directly rendering the client <SiteTracking /> with the config) to avoid duplicate fetching.
			<SiteTrackingShell />

Comment on lines +37 to +46
return buildCookieSections(
{
...doc,
preferencesModal: {
...doc.preferencesModal,
sections: JSON.stringify(defaultSectionsFor(locale)),
},
},
trackers,
);
…outs

Round four. Two of these are regressions from my own earlier fixes:

- Stored XSS. The consent description is assigned with `innerHTML` by
  vanilla-cookieconsent, and the schema help text I added actively told
  editors to put an `<a>` tag in it — with `unsafe-inline` still in the CSP
  that is script execution for anyone with Studio write access. The
  description is now escaped and rendered as text; the policy link comes from
  dedicated `privacyPolicyUrl`/`privacyPolicyLabel` fields and is built in
  trusted code behind an http(s)-or-relative protocol allowlist.
- Overriding the visitor's own opt-out. `forgetUserOptOut` and
  `opt_in_capturing` ran on every load, not only after a banner-driven
  re-grant, so someone who had opted out through Matomo's or PostHog's own
  mechanism was quietly re-enabled. Withdrawal now records that the opt-out
  was ours, and only that is reversed.
- A copy edit forced a page reload. Staleness compared whole documents, so
  editing a display-only field like `cookieBannerLabel` counted as a config
  change — and for Clarity a teardown means `location.reload()`. Compares a
  load signature of the fields that actually affect loading.
- The `open-cookie-preferences` link can sit in navigation while the banner
  is disabled, where `showPreferences()` has nothing to show. Guarded.

Not changed, for the record: Bugbot re-reported the parent-domain cookie
scopes, script re-injection, and re-grant opt-out as open on the commit that
fixed them. Copilot's `...doc.preferencesModal` warning is also wrong —
object spread of null yields `{}`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Opt-out reverse lost on reload
    • Persisted the self-opt-out marker in sessionStorage so it survives full page reloads and consumeSelfOptOut can still reverse Matomo/PostHog opt-outs when consent is re-granted.

Create PR

Or push these changes by commenting:

@cursor push 62e16f36da
Preview (62e16f36da)
diff --git a/web/src/components/tracking/lib/trackingRuntime.ts b/web/src/components/tracking/lib/trackingRuntime.ts
--- a/web/src/components/tracking/lib/trackingRuntime.ts
+++ b/web/src/components/tracking/lib/trackingRuntime.ts
@@ -26,16 +26,51 @@
  * Matomo's and PostHog's opt-outs persist in storage and can also be set by the
  * visitor through the provider's own mechanism. Clearing them unconditionally on
  * load would silently re-enable tracking for someone who opted out elsewhere.
+ *
+ * Persisted in sessionStorage so a Clarity-driven reload (or any full reload
+ * before consent is re-granted) does not lose the marker while the provider
+ * opt-out remains in cookies/storage.
  */
-const selfOptedOut = new Set<string>();
+const SELF_OPTED_OUT_STORAGE_KEY = "tracking:self-opted-out";
 
+function readPersistedSelfOptedOut(): Set<string> {
+	if (typeof sessionStorage === "undefined") return new Set();
+	try {
+		const raw = sessionStorage.getItem(SELF_OPTED_OUT_STORAGE_KEY);
+		if (!raw) return new Set();
+		const parsed: unknown = JSON.parse(raw);
+		return new Set(
+			Array.isArray(parsed)
+				? parsed.filter((key): key is string => typeof key === "string")
+				: [],
+		);
+	} catch {
+		return new Set();
+	}
+}
+
+function writePersistedSelfOptedOut(keys: Set<string>): void {
+	if (typeof sessionStorage === "undefined") return;
+	if (keys.size === 0) sessionStorage.removeItem(SELF_OPTED_OUT_STORAGE_KEY);
+	else
+		sessionStorage.setItem(
+			SELF_OPTED_OUT_STORAGE_KEY,
+			JSON.stringify([...keys]),
+		);
+}
+
 export function markSelfOptedOut(key: string): void {
-	selfOptedOut.add(key);
+	const keys = readPersistedSelfOptedOut();
+	keys.add(key);
+	writePersistedSelfOptedOut(keys);
 }
 
 /** True once, if this integration was what opted the visitor out. */
 export function consumeSelfOptOut(key: string): boolean {
-	return selfOptedOut.delete(key);
+	const keys = readPersistedSelfOptedOut();
+	if (!keys.delete(key)) return false;
+	writePersistedSelfOptedOut(keys);
+	return true;
 }
 
 export function isTrackerLoaded(key: string): boolean {

You can send follow-ups to the cloud agent here.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d4887bf. Configure here.

Comment thread web/src/components/tracking/lib/trackingRuntime.ts
Regression from the previous commit. Gating `forgetUserOptOut` and
`opt_in_capturing` on "did we opt this visitor out" was right, but the marker
lived in a module-level Set while the provider opt-outs it mirrors live in
cookies and localStorage. Any reload cleared it — including the reload that
withdrawal triggers for Clarity — so a later consent grant found no marker
and left Matomo and PostHog opted out for good.

Stored in localStorage now, which matches the durability of what it tracks.
Recording a consent decision is strictly necessary processing, so it needs no
consent itself, and a storage failure degrades to "not ours to reverse".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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