Skip to content

Commit 28de5c1

Browse files
committed
fix: prevent duplicate scripts on soft navigation (#29)
Introduce script `mode` property to control execution timing: - "once": run only on first mount (gtag.js, analytics init) - "on-params-change": run when template params change (config with user_id) - "every-render": run on every navigation (page_view events) Refactor client.tsx to use React components with proper lifecycle management instead of imperative DOM manipulation. Add e2e test for script mode behavior during soft navigation.
1 parent d420a1c commit 28de5c1

13 files changed

Lines changed: 379 additions & 121 deletions

File tree

e2e/test-app/src/app/page.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { auth } from "@/auth";
2+
import Link from "next/link";
23

34
export default async function Home() {
45
const session = await auth();
@@ -23,9 +24,9 @@ export default async function Home() {
2324
</div>
2425
)}
2526
<nav>
26-
<a href="/test-page" data-testid="test-page-link">
27+
<Link href="/test-page" data-testid="test-page-link">
2728
Test Page
28-
</a>
29+
</Link>
2930
</nav>
3031
</main>
3132
);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
import Link from "next/link";
2+
13
export default function TestPage() {
24
return (
35
<main>
46
<h1>App Router Test Page</h1>
57
<p data-testid="page-marker">This is a test page for App Router</p>
8+
<nav>
9+
<Link href="/" data-testid="home-link">
10+
Back to Home
11+
</Link>
12+
</nav>
613
</main>
714
);
815
}

e2e/test-app/src/nextlytics.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,83 @@
11
import { Nextlytics } from "@nextlytics/core/server";
22
import { postgrestBackend } from "@nextlytics/core/backends/postgrest";
3+
import type { NextlyticsBackend } from "@nextlytics/core";
34
import { auth } from "./auth";
45

6+
// Inline types for test backend (avoid import issues with workspace)
7+
type ScriptMode = "once" | "on-params-change" | "every-render";
8+
type ScriptElement = { async?: boolean; body?: string; src?: string; mode?: ScriptMode };
9+
type JavascriptTemplate = { items: ScriptElement[] };
10+
type ClientAction = { items: Array<{ type: "script-template"; templateId: string; params: unknown }> };
11+
12+
const CONSOLE_TEMPLATE_ID = "console-test";
13+
14+
/**
15+
* Test backend that injects console.log scripts with different modes.
16+
* Used to verify script injection behavior during soft navigation.
17+
*/
18+
function consoleTestBackend(): NextlyticsBackend {
19+
return {
20+
name: "console-test",
21+
returnsClientActions: true,
22+
23+
getClientSideTemplates(): Record<string, JavascriptTemplate> {
24+
return {
25+
[CONSOLE_TEMPLATE_ID]: {
26+
items: [
27+
// mode: "once" - should only run once, even after soft navigation
28+
{
29+
body: [
30+
"window.__nextlyticsTestOnce = (window.__nextlyticsTestOnce || 0) + 1;",
31+
"console.log('[nextlytics-test] once:', window.__nextlyticsTestOnce);",
32+
].join("\n"),
33+
mode: "once",
34+
},
35+
// mode: "on-params-change" - should run when params change
36+
{
37+
body: [
38+
"window.__nextlyticsTestParamsChange = (window.__nextlyticsTestParamsChange || 0) + 1;",
39+
"console.log('[nextlytics-test] on-params-change:', window.__nextlyticsTestParamsChange, 'path={{path}}');",
40+
].join("\n"),
41+
mode: "on-params-change",
42+
},
43+
// mode: "every-render" (default) - should run on every navigation
44+
{
45+
body: [
46+
"window.__nextlyticsTestEveryRender = (window.__nextlyticsTestEveryRender || 0) + 1;",
47+
"console.log('[nextlytics-test] every-render:', window.__nextlyticsTestEveryRender);",
48+
].join("\n"),
49+
},
50+
],
51+
},
52+
};
53+
},
54+
55+
async onEvent(event): Promise<ClientAction> {
56+
// Return script insertion for pageView events
57+
if (event.type === "pageView") {
58+
return {
59+
items: [
60+
{
61+
type: "script-template",
62+
templateId: CONSOLE_TEMPLATE_ID,
63+
params: { path: event.serverContext?.path || "/" },
64+
},
65+
],
66+
};
67+
}
68+
return { items: [] };
69+
},
70+
71+
updateEvent() {},
72+
};
73+
}
74+
575
const backends = [
676
postgrestBackend({
777
url: process.env.POSTGREST_URL || "http://localhost:3001",
878
tableName: "analytics",
979
}),
80+
consoleTestBackend(),
1081
];
1182

1283
export const { middleware, handlers, analytics } = Nextlytics({

e2e/test-app/src/pages/pages-home.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useSession } from "next-auth/react";
2+
import Link from "next/link";
23

34
export default function PagesHome() {
45
const { data: session } = useSession();
@@ -23,9 +24,9 @@ export default function PagesHome() {
2324
</div>
2425
)}
2526
<nav>
26-
<a href="/pages-test" data-testid="test-page-link">
27+
<Link href="/pages-test" data-testid="test-page-link">
2728
Test Page
28-
</a>
29+
</Link>
2930
</nav>
3031
</main>
3132
);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
import Link from "next/link";
2+
13
export default function PagesTestPage() {
24
return (
35
<main>
46
<h1>Pages Router Test Page</h1>
57
<p data-testid="page-marker">This is a test page for Pages Router</p>
8+
<nav>
9+
<Link href="/pages-home" data-testid="home-link">
10+
Back to Home
11+
</Link>
12+
</nav>
613
</main>
714
);
815
}

e2e/test-app/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"moduleResolution": "bundler",
1212
"resolveJsonModule": true,
1313
"isolatedModules": true,
14-
"jsx": "react-jsx",
14+
"jsx": "preserve",
1515
"incremental": true,
1616
"plugins": [
1717
{

e2e/tests/analytics.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,5 +139,85 @@ describe.each(versions)("%s", (version) => {
139139

140140
await page.close();
141141
});
142+
143+
it("script modes work correctly during soft navigation", async () => {
144+
// This test only applies to App Router (soft navigation with <Link>)
145+
if (routerType !== "app") return;
146+
147+
const page = await testApp.newPage();
148+
149+
// Visit home page
150+
await testApp.visitHome(page);
151+
await page.waitForLoadState("networkidle");
152+
153+
// Wait for initial scripts to execute
154+
await page.waitForFunction(() => window.__nextlyticsTestOnce !== undefined);
155+
156+
// Get initial counters
157+
const initialCounters = await page.evaluate(() => ({
158+
once: window.__nextlyticsTestOnce,
159+
paramsChange: window.__nextlyticsTestParamsChange,
160+
everyRender: window.__nextlyticsTestEveryRender,
161+
}));
162+
163+
expect(initialCounters.once).toBe(1);
164+
expect(initialCounters.paramsChange).toBe(1);
165+
expect(initialCounters.everyRender).toBe(1);
166+
167+
// Soft navigate to test page using Link
168+
await page.click('[data-testid="test-page-link"]');
169+
await page.waitForLoadState("networkidle");
170+
await page.waitForFunction(
171+
(prev) => (window.__nextlyticsTestEveryRender ?? 0) > prev,
172+
initialCounters.everyRender ?? 0
173+
);
174+
175+
// Get counters after soft navigation
176+
const afterNavCounters = await page.evaluate(() => ({
177+
once: window.__nextlyticsTestOnce,
178+
paramsChange: window.__nextlyticsTestParamsChange,
179+
everyRender: window.__nextlyticsTestEveryRender,
180+
}));
181+
182+
// "once" should still be 1 - not re-executed
183+
expect(afterNavCounters.once).toBe(1);
184+
// "on-params-change" should be 2 - path changed from "/" to "/test-page"
185+
expect(afterNavCounters.paramsChange).toBe(2);
186+
// "every-render" should be 2 - runs on every navigation
187+
expect(afterNavCounters.everyRender).toBe(2);
188+
189+
// Navigate back to home
190+
await page.click('[data-testid="home-link"]');
191+
await page.waitForLoadState("networkidle");
192+
await page.waitForFunction(
193+
(prev) => (window.__nextlyticsTestEveryRender ?? 0) > prev,
194+
afterNavCounters.everyRender ?? 0
195+
);
196+
197+
// Get final counters
198+
const finalCounters = await page.evaluate(() => ({
199+
once: window.__nextlyticsTestOnce,
200+
paramsChange: window.__nextlyticsTestParamsChange,
201+
everyRender: window.__nextlyticsTestEveryRender,
202+
}));
203+
204+
// "once" should still be 1
205+
expect(finalCounters.once).toBe(1);
206+
// "on-params-change" should be 3 - path changed again
207+
expect(finalCounters.paramsChange).toBe(3);
208+
// "every-render" should be 3
209+
expect(finalCounters.everyRender).toBe(3);
210+
211+
await page.close();
212+
});
142213
});
143214
});
215+
216+
// Type augmentation for test globals
217+
declare global {
218+
interface Window {
219+
__nextlyticsTestOnce?: number;
220+
__nextlyticsTestParamsChange?: number;
221+
__nextlyticsTestEveryRender?: number;
222+
}
223+
}

packages/core/src/backends/ga.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,19 +192,30 @@ export function googleAnalyticsBackend(
192192
return {
193193
[GA_TEMPLATE_ID]: {
194194
items: [
195+
// External gtag.js - load once
195196
{
196-
async: "true",
197197
src: "https://www.googletagmanager.com/gtag/js?id={{measurementId}}",
198-
singleton: true,
198+
async: true,
199+
mode: "once",
199200
},
201+
// gtag definition and initialization - run once
200202
{
201203
body: [
202204
"window.dataLayer = window.dataLayer || [];",
203205
"function gtag(){dataLayer.push(arguments);}",
206+
"window.gtag = gtag;",
204207
"gtag('js', new Date());",
205-
"gtag('config', '{{measurementId}}', {{json(config)}});",
206-
"gtag('event', 'page_view');",
207208
].join("\n"),
209+
mode: "once",
210+
},
211+
// Config - run when params change (e.g., user_id added after login)
212+
{
213+
body: "gtag('config', '{{measurementId}}', {{json(config)}});",
214+
mode: "on-params-change",
215+
},
216+
// Page view - run on every navigation
217+
{
218+
body: "gtag('event', 'page_view');",
208219
},
209220
],
210221
},

packages/core/src/backends/gtm.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,22 @@ export function googleTagManagerBackend(opts: GoogleTagManagerBackendOptions): N
4747
return {
4848
[GTM_INIT_TEMPLATE_ID]: {
4949
items: [
50+
// GTM script loader - run once
5051
{
5152
body: [
5253
"window.dataLayer = window.dataLayer || [];",
53-
"dataLayer.push({{json(initialData)}});",
54-
"if (!window.google_tag_manager || !window.google_tag_manager['{{containerId}}']) {",
55-
" (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':",
56-
" new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],",
57-
" j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=",
58-
" 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);",
59-
" })(window,document,'script','dataLayer','{{containerId}}');",
60-
"}",
54+
"(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':",
55+
"new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],",
56+
"j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=",
57+
"'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);",
58+
"})(window,document,'script','dataLayer','{{containerId}}');",
6159
].join("\n"),
60+
mode: "once",
61+
},
62+
// Initial data push - run when params change (e.g., user logs in)
63+
{
64+
body: "dataLayer.push({{json(initialData)}});",
65+
mode: "on-params-change",
6266
},
6367
],
6468
},
@@ -69,6 +73,7 @@ export function googleTagManagerBackend(opts: GoogleTagManagerBackendOptions): N
6973
"window.dataLayer = window.dataLayer || [];",
7074
"dataLayer.push({{json(pageData)}});",
7175
].join("\n"),
76+
// default: "every-render"
7277
},
7378
],
7479
},
@@ -79,6 +84,7 @@ export function googleTagManagerBackend(opts: GoogleTagManagerBackendOptions): N
7984
"window.dataLayer = window.dataLayer || [];",
8085
"dataLayer.push({{json(eventData)}});",
8186
].join("\n"),
87+
// default: "every-render"
8288
},
8389
],
8490
},

0 commit comments

Comments
 (0)