Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main-build-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ jobs:
uses: ./.github/workflows/template-ui-tests.yml
with:
deploy_url: ${{ needs.deploy-staging.outputs.url }}
tests_to_run: "images seo-noindex" # staging slot should not be indexed
tests_to_run: "images seo-noindex gtm-tracking" # staging slot should not be indexed

swap-staging:
name: Swap staging with production
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-deploy-command.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ jobs:
uses: ./.github/workflows/template-ui-tests.yml
with:
deploy_url: ${{ needs.pr-deploy.outputs.url }}
tests_to_run: "images seo-noindex"
tests_to_run: "images seo-noindex gtm-tracking"

prLighthouseInsights:
name: Run PR Lighthouse Insights
Expand Down
33 changes: 33 additions & 0 deletions app/components/google-tag-manager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Script from "next/script";

/**
* GTM split out of @next/third-parties' <GoogleTagManager>, which only exposes an
* afterInteractive load. The dataLayer is created and `gtm.start` pushed eagerly, so early
* `sendGTMEvent` / `dataLayer.push` calls (e.g. the eventbrite_order_complete conversion) queue
* and flush once GTM arrives — but gtm.js itself loads on idle (lazyOnload) instead of during
* hydration, keeping the container's marketing tags (GA4/Ads/FB/Hotjar) out of the critical window.
* sendGTMEvent from @next/third-parties keeps working: it pushes to window.dataLayer directly and
* doesn't depend on this component rendering.
* Retiming the individual tag triggers is GTM container config, not code — see #4908.
*/
export const GoogleTagManager = ({ gtmId }: { gtmId?: string }) => {
if (!gtmId) return null;
return (
<>
<Script
id="gtm-datalayer-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html:
"window.dataLayer=window.dataLayer||[];window.dataLayer.push({'gtm.start':new Date().getTime(),event:'gtm.js'});",
}}
/>
<Script
id="gtm-script"
data-ntpc="GTM"
strategy="lazyOnload"
src={`https://www.googletagmanager.com/gtm.js?id=${gtmId}`}
/>
</>
);
};
2 changes: 1 addition & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { PhishingBanner } from "@/components/phishing-banner/phishing-banner";
import { MegaMenuWrapper } from "@/components/server/MegaMenuWrapper";
import { AppInsightsProvider } from "@/context/app-insight-client";
import { EventInfoStatic } from "@/services/server/events-types";
import { GoogleTagManager } from "@next/third-parties/google";
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import isBetween from "dayjs/plugin/isBetween";
Expand All @@ -14,6 +13,7 @@ import dynamic from "next/dynamic";
import { inter } from "@/lib/fonts";
import "styles.css";
import client from "../tina/__generated__/client";
import { GoogleTagManager } from "./components/google-tag-manager";
import LandingPageCapture from "./components/landing-page-capture";
import { MenuWrapper } from "./components/MenuWrapper";
import PageLayout from "./components/page-layout";
Expand Down
64 changes: 64 additions & 0 deletions ui-tests/gtm-tracking.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { test } from "@playwright/test";

/**
* Regression guard for #4908: GTM now loads on idle (lazyOnload) instead of during hydration.
* The deferral must NOT drop tracking, so this asserts on a running site that:
* 1. gtm.js is still fetched (on idle) — the container/beacons aren't silently disabled.
* 2. window.dataLayer is initialised with the gtm.js start event, so early
* sendGTMEvent / dataLayer.push calls (e.g. the eventbrite_order_complete conversion)
* queue and flush once GTM arrives.
*
* The downstream marketing beacons only fire with the real GTM container + NEXT_PUBLIC_GOOGLE_GTM_ID
* present, so those stay manual dashboard checks: GTM Tag Assistant (tags fired + order), GA4
* Realtime, Meta Events Manager -> Test Events, Hotjar dashboard. Corresponding network beacons:
* GA4 google-analytics.com/g/collect, Ads googleads.g.doubleclick.net/pagead,
* FB facebook.com/tr?, Hotjar *.hotjar.io.
*
* Run against a server with the GTM id set (e.g. HOST_URL=https://www.ssw.com.au pnpm exec
* playwright test ui-tests/gtm-tracking.spec.ts); without the id the beacon assertion self-skips.
*/
test("GTM still loads and dataLayer queues after deferral", async ({
page,
}) => {
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)."
);
Comment on lines +23 to +40

// dataLayer queuing: the eager init created the array and pushed gtm.start, so events sent
// before GTM finishes loading are queued rather than lost. Poll rather than sample once — the
// init runs on afterInteractive, so it may land a tick after the gtm.js request is observed.
await page
.waitForFunction(
() => {
const w = window as unknown as {
dataLayer?: Array<{ event?: string }>;
};
return (
Array.isArray(w.dataLayer) &&
w.dataLayer.some((e) => e?.event === "gtm.js")
);
},
null,
{ timeout: 5000 }
)
.catch(() => {
throw new Error(
"window.dataLayer never received the gtm.js start event — early events would be dropped."
);
});
});
Loading