From 91a0e2dbce109011f843bc67b62b518c2cf114b9 Mon Sep 17 00:00:00 2001 From: Sean Burton Date: Mon, 24 Aug 2026 12:59:28 +0100 Subject: [PATCH 1/4] fix: further attempts at fixing analytics --- .github/workflows/release.yml | 2 - documentation-ui/README.md | 36 + documentation-ui/package.json | 36 +- .../analytics/GoogleAnalyticsHead.test.tsx | 39 + .../analytics/GoogleAnalyticsHead.tsx | 75 + .../GoogleAnalyticsWithConsent.test.tsx | 4 +- .../analytics/GoogleAnalyticsWithConsent.tsx | 20 +- .../components/analytics/bootstrap.test.ts | 30 +- .../docs/components/analytics/bootstrap.ts | 39 +- .../components/analytics/consent-mode.test.ts | 9 +- .../docs/components/analytics/consent-mode.ts | 19 +- .../custom/docs/components/analytics/index.ts | 1 + .../CookieSettingsDialog.tsx | 2 +- pnpm-lock.yaml | 3807 +++++++++-------- sphinx-ui/README.md | 23 + sphinx-ui/build-demo.sh | 42 +- sphinx-ui/quantinuum_sphinx/page.html | 52 +- sphinx-ui/quantinuum_sphinx/theme.toml | 2 + sphinx-ui/react/package.json | 16 +- sphinx-ui/react/src/globals.d.ts | 1 - sphinx-ui/react/src/injectNav.tsx | 18 +- sphinx-ui/react/tsdown.config.ts | 8 - 22 files changed, 2278 insertions(+), 2003 deletions(-) create mode 100644 documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.test.tsx create mode 100644 documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0cb8918..23a8899 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -130,8 +130,6 @@ jobs: pnpm run build - name: Generate UI Assets working-directory: sphinx-ui/react - env: - NEXT_PUBLIC_GA_ID: "G-YPQ1FTGDL3" run: | echo "🔨 Generating UI assets..." pnpm i --frozen-lockfile diff --git a/documentation-ui/README.md b/documentation-ui/README.md index b2a930c..1d2beaa 100644 --- a/documentation-ui/README.md +++ b/documentation-ui/README.md @@ -18,6 +18,42 @@ pnpm run build # Build component library with rollup. CSS variables are declared in `src/tokens.css`. Dark mode is enabled when using the attribute `data-theme="dark"` higher up in the DOM tree. See `src/tokens.css` for more details. +### Google Analytics + +Render `GoogleAnalyticsHead` in the initial server-rendered document ``. It applies the valid stored Consent Mode v2 state before starting `gtag.js`, so the first automatic `page_view` is queued without waiting for React hydration. Render `GoogleAnalyticsWithConsent` inside `CookieConsentProvider` to forward consent changes made after page load. + +In a Next.js App Router project, add both components to the root server layout: + +```tsx +import { + CookieConsentManager, + CookieConsentProvider, + GoogleAnalyticsHead, + GoogleAnalyticsWithConsent, +} from '@quantinuum/documentation-ui' + +const GA_ID = process.env.NEXT_PUBLIC_GA_ID + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {GA_ID && } + + {children} + + {GA_ID && } + + + + + ) +} +``` + +`GoogleAnalyticsWithConsent` retains the same consent sequence as a client-side loading fallback, but `GoogleAnalyticsHead` starts initialization earlier because it does not wait for hydration. GA4 may batch network transmission for about five seconds; `tfd` measures transmission time, not when `config` queued the page view. Pass `nonce` to `GoogleAnalyticsHead` when the page uses a nonce-based Content Security Policy. + +Upgrade the consuming project to a package release that exports `GoogleAnalyticsHead` before adding this import. + ### Semantic release This package uses semantic-release for creating releases. So that the version numbers are incremented appropriately, commits should conform to the Angular Commit Message Conventions. diff --git a/documentation-ui/package.json b/documentation-ui/package.json index 53a0096..6e6c4b5 100644 --- a/documentation-ui/package.json +++ b/documentation-ui/package.json @@ -63,8 +63,8 @@ "node": ">=22.12.0" }, "devDependencies": { - "@biomejs/biome": "^2.5.3", - "@chromatic-com/storybook": "^5.2.1", + "@biomejs/biome": "^2.5.7", + "@chromatic-com/storybook": "^5.3.0", "@commitlint/cli": "^21.2.1", "@commitlint/config-conventional": "^21.2.0", "@eslint/js": "^9.39.5", @@ -73,40 +73,40 @@ "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^12.3.0", - "@storybook/addon-docs": "^10.5.0", - "@storybook/addon-links": "^10.5.0", - "@storybook/addon-onboarding": "^10.5.0", - "@storybook/react": "^10.5.0", - "@storybook/react-vite": "^10.5.0", + "@storybook/addon-docs": "^10.5.7", + "@storybook/addon-links": "^10.5.7", + "@storybook/addon-onboarding": "^10.5.7", + "@storybook/react": "^10.5.7", + "@storybook/react-vite": "^10.5.7", "@tailwindcss/typography": "^0.5.20", "@testing-library/react": "^16.3.2", "@types/node": "^22.20.1", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", - "autoprefixer": "^10.5.2", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "autoprefixer": "^10.5.4", "eslint": "^9.39.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-storybook": "10.4.4", "globals": "^15.15.0", "jsdom": "^29.1.1", - "postcss": "^8.5.19", + "postcss": "^8.5.26", "postcss-fail-on-warn": "^0.2.1", - "rollup": "^4.62.2", + "rollup": "^4.62.4", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-preserve-directives": "^0.4.0", - "semantic-release": "^25.0.7", - "storybook": "^10.5.0", + "semantic-release": "^25.0.9", + "storybook": "^10.5.7", "tailwindcss": "^3.4.19", "tailwindcss-animate": "^1.0.7", "typescript": "^6.0.3", - "typescript-eslint": "^8.64.0", - "vite": "^8.1.4", + "typescript-eslint": "^8.66.0", + "vite": "^8.2.1", "vitest": "^4.1.10" }, "dependencies": { - "@quantinuum/quantinuum-ui": "^5.1.0", + "@quantinuum/quantinuum-ui": "^5.3.0", "clsx": "^2.1.1", "lucide-react": "^0.468.0", "remeda": "^2.39.0", diff --git a/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.test.tsx b/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.test.tsx new file mode 100644 index 0000000..2fa372c --- /dev/null +++ b/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.test.tsx @@ -0,0 +1,39 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { GoogleAnalyticsHead } from './GoogleAnalyticsHead' + +const TEST_GA_ID = 'G-TEST12345' + +describe('GoogleAnalyticsHead', () => { + it('renders nothing without a measurement id', () => { + expect(renderToStaticMarkup()).toBe('') + }) + + it('renders an ordered parser-time bootstrap', () => { + const markup = renderToStaticMarkup() + + const consentDefaultIndex = markup.indexOf("gtag('consent', 'default'") + const configIndex = markup.indexOf("gtag('config', gaId)") + const consentUpdateIndex = markup.indexOf("gtag('consent', 'update'") + const loaderIndex = markup.indexOf("document.createElement('script')") + const appendIndex = markup.indexOf('document.head.appendChild(script)') + + expect(consentDefaultIndex).toBeGreaterThanOrEqual(0) + expect(configIndex).toBeGreaterThan(consentDefaultIndex) + expect(consentUpdateIndex).toBeGreaterThan(configIndex) + expect(loaderIndex).toBeGreaterThan(consentUpdateIndex) + expect(appendIndex).toBeGreaterThan(loaderIndex) + expect(markup).toContain(`data-measurement-id="${TEST_GA_ID}"`) + expect(markup).toContain('https://www.googletagmanager.com/gtag/js?id=') + expect(markup).not.toContain('wait_for_update') + }) + + it('applies a CSP nonce to every script', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup.match(/nonce="test-nonce"/g)).toHaveLength(1) + expect(markup).toContain('script.nonce = bootstrap.nonce') + }) +}) diff --git a/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.tsx b/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.tsx new file mode 100644 index 0000000..44d1be4 --- /dev/null +++ b/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.tsx @@ -0,0 +1,75 @@ +import { + COOKIES_CONSENT_COOKIE_NAME, + COOKIES_CONSENT_VERSION, +} from '../gdpr/cookies-consent.config' +import { CookieCategoryName } from '../gdpr/types' +import { DEFAULT_GOOGLE_CONSENT, GOOGLE_ANALYTICS_SCRIPT_ID } from './consent-mode' + +export type GoogleAnalyticsHeadProps = { + gaId: string + nonce?: string +} + +function bootstrapScript(): string { + const consentCookieName = JSON.stringify(COOKIES_CONSENT_COOKIE_NAME) + const analyticsCategoryName = JSON.stringify(CookieCategoryName.Analytics) + const consentVersion = JSON.stringify(COOKIES_CONSENT_VERSION) + const deniedConsent = JSON.stringify(DEFAULT_GOOGLE_CONSENT) + + return ` +(function () { + var bootstrap = document.currentScript; + var gaId = bootstrap && bootstrap.getAttribute('data-measurement-id'); + if (!gaId) return; + + var analyticsGranted = false; + try { + var cookieName = ${consentCookieName}; + var cookie = document.cookie.split(';').find(function (part) { + return part.trim().indexOf(cookieName + '=') === 0; + }); + if (cookie) { + var value = cookie.trim().slice(cookieName.length + 1); + var storedConsent = JSON.parse(decodeURIComponent(value)); + analyticsGranted = storedConsent.consentVersion === ${consentVersion} + && storedConsent.consentCategories + && storedConsent.consentCategories[${analyticsCategoryName}] === true; + } + } catch (_) {} + + window.dataLayer = window.dataLayer || []; + window.gtag = window.gtag || function () { window.dataLayer.push(arguments); }; + window.gtag('consent', 'default', ${deniedConsent}); + window.gtag('js', new Date()); + window.gtag('config', gaId); + window.gtag('consent', 'update', { + analytics_storage: analyticsGranted ? 'granted' : 'denied' + }); + + var script = document.createElement('script'); + script.id = '${GOOGLE_ANALYTICS_SCRIPT_ID}'; + script.async = true; + script.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(gaId); + if (bootstrap.nonce) script.nonce = bootstrap.nonce; + document.head.appendChild(script); +})();`.trim() +} + +/** + * Renders the Google tag in parser order for use in an SSR document ``. + * Pair it with `GoogleAnalyticsWithConsent` inside the consent provider so + * consent changes made after page load are forwarded to Google. + */ +export function GoogleAnalyticsHead({ gaId, nonce }: GoogleAnalyticsHeadProps) { + if (!gaId) { + return null + } + + return ( + +{% endif %} diff --git a/sphinx-ui/quantinuum_sphinx/theme.toml b/sphinx-ui/quantinuum_sphinx/theme.toml index 9db0121..b401a85 100644 --- a/sphinx-ui/quantinuum_sphinx/theme.toml +++ b/sphinx-ui/quantinuum_sphinx/theme.toml @@ -13,3 +13,5 @@ stylesheets = [ # environments (e.g. staging) do not send analytics. Set to true in the # production conf.py via html_theme_options = {"enable_analytics": True}. enable_analytics = false +# Override this when a documentation site reports to a different GA4 property. +analytics_id = "G-YPQ1FTGDL3" diff --git a/sphinx-ui/react/package.json b/sphinx-ui/react/package.json index dfca2c0..481b81b 100644 --- a/sphinx-ui/react/package.json +++ b/sphinx-ui/react/package.json @@ -14,14 +14,14 @@ "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/node": "^20.19.43", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "autoprefixer": "^10.5.2", - "postcss": "^8.5.19", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "autoprefixer": "^10.5.4", + "postcss": "^8.5.26", "postcss-fail-on-warn": "^0.2.1", "postcss-import": "^16.1.1", "postcss-prefix-selector": "^1.16.1", - "prettier": "^3.9.5", + "prettier": "^3.9.6", "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^3.4.19", "tsdown": "^0.21.10", @@ -29,9 +29,9 @@ }, "dependencies": { "@quantinuum/documentation-ui": "workspace:*", - "@quantinuum/quantinuum-ui": "^5.1.0", - "react": "^19.2.7", - "react-dom": "^19.2.7" + "@quantinuum/quantinuum-ui": "^5.3.0", + "react": "^19.2.8", + "react-dom": "^19.2.8" }, "volta": { "node": "22.22.3", diff --git a/sphinx-ui/react/src/globals.d.ts b/sphinx-ui/react/src/globals.d.ts index 27e12ec..a3528f2 100644 --- a/sphinx-ui/react/src/globals.d.ts +++ b/sphinx-ui/react/src/globals.d.ts @@ -10,4 +10,3 @@ type Icon = { declare const navTextLinks: Link[] declare const navProductName: string declare const navIconLinks: (Icon & Link)[] -declare const __NEXT_PUBLIC_GA_ID__: string | undefined diff --git a/sphinx-ui/react/src/injectNav.tsx b/sphinx-ui/react/src/injectNav.tsx index 625a61b..b1aa428 100644 --- a/sphinx-ui/react/src/injectNav.tsx +++ b/sphinx-ui/react/src/injectNav.tsx @@ -1,9 +1,7 @@ -import { bootstrapGoogleAnalytics, CookieConsentManager, CookieConsentProvider, DocsNavBar, GoogleAnalyticsWithConsent } from "@quantinuum/documentation-ui"; +import { CookieConsentManager, CookieConsentProvider, DocsNavBar, GoogleAnalyticsWithConsent } from "@quantinuum/documentation-ui"; import { createRoot } from "react-dom/client"; -const GA_ID = __NEXT_PUBLIC_GA_ID__; - const tailwindScopeClassName = 'use-tailwind'; const isTailwindDialogPortalElement = (element: Element): element is HTMLElement => { @@ -52,15 +50,9 @@ const observeTailwindDialogPortalElements = () => { if (!mountElement) return const analyticsEnabled = mountElement.getAttribute('data-analytics-enabled') === 'true' - - // Bootstrap GA synchronously, before React mounts, so the first `page_view` - // is dispatched without waiting for hydration and passive-effect scheduling. - // The render below still includes GoogleAnalyticsWithConsent, whose bootstrap - // is idempotent (a no-op once this call has run) and which handles subsequent - // live consent changes. - if (analyticsEnabled && GA_ID) { - bootstrapGoogleAnalytics(GA_ID) - } + const analyticsId = analyticsEnabled + ? mountElement.getAttribute('data-analytics-id') || '' + : '' observeTailwindDialogPortalElements() @@ -73,7 +65,7 @@ const observeTailwindDialogPortalElements = () => {
- {analyticsEnabled && GA_ID && } + {analyticsId && } diff --git a/sphinx-ui/react/tsdown.config.ts b/sphinx-ui/react/tsdown.config.ts index dd89be8..0ed0c52 100644 --- a/sphinx-ui/react/tsdown.config.ts +++ b/sphinx-ui/react/tsdown.config.ts @@ -2,8 +2,6 @@ import { defineConfig } from 'tsdown' -const NEXT_PUBLIC_GA_ID = JSON.stringify(process.env['NEXT_PUBLIC_GA_ID'] || '') - export default defineConfig([ // Script for injecting nav into sphinx build. { @@ -16,9 +14,6 @@ export default defineConfig([ }, target: "es2020", platform: "browser", - define: { - '__NEXT_PUBLIC_GA_ID__': NEXT_PUBLIC_GA_ID, - }, format: ["iife"], clean: true, }, @@ -33,9 +28,6 @@ export default defineConfig([ }, target: "es2020", platform: "browser", - define: { - '__NEXT_PUBLIC_GA_ID__': NEXT_PUBLIC_GA_ID, - }, format: ["iife"], clean: true, }, From 3ecd81f8f669bed9b9036d5c6d6dfd994199114b Mon Sep 17 00:00:00 2001 From: Sean Burton Date: Mon, 24 Aug 2026 15:18:21 +0100 Subject: [PATCH 2/4] fix: address review comments --- .../analytics/GoogleAnalyticsHead.test.tsx | 11 +++++++---- .../analytics/GoogleAnalyticsHead.tsx | 18 ++++++++++++++---- .../components/analytics/bootstrap.test.ts | 8 ++++---- .../docs/components/analytics/bootstrap.ts | 7 +++---- sphinx-ui/build-demo.sh | 4 ++++ sphinx-ui/quantinuum_sphinx/page.html | 18 ++++++++++++++---- 6 files changed, 46 insertions(+), 20 deletions(-) diff --git a/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.test.tsx b/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.test.tsx index 2fa372c..cc5bfa7 100644 --- a/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.test.tsx +++ b/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.test.tsx @@ -13,18 +13,21 @@ describe('GoogleAnalyticsHead', () => { const markup = renderToStaticMarkup() const consentDefaultIndex = markup.indexOf("gtag('consent', 'default'") - const configIndex = markup.indexOf("gtag('config', gaId)") const consentUpdateIndex = markup.indexOf("gtag('consent', 'update'") + const configIndex = markup.indexOf("gtag('config', gaId)") const loaderIndex = markup.indexOf("document.createElement('script')") const appendIndex = markup.indexOf('document.head.appendChild(script)') expect(consentDefaultIndex).toBeGreaterThanOrEqual(0) - expect(configIndex).toBeGreaterThan(consentDefaultIndex) - expect(consentUpdateIndex).toBeGreaterThan(configIndex) - expect(loaderIndex).toBeGreaterThan(consentUpdateIndex) + expect(consentUpdateIndex).toBeGreaterThan(consentDefaultIndex) + expect(configIndex).toBeGreaterThan(consentUpdateIndex) + expect(loaderIndex).toBeGreaterThan(configIndex) expect(appendIndex).toBeGreaterThan(loaderIndex) expect(markup).toContain(`data-measurement-id="${TEST_GA_ID}"`) expect(markup).toContain('https://www.googletagmanager.com/gtag/js?id=') + expect(markup).toContain('dateConsentWasGiven') + expect(markup).toContain('consentCategories.Essential') + expect(markup).toContain(String.raw`/^\d{4}-\d{2}-\d{2}T`) expect(markup).not.toContain('wait_for_update') }) diff --git a/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.tsx b/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.tsx index 44d1be4..732bae8 100644 --- a/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.tsx +++ b/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.tsx @@ -31,8 +31,18 @@ function bootstrapScript(): string { if (cookie) { var value = cookie.trim().slice(cookieName.length + 1); var storedConsent = JSON.parse(decodeURIComponent(value)); - analyticsGranted = storedConsent.consentVersion === ${consentVersion} - && storedConsent.consentCategories + var consentDate = storedConsent && storedConsent.dateConsentWasGiven; + var consentCategories = storedConsent && storedConsent.consentCategories; + analyticsGranted = storedConsent !== null + && typeof storedConsent === 'object' + && storedConsent.consentVersion === ${consentVersion} + && typeof consentDate === 'string' + && /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/.test(consentDate) + && !Number.isNaN(Date.parse(consentDate)) + && consentCategories !== null + && typeof consentCategories === 'object' + && typeof consentCategories.Essential === 'boolean' + && typeof consentCategories[${analyticsCategoryName}] === 'boolean' && storedConsent.consentCategories[${analyticsCategoryName}] === true; } } catch (_) {} @@ -40,11 +50,11 @@ function bootstrapScript(): string { window.dataLayer = window.dataLayer || []; window.gtag = window.gtag || function () { window.dataLayer.push(arguments); }; window.gtag('consent', 'default', ${deniedConsent}); - window.gtag('js', new Date()); - window.gtag('config', gaId); window.gtag('consent', 'update', { analytics_storage: analyticsGranted ? 'granted' : 'denied' }); + window.gtag('js', new Date()); + window.gtag('config', gaId); var script = document.createElement('script'); script.id = '${GOOGLE_ANALYTICS_SCRIPT_ID}'; diff --git a/documentation-ui/src/custom/docs/components/analytics/bootstrap.test.ts b/documentation-ui/src/custom/docs/components/analytics/bootstrap.test.ts index 2f5cb02..87bdfce 100644 --- a/documentation-ui/src/custom/docs/components/analytics/bootstrap.test.ts +++ b/documentation-ui/src/custom/docs/components/analytics/bootstrap.test.ts @@ -42,7 +42,7 @@ afterEach(() => { }) describe('bootstrapGoogleAnalytics', () => { - it('sends a stored grant as an update after loading GA', () => { + it('applies a stored grant before loading GA', () => { vi.mocked(retrieveConsentCategoriesFromCookies).mockReturnValue(buildConsent(true)) bootstrapGoogleAnalytics(TEST_GA_ID) @@ -52,10 +52,10 @@ describe('bootstrapGoogleAnalytics', () => { expect(updateAnalyticsConsent).toHaveBeenCalledWith(true) const defaultOrder = vi.mocked(setGoogleConsentDefault).mock.invocationCallOrder[0] - const loadOrder = vi.mocked(loadGoogleAnalytics).mock.invocationCallOrder[0] const updateOrder = vi.mocked(updateAnalyticsConsent).mock.invocationCallOrder[0] - expect(defaultOrder).toBeLessThan(loadOrder) - expect(loadOrder).toBeLessThan(updateOrder) + const loadOrder = vi.mocked(loadGoogleAnalytics).mock.invocationCallOrder[0] + expect(defaultOrder).toBeLessThan(updateOrder) + expect(updateOrder).toBeLessThan(loadOrder) }) it('seeds a denied default for a first-time or declining visitor', () => { diff --git a/documentation-ui/src/custom/docs/components/analytics/bootstrap.ts b/documentation-ui/src/custom/docs/components/analytics/bootstrap.ts index 4c9ead5..547a887 100644 --- a/documentation-ui/src/custom/docs/components/analytics/bootstrap.ts +++ b/documentation-ui/src/custom/docs/components/analytics/bootstrap.ts @@ -21,9 +21,8 @@ import { } from './consent-mode' /** - * Initialises GA with a denied default, then sends the persisted choice as an - * update after config is queued. This is the sequence Google documents for - * consent choices restored on subsequent pages. + * Initialises GA with a denied default, applies any persisted choice as an + * explicit update, then queues config so the first page view uses that choice. * * Idempotent and safe to call after `GoogleAnalyticsHead`: * `GoogleAnalyticsWithConsent` invokes it from an effect as a compatibility @@ -39,6 +38,6 @@ export function bootstrapGoogleAnalytics(gaId: string): void { retrieveConsentCategoriesFromCookies()[CookieCategoryName.Analytics] setGoogleConsentDefault() - loadGoogleAnalytics(gaId) updateAnalyticsConsent(analyticsGranted) + loadGoogleAnalytics(gaId) } diff --git a/sphinx-ui/build-demo.sh b/sphinx-ui/build-demo.sh index b77c0a7..36261de 100755 --- a/sphinx-ui/build-demo.sh +++ b/sphinx-ui/build-demo.sh @@ -18,6 +18,10 @@ while (($#)); do fi analytics_enabled=true analytics_id="$2" + if [[ ! "$analytics_id" =~ ^G-[A-Z0-9]+$ ]]; then + echo "Error: --analytics-id must be a GA4 measurement ID such as G-XXXXXXXXXX." >&2 + exit 2 + fi shift 2 ;; -h|--help) diff --git a/sphinx-ui/quantinuum_sphinx/page.html b/sphinx-ui/quantinuum_sphinx/page.html index d8e79f4..d36375e 100644 --- a/sphinx-ui/quantinuum_sphinx/page.html +++ b/sphinx-ui/quantinuum_sphinx/page.html @@ -20,8 +20,18 @@ if (cookie) { var value = cookie.trim().slice(cookieName.length + 1); var storedConsent = JSON.parse(decodeURIComponent(value)); - analyticsGranted = storedConsent.consentVersion === 1 - && storedConsent.consentCategories + var consentDate = storedConsent && storedConsent.dateConsentWasGiven; + var consentCategories = storedConsent && storedConsent.consentCategories; + analyticsGranted = storedConsent !== null + && typeof storedConsent === 'object' + && storedConsent.consentVersion === 1 + && typeof consentDate === 'string' + && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(consentDate) + && !Number.isNaN(Date.parse(consentDate)) + && consentCategories !== null + && typeof consentCategories === 'object' + && typeof consentCategories.Essential === 'boolean' + && typeof consentCategories.Analytics === 'boolean' && storedConsent.consentCategories.Analytics === true; } } catch (_) {} @@ -35,11 +45,11 @@ analytics_storage: 'denied' }); - window.gtag('js', new Date()); - window.gtag('config', gaId); window.gtag('consent', 'update', { analytics_storage: analyticsGranted ? 'granted' : 'denied' }); + window.gtag('js', new Date()); + window.gtag('config', gaId); var script = document.createElement('script'); script.id = 'quantinuum-google-analytics'; From 32d1fb827152cd23172a30ba5949f505337f36e8 Mon Sep 17 00:00:00 2001 From: Sean Burton Date: Mon, 24 Aug 2026 17:21:23 +0100 Subject: [PATCH 3/4] fix: revert changes that mostly didn't help --- documentation-ui/README.md | 36 -------- .../analytics/GoogleAnalyticsHead.test.tsx | 42 --------- .../analytics/GoogleAnalyticsHead.tsx | 85 ------------------- .../GoogleAnalyticsWithConsent.test.tsx | 4 +- .../analytics/GoogleAnalyticsWithConsent.tsx | 20 +++-- .../components/analytics/bootstrap.test.ts | 32 ++----- .../docs/components/analytics/bootstrap.ts | 38 ++++----- .../components/analytics/consent-mode.test.ts | 9 +- .../docs/components/analytics/consent-mode.ts | 19 ++--- .../custom/docs/components/analytics/index.ts | 1 - sphinx-ui/README.md | 2 +- sphinx-ui/quantinuum_sphinx/page.html | 64 +------------- 12 files changed, 58 insertions(+), 294 deletions(-) delete mode 100644 documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.test.tsx delete mode 100644 documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.tsx diff --git a/documentation-ui/README.md b/documentation-ui/README.md index 1d2beaa..b2a930c 100644 --- a/documentation-ui/README.md +++ b/documentation-ui/README.md @@ -18,42 +18,6 @@ pnpm run build # Build component library with rollup. CSS variables are declared in `src/tokens.css`. Dark mode is enabled when using the attribute `data-theme="dark"` higher up in the DOM tree. See `src/tokens.css` for more details. -### Google Analytics - -Render `GoogleAnalyticsHead` in the initial server-rendered document ``. It applies the valid stored Consent Mode v2 state before starting `gtag.js`, so the first automatic `page_view` is queued without waiting for React hydration. Render `GoogleAnalyticsWithConsent` inside `CookieConsentProvider` to forward consent changes made after page load. - -In a Next.js App Router project, add both components to the root server layout: - -```tsx -import { - CookieConsentManager, - CookieConsentProvider, - GoogleAnalyticsHead, - GoogleAnalyticsWithConsent, -} from '@quantinuum/documentation-ui' - -const GA_ID = process.env.NEXT_PUBLIC_GA_ID - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - {GA_ID && } - - {children} - - {GA_ID && } - - - - - ) -} -``` - -`GoogleAnalyticsWithConsent` retains the same consent sequence as a client-side loading fallback, but `GoogleAnalyticsHead` starts initialization earlier because it does not wait for hydration. GA4 may batch network transmission for about five seconds; `tfd` measures transmission time, not when `config` queued the page view. Pass `nonce` to `GoogleAnalyticsHead` when the page uses a nonce-based Content Security Policy. - -Upgrade the consuming project to a package release that exports `GoogleAnalyticsHead` before adding this import. - ### Semantic release This package uses semantic-release for creating releases. So that the version numbers are incremented appropriately, commits should conform to the Angular Commit Message Conventions. diff --git a/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.test.tsx b/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.test.tsx deleted file mode 100644 index cc5bfa7..0000000 --- a/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' -import { GoogleAnalyticsHead } from './GoogleAnalyticsHead' - -const TEST_GA_ID = 'G-TEST12345' - -describe('GoogleAnalyticsHead', () => { - it('renders nothing without a measurement id', () => { - expect(renderToStaticMarkup()).toBe('') - }) - - it('renders an ordered parser-time bootstrap', () => { - const markup = renderToStaticMarkup() - - const consentDefaultIndex = markup.indexOf("gtag('consent', 'default'") - const consentUpdateIndex = markup.indexOf("gtag('consent', 'update'") - const configIndex = markup.indexOf("gtag('config', gaId)") - const loaderIndex = markup.indexOf("document.createElement('script')") - const appendIndex = markup.indexOf('document.head.appendChild(script)') - - expect(consentDefaultIndex).toBeGreaterThanOrEqual(0) - expect(consentUpdateIndex).toBeGreaterThan(consentDefaultIndex) - expect(configIndex).toBeGreaterThan(consentUpdateIndex) - expect(loaderIndex).toBeGreaterThan(configIndex) - expect(appendIndex).toBeGreaterThan(loaderIndex) - expect(markup).toContain(`data-measurement-id="${TEST_GA_ID}"`) - expect(markup).toContain('https://www.googletagmanager.com/gtag/js?id=') - expect(markup).toContain('dateConsentWasGiven') - expect(markup).toContain('consentCategories.Essential') - expect(markup).toContain(String.raw`/^\d{4}-\d{2}-\d{2}T`) - expect(markup).not.toContain('wait_for_update') - }) - - it('applies a CSP nonce to every script', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup.match(/nonce="test-nonce"/g)).toHaveLength(1) - expect(markup).toContain('script.nonce = bootstrap.nonce') - }) -}) diff --git a/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.tsx b/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.tsx deleted file mode 100644 index 732bae8..0000000 --- a/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsHead.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { - COOKIES_CONSENT_COOKIE_NAME, - COOKIES_CONSENT_VERSION, -} from '../gdpr/cookies-consent.config' -import { CookieCategoryName } from '../gdpr/types' -import { DEFAULT_GOOGLE_CONSENT, GOOGLE_ANALYTICS_SCRIPT_ID } from './consent-mode' - -export type GoogleAnalyticsHeadProps = { - gaId: string - nonce?: string -} - -function bootstrapScript(): string { - const consentCookieName = JSON.stringify(COOKIES_CONSENT_COOKIE_NAME) - const analyticsCategoryName = JSON.stringify(CookieCategoryName.Analytics) - const consentVersion = JSON.stringify(COOKIES_CONSENT_VERSION) - const deniedConsent = JSON.stringify(DEFAULT_GOOGLE_CONSENT) - - return ` -(function () { - var bootstrap = document.currentScript; - var gaId = bootstrap && bootstrap.getAttribute('data-measurement-id'); - if (!gaId) return; - - var analyticsGranted = false; - try { - var cookieName = ${consentCookieName}; - var cookie = document.cookie.split(';').find(function (part) { - return part.trim().indexOf(cookieName + '=') === 0; - }); - if (cookie) { - var value = cookie.trim().slice(cookieName.length + 1); - var storedConsent = JSON.parse(decodeURIComponent(value)); - var consentDate = storedConsent && storedConsent.dateConsentWasGiven; - var consentCategories = storedConsent && storedConsent.consentCategories; - analyticsGranted = storedConsent !== null - && typeof storedConsent === 'object' - && storedConsent.consentVersion === ${consentVersion} - && typeof consentDate === 'string' - && /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/.test(consentDate) - && !Number.isNaN(Date.parse(consentDate)) - && consentCategories !== null - && typeof consentCategories === 'object' - && typeof consentCategories.Essential === 'boolean' - && typeof consentCategories[${analyticsCategoryName}] === 'boolean' - && storedConsent.consentCategories[${analyticsCategoryName}] === true; - } - } catch (_) {} - - window.dataLayer = window.dataLayer || []; - window.gtag = window.gtag || function () { window.dataLayer.push(arguments); }; - window.gtag('consent', 'default', ${deniedConsent}); - window.gtag('consent', 'update', { - analytics_storage: analyticsGranted ? 'granted' : 'denied' - }); - window.gtag('js', new Date()); - window.gtag('config', gaId); - - var script = document.createElement('script'); - script.id = '${GOOGLE_ANALYTICS_SCRIPT_ID}'; - script.async = true; - script.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(gaId); - if (bootstrap.nonce) script.nonce = bootstrap.nonce; - document.head.appendChild(script); -})();`.trim() -} - -/** - * Renders the Google tag in parser order for use in an SSR document ``. - * Pair it with `GoogleAnalyticsWithConsent` inside the consent provider so - * consent changes made after page load are forwarded to Google. - */ -export function GoogleAnalyticsHead({ gaId, nonce }: GoogleAnalyticsHeadProps) { - if (!gaId) { - return null - } - - return ( - -{% endif %} From 2992398cd8e7c47ad72250cb732fb52e7e595d2c Mon Sep 17 00:00:00 2001 From: Sean Burton Date: Mon, 24 Aug 2026 21:17:25 +0100 Subject: [PATCH 4/4] chore: update quantinuum-ui --- pnpm-lock.yaml | 53 ++++++++++++++++++++++++++++++++---- sphinx-ui/react/package.json | 2 +- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf4eeac..4c389f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,8 +172,8 @@ importers: specifier: workspace:* version: link:../../documentation-ui '@quantinuum/quantinuum-ui': - specifier: ^5.3.0 - version: 5.3.0(@hookform/resolvers@3.10.0(react-hook-form@7.85.0(react@19.2.8)))(@tailwindcss/typography@0.5.20(tailwindcss@3.4.19))(@tanstack/react-table@8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@3.6.0)(lucide-react@0.468.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-hook-form@7.85.0(react@19.2.8))(react-icons@5.6.0(react@19.2.8))(react@19.2.8)(tailwindcss@3.4.19)(zod@3.25.76) + specifier: ^5.3.1 + version: 5.3.1(@hookform/resolvers@3.10.0(react-hook-form@7.85.0(react@19.2.8)))(@tailwindcss/typography@0.5.20(tailwindcss@3.4.19))(@tanstack/react-table@8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@3.6.0)(lucide-react@0.468.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-hook-form@7.85.0(react@19.2.8))(react-icons@5.6.0(react@19.2.8))(react@19.2.8)(tailwindcss@3.4.19)(zod@3.25.76) react: specifier: ^19.2.8 version: 19.2.8 @@ -1238,6 +1238,20 @@ packages: tailwindcss: ^3.4.15 zod: ^3.25.56 + '@quantinuum/quantinuum-ui@5.3.1': + resolution: {integrity: sha512-qmPIqLiQ633lpOQ7D9H2KY6xUw7YoWP7NvF9uEXj0pgh3UvTdVrgtyRUjf6CYGjC6G09DCevBwXEvrU7YEJDmA==} + peerDependencies: + '@hookform/resolvers': ^3.9.0 + '@tailwindcss/typography': ^0.5.20 + '@tanstack/react-table': ^8.11.0 + lucide-react: ^0.468.0 + react: ^19.0.0 + react-dom: ^19.0.0 + react-hook-form: ^7.50.1 + react-icons: ^5.3.0 + tailwindcss: ^3.4.15 + zod: ^3.25.56 + '@radix-ui/number@1.1.3': resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} @@ -3269,6 +3283,7 @@ packages: eslint@9.39.5: resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -5063,8 +5078,8 @@ packages: synckit: optional: true - update-browserslist-db@1.3.0: - resolution: {integrity: sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==} + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -6240,6 +6255,32 @@ snapshots: - '@types/react-dom' - date-fns + '@quantinuum/quantinuum-ui@5.3.1(@hookform/resolvers@3.10.0(react-hook-form@7.85.0(react@19.2.8)))(@tailwindcss/typography@0.5.20(tailwindcss@3.4.19))(@tanstack/react-table@8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@3.6.0)(lucide-react@0.468.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-hook-form@7.85.0(react@19.2.8))(react-icons@5.6.0(react@19.2.8))(react@19.2.8)(tailwindcss@3.4.19)(zod@3.25.76)': + dependencies: + '@hookform/resolvers': 3.10.0(react-hook-form@7.85.0(react@19.2.8)) + '@radix-ui/react-icons': 1.3.2(react@19.2.8) + '@tailwindcss/typography': 0.5.20(tailwindcss@3.4.19) + '@tanstack/react-table': 8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + class-variance-authority: 0.7.1 + clsx: 2.1.1 + cmdk: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + input-otp: 1.4.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + lucide-react: 0.468.0(react@19.2.8) + radix-ui: 1.6.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.8) + react-dom: 19.2.8(react@19.2.8) + react-hook-form: 7.85.0(react@19.2.8) + react-icons: 5.6.0(react@19.2.8) + sonner: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tailwind-merge: 2.6.1 + tailwindcss: 3.4.19 + zod: 3.25.76 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - date-fns + '@radix-ui/number@1.1.3': {} '@radix-ui/primitive@1.1.7': {} @@ -7825,7 +7866,7 @@ snapshots: caniuse-lite: 1.0.30001809 electron-to-chromium: 1.5.403 node-releases: 2.0.53 - update-browserslist-db: 1.3.0(browserslist@4.28.8) + update-browserslist-db: 1.3.1(browserslist@4.28.8) buffer-from@1.1.2: {} @@ -9925,7 +9966,7 @@ snapshots: dependencies: rolldown: 1.0.0-rc.17 - update-browserslist-db@1.3.0(browserslist@4.28.8): + update-browserslist-db@1.3.1(browserslist@4.28.8): dependencies: browserslist: 4.28.8 escalade: 3.2.0 diff --git a/sphinx-ui/react/package.json b/sphinx-ui/react/package.json index 481b81b..3b8de25 100644 --- a/sphinx-ui/react/package.json +++ b/sphinx-ui/react/package.json @@ -29,7 +29,7 @@ }, "dependencies": { "@quantinuum/documentation-ui": "workspace:*", - "@quantinuum/quantinuum-ui": "^5.3.0", + "@quantinuum/quantinuum-ui": "^5.3.1", "react": "^19.2.8", "react-dom": "^19.2.8" },