Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import { render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CookieCategoryName, type CookieConsent } from '../gdpr/types'
import {
loadGoogleAnalytics,
setGoogleConsentDefault,
updateAnalyticsConsent,
} from './consent-mode'
import { bootstrapGoogleAnalytics } from './bootstrap'
import { updateAnalyticsConsent } from './consent-mode'
import { GoogleAnalyticsWithConsent } from './GoogleAnalyticsWithConsent'
import { useCookieConsent } from '../gdpr/contexts/useCookieConsent'
import type { CookieConsentContextType } from '../gdpr/contexts/CookieConsentShared'

vi.mock('./bootstrap', () => ({
bootstrapGoogleAnalytics: vi.fn(),
}))

vi.mock('./consent-mode', () => ({
setGoogleConsentDefault: vi.fn(),
loadGoogleAnalytics: vi.fn(),
updateAnalyticsConsent: vi.fn(),
}))

Expand Down Expand Up @@ -46,18 +45,13 @@ describe('GoogleAnalyticsWithConsent', () => {
expect(container.childNodes).toHaveLength(0)
})

it('sets the denied consent default before loading GA on mount', () => {
it('bootstraps GA once on mount', () => {
mockConsent(false)

render(<GoogleAnalyticsWithConsent gaId={TEST_GA_ID} />)

expect(setGoogleConsentDefault).toHaveBeenCalledTimes(1)
expect(loadGoogleAnalytics).toHaveBeenCalledWith(TEST_GA_ID)

// The default must be set before GA is configured.
const defaultOrder = vi.mocked(setGoogleConsentDefault).mock.invocationCallOrder[0]
const loadOrder = vi.mocked(loadGoogleAnalytics).mock.invocationCallOrder[0]
expect(defaultOrder).toBeLessThan(loadOrder)
expect(bootstrapGoogleAnalytics).toHaveBeenCalledTimes(1)
expect(bootstrapGoogleAnalytics).toHaveBeenCalledWith(TEST_GA_ID)
})

it('keeps analytics denied while the visitor has not consented', () => {
Expand Down Expand Up @@ -86,17 +80,15 @@ describe('GoogleAnalyticsWithConsent', () => {
rerender(<GoogleAnalyticsWithConsent gaId={TEST_GA_ID} />)

expect(updateAnalyticsConsent).toHaveBeenLastCalledWith(true)
// GA is loaded once; only the consent signal changes.
expect(setGoogleConsentDefault).toHaveBeenCalledTimes(1)
expect(loadGoogleAnalytics).toHaveBeenCalledTimes(1)
// GA is bootstrapped once; only the consent signal changes afterwards.
expect(bootstrapGoogleAnalytics).toHaveBeenCalledTimes(1)
})

it('does nothing when no gaId is provided', () => {
it('does not bootstrap when no gaId is provided', () => {
mockConsent(false)

render(<GoogleAnalyticsWithConsent gaId="" />)

expect(setGoogleConsentDefault).not.toHaveBeenCalled()
expect(loadGoogleAnalytics).not.toHaveBeenCalled()
expect(bootstrapGoogleAnalytics).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@
import { useEffect } from 'react'
import { useCookieConsent } from '../gdpr/contexts/useCookieConsent'
import { CookieCategoryName } from '../gdpr/types'
import {
loadGoogleAnalytics,
setGoogleConsentDefault,
updateAnalyticsConsent,
} from './consent-mode'
import { bootstrapGoogleAnalytics } from './bootstrap'
import { updateAnalyticsConsent } from './consent-mode'

export type GoogleAnalyticsWithConsentProps = {
gaId: string
Expand All @@ -16,10 +13,18 @@ export type GoogleAnalyticsWithConsentProps = {
/**
* Loads Google Analytics (GA4) and drives it with Google Consent Mode v2.
*
* GA is always loaded, but consent defaults to `denied`, so GA sends anonymous
* cookieless pings (aggregate pageview counts, no tracking cookies) until the
* visitor grants the Analytics cookie category. When consent changes, a
* `consent update` upgrades or downgrades GA accordingly.
* GA is always loaded. Bootstrapping is delegated to `bootstrapGoogleAnalytics`,
* which seeds the consent *default* from the visitor's stored decision: a
* first-time or declining visitor defaults to `denied` (anonymous cookieless
* pings, no tracking cookies), while a returning, already-consented visitor
* defaults to `granted` so their very first `page_view` is measured with
* consent. When consent changes during the session, a `consent update`
* upgrades or downgrades GA accordingly.
*
* The bootstrap is idempotent: in the sphinx injection it is invoked
* synchronously before React mounts (avoiding post-hydration delay), so this
* effect becomes a no-op there; in contexts without that injection (e.g.
* Next.js) this effect performs the bootstrap itself.
*
* This is framework-agnostic (no `next/script`), so it works in Next.js apps
* and in the plain-React sphinx injection alike. Render it inside a
Expand All @@ -34,9 +39,7 @@ export function GoogleAnalyticsWithConsent({ gaId }: GoogleAnalyticsWithConsentP
return
}

// The consent default must be set before GA's config call.
setGoogleConsentDefault()
loadGoogleAnalytics(gaId)
bootstrapGoogleAnalytics(gaId)
}, [gaId])

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CookieCategoryName, type CookieConsent } from '../gdpr/types'
import { retrieveConsentCategoriesFromCookies } from '../gdpr/service/cookie-consent-service'
import {
hasGoogleAnalyticsScript,
loadGoogleAnalytics,
setGoogleConsentDefault,
} from './consent-mode'
import { bootstrapGoogleAnalytics } from './bootstrap'

vi.mock('./consent-mode', () => ({
hasGoogleAnalyticsScript: vi.fn(() => false),
loadGoogleAnalytics: vi.fn(),
setGoogleConsentDefault: vi.fn(),
}))

vi.mock('../gdpr/service/cookie-consent-service', () => ({
retrieveConsentCategoriesFromCookies: vi.fn(),
}))

const TEST_GA_ID = 'G-TEST12345'

const buildConsent = (analyticsGranted: boolean): CookieConsent => ({
[CookieCategoryName.Essential]: true,
[CookieCategoryName.Analytics]: analyticsGranted,
})

beforeEach(() => {
vi.mocked(hasGoogleAnalyticsScript).mockReturnValue(false)
vi.mocked(retrieveConsentCategoriesFromCookies).mockReturnValue(buildConsent(false))
})

afterEach(() => {
vi.clearAllMocks()
})

describe('bootstrapGoogleAnalytics', () => {
it('seeds a granted default before loading GA for an already-consented visitor', () => {
vi.mocked(retrieveConsentCategoriesFromCookies).mockReturnValue(buildConsent(true))

bootstrapGoogleAnalytics(TEST_GA_ID)

expect(setGoogleConsentDefault).toHaveBeenCalledWith({ analytics_storage: 'granted' })
expect(loadGoogleAnalytics).toHaveBeenCalledWith(TEST_GA_ID)

const defaultOrder = vi.mocked(setGoogleConsentDefault).mock.invocationCallOrder[0]
const loadOrder = vi.mocked(loadGoogleAnalytics).mock.invocationCallOrder[0]
expect(defaultOrder).toBeLessThan(loadOrder)
})

it('seeds a denied default for a first-time or declining visitor', () => {
vi.mocked(retrieveConsentCategoriesFromCookies).mockReturnValue(buildConsent(false))

bootstrapGoogleAnalytics(TEST_GA_ID)

expect(setGoogleConsentDefault).toHaveBeenCalledWith({ analytics_storage: 'denied' })
expect(loadGoogleAnalytics).toHaveBeenCalledWith(TEST_GA_ID)
})

it('does nothing when no gaId is provided', () => {
bootstrapGoogleAnalytics('')

expect(retrieveConsentCategoriesFromCookies).not.toHaveBeenCalled()
expect(setGoogleConsentDefault).not.toHaveBeenCalled()
expect(loadGoogleAnalytics).not.toHaveBeenCalled()
})

it('is idempotent: does nothing when GA for the same gaId is already injected', () => {
vi.mocked(hasGoogleAnalyticsScript).mockReturnValue(true)

bootstrapGoogleAnalytics(TEST_GA_ID)

expect(hasGoogleAnalyticsScript).toHaveBeenCalledWith(TEST_GA_ID)
expect(retrieveConsentCategoriesFromCookies).not.toHaveBeenCalled()
expect(setGoogleConsentDefault).not.toHaveBeenCalled()
expect(loadGoogleAnalytics).not.toHaveBeenCalled()
})

it('re-initialises when only a script for a different gaId is present', () => {
// hasGoogleAnalyticsScript(gaId) reports false when the injected script
// targets a different measurement ID, so the bootstrap should proceed and
// let loadGoogleAnalytics update it.
vi.mocked(hasGoogleAnalyticsScript).mockReturnValue(false)

bootstrapGoogleAnalytics(TEST_GA_ID)

expect(hasGoogleAnalyticsScript).toHaveBeenCalledWith(TEST_GA_ID)
expect(loadGoogleAnalytics).toHaveBeenCalledWith(TEST_GA_ID)
})
})
43 changes: 43 additions & 0 deletions documentation-ui/src/custom/docs/components/analytics/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* One-time bootstrap for GA4 + Google Consent Mode v2, deliberately decoupled
* from React.
*
* Running the bootstrap from a React `useEffect` means GA is not initialised
* until after the tree has rendered, committed and flushed its passive effects,
* which on a heavy page delays the first `page_view` by hundreds of
* milliseconds. Because this helper (and the primitives it composes) have no
* React dependency, it can instead be invoked synchronously from a plain-JS
* entry point — e.g. the sphinx injection script, before the React root is
* mounted — so GA starts as early as the page's JavaScript can run.
*/

import { retrieveConsentCategoriesFromCookies } from '../gdpr/service/cookie-consent-service'
import { CookieCategoryName } from '../gdpr/types'
import {
hasGoogleAnalyticsScript,
loadGoogleAnalytics,
setGoogleConsentDefault,
} from './consent-mode'

/**
* Initialises GA once, seeding the consent default from the visitor's stored
* decision so a returning, already-consented visitor's first `page_view` is
* sent with consent granted (rather than going out cookieless and relying on a
* later `consent update`).
*
* Idempotent and safe to call from multiple entry points: the sphinx injection
* can call it synchronously before mounting React, and `GoogleAnalyticsWithConsent`
* calls it from an effect as a fallback for contexts without that injection
* (e.g. Next.js). Whichever runs first wins; subsequent calls are no-ops.
*/
export function bootstrapGoogleAnalytics(gaId: string): void {
if (!gaId || typeof document === 'undefined' || hasGoogleAnalyticsScript(gaId)) {
return
}
Comment thread
Copilot marked this conversation as resolved.

const analyticsGranted = retrieveConsentCategoriesFromCookies()[CookieCategoryName.Analytics]

// The consent default must be set before GA's config call.
setGoogleConsentDefault({ analytics_storage: analyticsGranted ? 'granted' : 'denied' })
loadGoogleAnalytics(gaId)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
DEFAULT_GOOGLE_CONSENT,
ensureGtag,
hasGoogleAnalyticsScript,
loadGoogleAnalytics,
setGoogleConsentDefault,
updateAnalyticsConsent,
Expand Down Expand Up @@ -75,7 +76,7 @@ describe('setGoogleConsentDefault', () => {
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
wait_for_update: 500,
wait_for_update: 100,
})
})

Expand All @@ -85,7 +86,7 @@ describe('setGoogleConsentDefault', () => {
const settings = findCommand('consent', 'default')?.[2] as Record<string, unknown>
expect(settings).toEqual({
...DEFAULT_GOOGLE_CONSENT,
wait_for_update: 500,
wait_for_update: 100,
})
})

Expand Down Expand Up @@ -170,3 +171,25 @@ describe('loadGoogleAnalytics', () => {
expect(script?.src).toBe(`https://www.googletagmanager.com/gtag/js?id=${otherGaId}`)
})
})

describe('hasGoogleAnalyticsScript', () => {
it('is false before any script is injected', () => {
expect(hasGoogleAnalyticsScript()).toBe(false)
expect(hasGoogleAnalyticsScript(TEST_GA_ID)).toBe(false)
})

it('is true once a script is present when no gaId is supplied', () => {
loadGoogleAnalytics(TEST_GA_ID)

expect(hasGoogleAnalyticsScript()).toBe(true)
})

it('matches only the gaId the injected script targets', () => {
loadGoogleAnalytics(TEST_GA_ID)

expect(hasGoogleAnalyticsScript(TEST_GA_ID)).toBe(true)
// A script left over from a different ID must not count as present, so the
// bootstrap re-runs and updates it.
expect(hasGoogleAnalyticsScript('G-OTHER67890')).toBe(false)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,32 @@ export const DEFAULT_GOOGLE_CONSENT: Required<Omit<GoogleConsentSettings, 'wait_
analytics_storage: 'denied',
}

/** Builds the gtag.js script URL for a given measurement ID. */
function gtagScriptUrl(gaId: string): string {
const scriptUrl = new URL('https://www.googletagmanager.com/gtag/js')
scriptUrl.searchParams.set('id', gaId)
return scriptUrl.toString()
}

/**
* Returns whether the gtag.js script has already been injected. When a `gaId`
* is supplied it is only considered present if the injected script targets that
* same ID, so callers can detect a script left over from a different ID and
* re-run initialisation to update it (see `loadGoogleAnalytics`).
*/
export function hasGoogleAnalyticsScript(gaId?: string): boolean {
if (typeof document === 'undefined') {
return false
}

const existingScript = document.getElementById(GA_SCRIPT_ID) as HTMLScriptElement | null
if (!existingScript) {
return false
}

return gaId === undefined || existingScript.src === gtagScriptUrl(gaId)
}

/** Ensures `window.dataLayer` / `window.gtag` exist and returns the gtag function. */
export function ensureGtag(): GtagFn | undefined {
if (typeof window === 'undefined') {
Expand Down Expand Up @@ -71,7 +97,7 @@ export function setGoogleConsentDefault(overrides: GoogleConsentSettings = {}):

gtag('consent', 'default', {
...DEFAULT_GOOGLE_CONSENT,
wait_for_update: 500,
wait_for_update: 100,
...overrides,
})
}
Expand Down Expand Up @@ -105,20 +131,19 @@ export function loadGoogleAnalytics(gaId: string): void {
gtag('js', new Date())
gtag('config', gaId)

const scriptUrl = new URL('https://www.googletagmanager.com/gtag/js')
scriptUrl.searchParams.set('id', gaId)
const src = gtagScriptUrl(gaId)

const existingScript = document.getElementById(GA_SCRIPT_ID) as HTMLScriptElement | null
if (existingScript) {
if (existingScript.src !== scriptUrl.toString()) {
existingScript.src = scriptUrl.toString()
if (existingScript.src !== src) {
existingScript.src = src
}
return
}

const script = document.createElement('script')
script.id = GA_SCRIPT_ID
script.async = true
script.src = scriptUrl.toString()
script.src = src
document.head.appendChild(script)
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './bootstrap'
export * from './consent-mode'
export * from './GoogleAnalyticsWithConsent'
11 changes: 10 additions & 1 deletion sphinx-ui/react/src/injectNav.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

import { CookieConsentManager, CookieConsentProvider, DocsNavBar, GoogleAnalyticsWithConsent } from "@quantinuum/documentation-ui";
import { bootstrapGoogleAnalytics, CookieConsentManager, CookieConsentProvider, DocsNavBar, GoogleAnalyticsWithConsent } from "@quantinuum/documentation-ui";
import { createRoot } from "react-dom/client";

const GA_ID = __NEXT_PUBLIC_GA_ID__;
Expand Down Expand Up @@ -53,6 +53,15 @@ const observeTailwindDialogPortalElements = () => {

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)
}

observeTailwindDialogPortalElements()

const renderIn = document.createElement('div')
Expand Down