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