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