From 9efecc5c0dc95b117e0f4bb94456c9e696717d9d Mon Sep 17 00:00:00 2001 From: Jonny Hislop Date: Thu, 24 Sep 2026 13:28:23 -0600 Subject: [PATCH] fix(analytics): stop duplicate GA4 page_view events --- .agents/analytics.md | 2 +- src/routes/__root.tsx | 29 +++++++++++++++++++++++------ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.agents/analytics.md b/.agents/analytics.md index d5757b286..28cb6818b 100644 --- a/.agents/analytics.md +++ b/.agents/analytics.md @@ -43,7 +43,7 @@ Every event automatically receives `page_location`, `page_path`, `page_title`, ` ### `page_view` -Fires on initial load (auto from gtag config) and on every SPA navigation. +Fires once on initial load and once per SPA navigation, sent only by `` in `src/routes/__root.tsx`. The gtag config uses `send_page_view: false`, and the GA4 web stream must keep enhanced measurement's "page changes based on browser history events" disabled; otherwise every navigation is counted twice. | Prop | Type | Notes | | --------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------ | diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 468c4cf40..95d37c7af 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -52,7 +52,12 @@ const GOOGLE_ANALYTICS_ID = 'G-JMT1Z50SPS' const GOOGLE_ANALYTICS_PROXY_PREFIX = '/_a' const GOOGLE_ANALYTICS_SCRIPT_SRC = `${GOOGLE_ANALYTICS_PROXY_PREFIX}/gtag.js` const THEME_BOOTSTRAP = `(function(){try{var t=localStorage.getItem('theme')||'auto';var v=['light','dark','auto'].includes(t)?t:'auto';var r=v==='auto'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):v;if(document.documentElement){document.documentElement.classList.add(r);if(v==='auto')document.documentElement.classList.add('auto');document.documentElement.style.colorScheme=r}}catch(e){if(document.documentElement){var r=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';document.documentElement.classList.add(r,'auto');document.documentElement.style.colorScheme=r}}})()` -const GOOGLE_ANALYTICS_BOOTSTRAP = `(function(){var id='${GOOGLE_ANALYTICS_ID}';var src='${GOOGLE_ANALYTICS_SCRIPT_SRC}';window.dataLayer=window.dataLayer||[];window.gtag=window.gtag||function(){window.dataLayer.push(arguments)};window.gtag('js',new Date());window.gtag('config',id,{transport_url:window.location.origin+'${GOOGLE_ANALYTICS_PROXY_PREFIX}'});var loaded=false;var load=function(){if(loaded)return;var parent=document.head||document.documentElement;if(!parent){window.setTimeout(load,100);return}loaded=true;var script=document.createElement('script');script.async=true;script.src=src;script.setAttribute('data-ga-loader','true');parent.appendChild(script)};if(typeof window.requestIdleCallback==='function'){window.requestIdleCallback(load,{timeout:3000});return}if(document.readyState==='complete'){window.setTimeout(load,1500);return}window.addEventListener('load',function(){window.setTimeout(load,1500)},{once:true})})();` +// Page views are sent only by (initial load and SPA +// navigations), so the gtag config sets send_page_view: false. The guard makes +// the bootstrap idempotent: without it the head script can execute twice on a +// single load, sending a second config (and a second page_view) and injecting +// a second gtag.js loader. +const GOOGLE_ANALYTICS_BOOTSTRAP = `(function(){if(window.__tanstackGaBootstrapped)return;window.__tanstackGaBootstrapped=true;var id='${GOOGLE_ANALYTICS_ID}';var src='${GOOGLE_ANALYTICS_SCRIPT_SRC}';window.dataLayer=window.dataLayer||[];window.gtag=window.gtag||function(){window.dataLayer.push(arguments)};window.gtag('js',new Date());window.gtag('config',id,{send_page_view:false,transport_url:window.location.origin+'${GOOGLE_ANALYTICS_PROXY_PREFIX}'});var loaded=false;var load=function(){if(loaded)return;var parent=document.head||document.documentElement;if(!parent){window.setTimeout(load,100);return}loaded=true;var script=document.createElement('script');script.async=true;script.src=src;script.setAttribute('data-ga-loader','true');parent.appendChild(script)};if(typeof window.requestIdleCallback==='function'){window.requestIdleCallback(load,{timeout:3000});return}if(document.readyState==='complete'){window.setTimeout(load,1500);return}window.addEventListener('load',function(){window.setTimeout(load,1500)},{once:true})})();` const DOCUMENT_CACHE_HEADERS = { 'Cache-Control': 'public, max-age=0, must-revalidate', 'Cloudflare-CDN-Cache-Control': 'no-store', @@ -408,23 +413,35 @@ function ShellComponent({ children }: { children: React.ReactNode }) { ) } +/** + * The single source of `page_view` events: one on initial load and one per + * SPA navigation. gtag config runs with `send_page_view: false`, and GA4's + * enhanced-measurement "page changes based on browser history events" must + * stay disabled on the web stream, or navigations are counted twice. + */ function PageViewTracker() { const pagePath = useRouterState({ select: (s) => { - const pathname = s.resolvedLocation?.pathname || '/' - const search = s.resolvedLocation?.searchStr || '' + if (!s.resolvedLocation) { + return null + } + + const pathname = s.resolvedLocation.pathname || '/' + const search = s.resolvedLocation.searchStr || '' return `${pathname}${search}` }, }) - const hasTrackedInitialPage = React.useRef(false) + const lastTrackedPath = React.useRef(null) React.useEffect(() => { - if (!hasTrackedInitialPage.current) { - hasTrackedInitialPage.current = true + // Skip until the router has resolved a location, and never send the same + // path twice in a row (guards against the location resolving twice). + if (pagePath === null || lastTrackedPath.current === pagePath) { return } + lastTrackedPath.current = pagePath trackPageView(pagePath) }, [pagePath])