Skip to content

fix(website): Consent Mode v2 must fire before gtag.js writes cookies#191

Merged
montfort merged 1 commit into
mainfrom
fix/consent-mode-script-order
May 21, 2026
Merged

fix(website): Consent Mode v2 must fire before gtag.js writes cookies#191
montfort merged 1 commit into
mainfrom
fix/consent-mode-script-order

Conversation

@montfort
Copy link
Copy Markdown
Contributor

Summary

Hotfix for #190. The Consent Mode v2 default-deny gate was set up in headTags, but the @docusaurus/plugin-google-gtag from the preset injects its scripts BEFORE user-config headTags — so the rendered <head> order was:

<script async src=gtag.js>                                    ← plugin
<script>gtag("config","G-M8NW21WY2M",{anonymize_ip:true})</script>  ← plugin
<script>gtag("consent","default",{analytics_storage:"denied"})</script>  ← mine, too late

When gtag.js finished loading and processed dataLayer, it saw config before the consent default → initialised the tracker → wrote _ga and _ga_M8NW21WY2M cookies with 2-year expiry before any banner interaction.

The user reported this by opening an incognito tab → DevTools → Application → Cookies and finding both already set:

incognito-cookies-pre-consent

Fix

Stopped using the preset's gtag shortcut. Replicated the plugin's behaviour by hand in headTags, in the correct order:

1. <script>gtag("consent","default","denied")</script>  ← now first
2. <link rel=preconnect googletagmanager>
3. <link rel=preconnect google-analytics>
4. <script async src=gtag.js>
5. <script>gtag("js",…); gtag("config",…,anonymize_ip:true)</script>

dataLayer is now seeded with the denied consent default before any config call. When gtag.js boots, the gate is already applied — no cookies, no hits, until the user clicks Accept.

SPA pageview tracking

The preset's plugin-google-gtag also handled SPA route changes via onRouteUpdate (firing gtag('event','page_view',...) on navigation). Ported that into the existing cookie-consent client module so route changes still report — gated by the current consent state, of course.

Test plan

Local build was verified by extracting the rendered <head> and confirming the new order. After deploy, in a fresh incognito tab:

  • Open https://straymark.dev/, DevTools → Application → Cookies → _ga* cookies should NOT exist.
  • DevTools → Network filter collect → no requests to *.google-analytics.com/g/collect.
  • Click Accept in the banner → _ga and _ga_M8NW21WY2M cookies appear, collect requests start, GA4 Realtime shows the visit.
  • Click Reject in a new incognito → cookies stay absent, collect requests never fire.
  • Navigate between pages after Accept → DevTools shows new collect requests with page_path matching each route (SPA pageview tracking).

🤖 Generated with Claude Code

PR #190 shipped a Consent Mode v2 default-deny gate, but the script
ordering in <head> was wrong: @docusaurus/plugin-google-gtag injects
its scripts BEFORE user-config headTags, so the rendered order was

  <script async src=gtag.js>          (kicks off, will run later)
  <script>gtag("config",...)</script>  (plugin-injected, sync)
  <script>gtag("consent","default","denied")</script>  (mine — too late)

dataLayer therefore got [..., config, consent default]. When gtag.js
boots it processes dataLayer top-down, so it applied `config` first,
initialised the tracker, wrote `_ga` and `_ga_M8NW21WY2M` cookies,
and only then saw the denied consent default. Reported by inspecting
Application > Cookies in an incognito session: both cookies present
with 2-year expiry, before any banner click.

Fix: stop using the preset's `gtag` shortcut and replicate its
behaviour manually in headTags, with consent default as the very
first tag. New order:

  1. <script>gtag("consent","default","denied")</script>
  2. <link rel=preconnect googletagmanager>
  3. <link rel=preconnect google-analytics>
  4. <script async src=gtag.js>
  5. <script>gtag("js",...);gtag("config",..., anonymize_ip)</script>

Now dataLayer is seeded with the denied consent default before any
config call, so gtag.js applies the gate first and refuses to write
cookies / send hits until the user clicks Accept.

The preset's plugin-google-gtag also handled SPA pageview tracking
via onRouteUpdate; ported that into the cookie-consent client module
so route changes still fire `gtag('event', 'page_view', ...)` — gated
by the current consent state, naturally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@montfort montfort force-pushed the fix/consent-mode-script-order branch from 6e4d649 to aec6104 Compare May 21, 2026 19:40
@montfort montfort merged commit 0d08263 into main May 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant