diff --git a/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsWithConsent.test.tsx b/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsWithConsent.test.tsx
index eefe539..815748a 100644
--- a/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsWithConsent.test.tsx
+++ b/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsWithConsent.test.tsx
@@ -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(),
}))
@@ -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()
- 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', () => {
@@ -86,17 +80,15 @@ describe('GoogleAnalyticsWithConsent', () => {
rerender()
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()
- expect(setGoogleConsentDefault).not.toHaveBeenCalled()
- expect(loadGoogleAnalytics).not.toHaveBeenCalled()
+ expect(bootstrapGoogleAnalytics).not.toHaveBeenCalled()
})
})
diff --git a/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsWithConsent.tsx b/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsWithConsent.tsx
index a9c8828..07689c9 100644
--- a/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsWithConsent.tsx
+++ b/documentation-ui/src/custom/docs/components/analytics/GoogleAnalyticsWithConsent.tsx
@@ -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
@@ -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
@@ -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(() => {
diff --git a/documentation-ui/src/custom/docs/components/analytics/bootstrap.test.ts b/documentation-ui/src/custom/docs/components/analytics/bootstrap.test.ts
new file mode 100644
index 0000000..f13b79c
--- /dev/null
+++ b/documentation-ui/src/custom/docs/components/analytics/bootstrap.test.ts
@@ -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)
+ })
+})
diff --git a/documentation-ui/src/custom/docs/components/analytics/bootstrap.ts b/documentation-ui/src/custom/docs/components/analytics/bootstrap.ts
new file mode 100644
index 0000000..8338698
--- /dev/null
+++ b/documentation-ui/src/custom/docs/components/analytics/bootstrap.ts
@@ -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
+ }
+
+ const analyticsGranted = retrieveConsentCategoriesFromCookies()[CookieCategoryName.Analytics]
+
+ // The consent default must be set before GA's config call.
+ setGoogleConsentDefault({ analytics_storage: analyticsGranted ? 'granted' : 'denied' })
+ loadGoogleAnalytics(gaId)
+}
diff --git a/documentation-ui/src/custom/docs/components/analytics/consent-mode.test.ts b/documentation-ui/src/custom/docs/components/analytics/consent-mode.test.ts
index 49937b6..52a99d7 100644
--- a/documentation-ui/src/custom/docs/components/analytics/consent-mode.test.ts
+++ b/documentation-ui/src/custom/docs/components/analytics/consent-mode.test.ts
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
DEFAULT_GOOGLE_CONSENT,
ensureGtag,
+ hasGoogleAnalyticsScript,
loadGoogleAnalytics,
setGoogleConsentDefault,
updateAnalyticsConsent,
@@ -75,7 +76,7 @@ describe('setGoogleConsentDefault', () => {
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
- wait_for_update: 500,
+ wait_for_update: 100,
})
})
@@ -85,7 +86,7 @@ describe('setGoogleConsentDefault', () => {
const settings = findCommand('consent', 'default')?.[2] as Record
expect(settings).toEqual({
...DEFAULT_GOOGLE_CONSENT,
- wait_for_update: 500,
+ wait_for_update: 100,
})
})
@@ -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)
+ })
+})
diff --git a/documentation-ui/src/custom/docs/components/analytics/consent-mode.ts b/documentation-ui/src/custom/docs/components/analytics/consent-mode.ts
index 9281240..53e2de5 100644
--- a/documentation-ui/src/custom/docs/components/analytics/consent-mode.ts
+++ b/documentation-ui/src/custom/docs/components/analytics/consent-mode.ts
@@ -37,6 +37,32 @@ export const DEFAULT_GOOGLE_CONSENT: Required {
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')