💸 Perf - Defer GTM to lazyOnload without dropping early events - #4910
Open
isaaclombardssw wants to merge 3 commits into
Open
💸 Perf - Defer GTM to lazyOnload without dropping early events#4910isaaclombardssw wants to merge 3 commits into
isaaclombardssw wants to merge 3 commits into
Conversation
GTM loaded via @next/third-parties' <GoogleTagManager> (afterInteractive), pulling the container's marketing tags (GA4/Ads/FB/Hotjar) into the hydration window. Replace it with the equivalent GTM snippet split across two next/script tags: the dataLayer + gtm.start init stays eager so early sendGTMEvent / dataLayer.push calls (e.g. eventbrite_order_complete) queue and flush, while gtm.js itself loads on idle (lazyOnload). sendGTMEvent keeps working: it pushes to window.dataLayer directly and does not depend on <GoogleTagManager> rendering. Adds a Playwright regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8genJyZboXaa4vgYhJF4P
Contributor
Coverage report
Test suite run success13 tests passing in 1 suite. Report generated by 🧪jest coverage report action from b611589 |
- Drop the flaky waitForLoadState("networkidle") and the #gtm-script
presence check (a lazyOnload script is only injected after idle, so
the check was coupled to the very wait it was meant to replace).
- Use the awaited gtm.js request as the skip signal, and poll the
dataLayer with waitForFunction so the gtm.js-event assertion can't
race the afterInteractive init.
- Add gtm-tracking to the ui-tests run in both deploy workflows so the
regression guard (#4908 AC4) actually executes on staging, which has
NEXT_PUBLIC_GOOGLE_GTM_ID from the build secret; self-skips elsewhere.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8genJyZboXaa4vgYhJF4P
Member
Author
|
/deploy |
1 similar comment
Member
Author
|
/deploy |
Contributor
|
Deployed changes to https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net ℹ️ Staging slots are no longer created automatically - comment |
Contributor
Member
Author
|
Needs GTM follow up |
Contributor
There was a problem hiding this comment.
Pull request overview
Defers Google Tag Manager loading to lazyOnload (post-load/idle) while preserving early dataLayer queuing, to reduce hydration-window JS cost and protect tracking events.
Changes:
- Replaces
@next/third-parties/googleGTM component with a local component that eagerly initializeswindow.dataLayerand lazily loadsgtm.js. - Adds a Playwright regression test to verify
gtm.jsis still requested and thatdataLayercontains the GTM start event. - Updates CI workflows to include the new
gtm-trackingUI test in PR deploy and main build pipelines.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| ui-tests/gtm-tracking.spec.ts | Adds Playwright regression coverage for deferred GTM loading and dataLayer initialization. |
| app/layout.tsx | Switches GTM import to the new local component while keeping the call site the same. |
| app/components/google-tag-manager.tsx | Implements split GTM snippet: eager dataLayer init + lazyOnload gtm.js load, with guard for missing ID. |
| .github/workflows/pr-deploy-command.yml | Includes gtm-tracking in PR deployment UI test suite. |
| .github/workflows/main-build-and-deploy.yml | Includes gtm-tracking in staging slot UI test suite. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+23
to
+40
| const gtmRequest = page | ||
| .waitForRequest( | ||
| (req) => req.url().includes("googletagmanager.com/gtm.js"), | ||
| { timeout: 15000 } | ||
| ) | ||
| .catch(() => null); | ||
|
|
||
| await page.goto("/", { waitUntil: "load" }); | ||
|
|
||
| // lazyOnload requests gtm.js on idle after load; resolves to the request, or null on timeout. | ||
| const request = await gtmRequest; | ||
|
|
||
| // Tracking not dropped: reaching here (not skipping) means gtm.js was fetched after the deferral. | ||
| // No id on this server (e.g. local dev) => no request => skip the live assertion cleanly. | ||
| test.skip( | ||
| request === null, | ||
| "NEXT_PUBLIC_GOOGLE_GTM_ID not set on this server — skipping the live GTM beacon assertion (verify tags via the manual dashboard checks in the file header)." | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4908.
What changed
app/layout.tsxloaded GTM via<GoogleTagManager>from@next/third-parties/google, which only offers anafterInteractiveload — so GTM (and the GA4/Ads/FB/Hotjar tags configured in the container) landed in the hydration window.Replaced it with a small local
GoogleTagManagercomponent (app/components/google-tag-manager.tsx) that emits the equivalent GTM snippet as twonext/scripttags:afterInteractive):window.dataLayer = window.dataLayer || []+ thegtm.startpush, so the dataLayer exists and early events queue.gtm.jsitself onstrategy="lazyOnload"(loads on idle, after the page is interactive).The call site is unchanged:
<GoogleTagManager gtmId={process.env.NEXT_PUBLIC_GOOGLE_GTM_ID} />. Added a guard so nothing renders when the id is unset (previously it would have requestedgtm.js?id=undefined).Conversions / dataLayer still work
sendGTMEventfrom@next/third-parties/googledoes not depend on<GoogleTagManager>rendering — its source pushes straight towindow.dataLayer(defaulting the layer name todataLayerand creating the array if missing):So the
eventbrite_order_completeconversion incomponents/eventbrite/eventbriteModalButton.tsxstill queues intowindow.dataLayerand is flushed when GTM finishes loading on idle — including the edge case of a CTA clicked immediately after load, since the push is queued, not dropped.Regression test
ui-tests/gtm-tracking.spec.ts(Playwright, existingui-tests/convention) loads/, waits for network idle, then asserts:gtm.jsis still requested (tracking isn't silently disabled by the deferral), andwindow.dataLayercontains thegtm.jsstart event (queuing is intact).It self-skips the live-beacon assertion when
NEXT_PUBLIC_GOOGLE_GTM_IDisn't set on the target server (e.g. local dev), so it's meaningful against a real environment and green everywhere else. Run it against a server that has the id, e.g.HOST_URL=https://www.ssw.com.au pnpm exec playwright test ui-tests/gtm-tracking.spec.ts.Out of code scope — follow-up for whoever owns the GTM container
The bulk of the ~650 KB win is retiming the individual tag triggers (GA4 / Google Ads / Facebook Pixel / Hotjar) to fire on Window Loaded / interaction / a short timer instead of the initial page view. That's GTM container config, not code, and lives entirely in the container. This PR only changes how/when GTM itself loads.
Manual dashboard verification (needs the real container)
google-analytics.com/g/collect, Adsgoogleads.g.doubleclick.net/pagead, FBfacebook.com/tr?, Hotjar*.hotjar.io🤖 Generated with Claude Code
https://claude.ai/code/session_01Q8genJyZboXaa4vgYhJF4P