Skip to content

Commit 455402a

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 3a0a25c commit 455402a

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,8 +1,78 @@
11
import { Nextlytics } from "@nextlytics/core/server";
22
import type { BackendConfigEntry } from "@nextlytics/core";
33
import { postgrestBackend } from "@nextlytics/core/backends/postgrest";
4+
import type { NextlyticsBackend } from "@nextlytics/core";
45
import { auth } from "./auth";
56

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

2293
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
@@ -184,5 +184,85 @@ describe.each(versions)("%s", (version) => {
184184

185185
await page.close();
186186
});
187+
188+
it("script modes work correctly during soft navigation", async () => {
189+
// This test only applies to App Router (soft navigation with <Link>)
190+
if (routerType !== "app") return;
191+
192+
const page = await testApp.newPage();
193+
194+
// Visit home page
195+
await testApp.visitHome(page);
196+
await page.waitForLoadState("networkidle");
197+
198+
// Wait for initial scripts to execute
199+
await page.waitForFunction(() => window.__nextlyticsTestOnce !== undefined);
200+
201+
// Get initial counters
202+
const initialCounters = await page.evaluate(() => ({
203+
once: window.__nextlyticsTestOnce,
204+
paramsChange: window.__nextlyticsTestParamsChange,
205+
everyRender: window.__nextlyticsTestEveryRender,
206+
}));
207+
208+
expect(initialCounters.once).toBe(1);
209+
expect(initialCounters.paramsChange).toBe(1);
210+
expect(initialCounters.everyRender).toBe(1);
211+
212+
// Soft navigate to test page using Link
213+
await page.click('[data-testid="test-page-link"]');
214+
await page.waitForLoadState("networkidle");
215+
await page.waitForFunction(
216+
(prev) => (window.__nextlyticsTestEveryRender ?? 0) > prev,
217+
initialCounters.everyRender ?? 0
218+
);
219+
220+
// Get counters after soft navigation
221+
const afterNavCounters = await page.evaluate(() => ({
222+
once: window.__nextlyticsTestOnce,
223+
paramsChange: window.__nextlyticsTestParamsChange,
224+
everyRender: window.__nextlyticsTestEveryRender,
225+
}));
226+
227+
// "once" should still be 1 - not re-executed
228+
expect(afterNavCounters.once).toBe(1);
229+
// "on-params-change" should be 2 - path changed from "/" to "/test-page"
230+
expect(afterNavCounters.paramsChange).toBe(2);
231+
// "every-render" should be 2 - runs on every navigation
232+
expect(afterNavCounters.everyRender).toBe(2);
233+
234+
// Navigate back to home
235+
await page.click('[data-testid="home-link"]');
236+
await page.waitForLoadState("networkidle");
237+
await page.waitForFunction(
238+
(prev) => (window.__nextlyticsTestEveryRender ?? 0) > prev,
239+
afterNavCounters.everyRender ?? 0
240+
);
241+
242+
// Get final counters
243+
const finalCounters = await page.evaluate(() => ({
244+
once: window.__nextlyticsTestOnce,
245+
paramsChange: window.__nextlyticsTestParamsChange,
246+
everyRender: window.__nextlyticsTestEveryRender,
247+
}));
248+
249+
// "once" should still be 1
250+
expect(finalCounters.once).toBe(1);
251+
// "on-params-change" should be 3 - path changed again
252+
expect(finalCounters.paramsChange).toBe(3);
253+
// "every-render" should be 3
254+
expect(finalCounters.everyRender).toBe(3);
255+
256+
await page.close();
257+
});
187258
});
188259
});
260+
261+
// Type augmentation for test globals
262+
declare global {
263+
interface Window {
264+
__nextlyticsTestOnce?: number;
265+
__nextlyticsTestParamsChange?: number;
266+
__nextlyticsTestEveryRender?: number;
267+
}
268+
}

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)