Skip to content

Commit 11efd46

Browse files
committed
fix: handle soft navigation in App Router
- Add soft-navigation event type to fetch scripts on client-side navigation - Use usePathname() to detect navigation changes - Dispatch pageView to immediate backends on soft navigation - Revert pages router to use hard navigation (maintains existing behavior)
1 parent 455402a commit 11efd46

4 files changed

Lines changed: 84 additions & 9 deletions

File tree

e2e/test-app/src/nextlytics.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { auth } from "./auth";
88
type ScriptMode = "once" | "on-params-change" | "every-render";
99
type ScriptElement = { async?: boolean; body?: string; src?: string; mode?: ScriptMode };
1010
type JavascriptTemplate = { items: ScriptElement[] };
11-
type ClientAction = { items: Array<{ type: "script-template"; templateId: string; params: unknown }> };
11+
type ClientAction = {
12+
items: Array<{ type: "script-template"; templateId: string; params: unknown }>;
13+
};
1214

1315
const CONSOLE_TEMPLATE_ID = "console-test";
1416

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

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

43
export default function PagesHome() {
54
const { data: session } = useSession();
@@ -24,9 +23,9 @@ export default function PagesHome() {
2423
</div>
2524
)}
2625
<nav>
27-
<Link href="/pages-test" data-testid="test-page-link">
26+
<a href="/pages-test" data-testid="test-page-link">
2827
Test Page
29-
</Link>
28+
</a>
3029
</nav>
3130
</main>
3231
);

packages/core/src/client.tsx

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
useReducer,
1111
useRef,
1212
} from "react";
13+
import { usePathname } from "next/navigation";
1314
import type {
1415
ClientContext,
1516
JavascriptTemplate,
@@ -138,6 +139,24 @@ function InjectScript({
138139
return null;
139140
}
140141

142+
/** Merge scripts by templateId - later scripts override earlier ones */
143+
function mergeScriptsByTemplateId(
144+
scripts: TemplatizedScriptInsertion<unknown>[]
145+
): TemplatizedScriptInsertion<unknown>[] {
146+
const byTemplateId = new Map<string, TemplatizedScriptInsertion<unknown>>();
147+
const nonTemplates: TemplatizedScriptInsertion<unknown>[] = [];
148+
149+
for (const script of scripts) {
150+
if (script.type === "script-template") {
151+
byTemplateId.set(script.templateId, script);
152+
} else {
153+
nonTemplates.push(script);
154+
}
155+
}
156+
157+
return [...byTemplateId.values(), ...nonTemplates];
158+
}
159+
141160
/** Renders initial scripts (from SSR) and dynamic scripts (from sendEvent calls) */
142161
function NextlyticsScripts({
143162
initialScripts,
@@ -146,7 +165,7 @@ function NextlyticsScripts({
146165
}) {
147166
const context = useContext(NextlyticsContext);
148167
if (!context) {
149-
throw new Error("NextlyticsScripts should be called within NextlyticsContext")
168+
throw new Error("NextlyticsScripts should be called within NextlyticsContext");
150169
}
151170

152171
const { scriptsRef, subscribersRef, templates, requestId } = context;
@@ -161,20 +180,22 @@ function NextlyticsScripts({
161180
}, [subscribersRef]);
162181

163182
const dynamicScripts = scriptsRef.current;
164-
const allScripts = [...initialScripts, ...dynamicScripts];
183+
// Merge by templateId - dynamic scripts override initial (they have newer params)
184+
const allScripts = mergeScriptsByTemplateId([...initialScripts, ...dynamicScripts]);
165185

166186
return (
167187
<>
168-
{allScripts.flatMap((script, scriptIndex) => {
188+
{allScripts.flatMap((script) => {
169189
if (script.type !== "script-template") return [];
170190
const template = templates[script.templateId];
171191
if (!template) {
172192
console.warn(`[Nextlytics] Template "${script.templateId}" not found`);
173193
return [];
174194
}
195+
// Use templateId as key - same template = same component instances
175196
return template.items.map((item, itemIndex) => (
176197
<InjectScript
177-
key={`${scriptIndex}:${script.templateId}:${itemIndex}`}
198+
key={`${script.templateId}:${itemIndex}`}
178199
item={item}
179200
params={script.params}
180201
requestId={requestId}
@@ -224,6 +245,11 @@ async function sendEventToServer(
224245
export function NextlyticsClient(props: { ctx: NextlyticsContext; children?: ReactNode }) {
225246
const { requestId, scripts: initialScripts = [], templates = {} } = props.ctx;
226247

248+
// Track pathname for soft navigation detection
249+
const pathname = usePathname();
250+
const initialPathRef = useRef<string | null>(null);
251+
const lastPathRef = useRef<string | null>(null);
252+
227253
// Refs for dynamic scripts (from sendEvent calls) - stable, no re-renders
228254
const scriptsRef = useRef<TemplatizedScriptInsertion<unknown>[]>([]);
229255
const subscribersRef = useRef<Set<() => void>>(new Set());
@@ -241,11 +267,27 @@ export function NextlyticsClient(props: { ctx: NextlyticsContext; children?: Rea
241267

242268
// Send client-init on mount (once per requestId)
243269
useEffect(() => {
270+
initialPathRef.current = pathname;
271+
lastPathRef.current = pathname;
244272
const clientContext = createClientContext();
245273
sendEventToServer(requestId, "client-init", clientContext).then(({ scripts }) => {
246274
if (scripts?.length) addScripts(scripts);
247275
});
248-
}, [requestId, addScripts]);
276+
}, [requestId, addScripts, pathname]);
277+
278+
// Detect soft navigation and fetch scripts for new page
279+
useEffect(() => {
280+
// Skip if this is the initial render or same path
281+
if (initialPathRef.current === null || pathname === lastPathRef.current) {
282+
return;
283+
}
284+
lastPathRef.current = pathname;
285+
286+
const clientContext = createClientContext();
287+
sendEventToServer(requestId, "soft-navigation", clientContext).then(({ scripts }) => {
288+
if (scripts?.length) addScripts(scripts);
289+
});
290+
}, [pathname, requestId, addScripts]);
249291

250292
return (
251293
<NextlyticsContext.Provider value={contextValue}>

packages/core/src/middleware.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,38 @@ async function handleEventPost(
289289
// Also update "immediate" backends with client context
290290
after(() => updateEvent(pageRenderId, { clientContext, userContext, anonymousUserId }, ctx));
291291

292+
// Filter to script-template only
293+
const scripts = actions.items.filter((i) => i.type === "script-template");
294+
return Response.json({ ok: true, scripts: scripts.length > 0 ? scripts : undefined });
295+
} else if (type === "soft-navigation") {
296+
// Soft navigation in App Router - layout didn't re-render, so we need to
297+
// dispatch pageView and return scripts that would have been in the initial render
298+
const clientContext = payload as unknown as ClientInitPayload;
299+
const serverContext = reconstructServerContext(apiCallServerContext, clientContext);
300+
301+
const { anonId: anonymousUserId } = await resolveAnonymousUser({
302+
ctx,
303+
serverContext,
304+
config,
305+
});
306+
307+
const event: NextlyticsEvent = {
308+
eventId: generateId(), // New event ID for the soft navigation
309+
parentEventId: pageRenderId,
310+
type: "pageView",
311+
collectedAt: new Date().toISOString(),
312+
anonymousUserId,
313+
serverContext,
314+
clientContext,
315+
userContext,
316+
properties: {},
317+
};
318+
319+
// Dispatch to "immediate" backends (same as middleware would do)
320+
const { clientActions, completion } = dispatchEvent(event, ctx, "immediate");
321+
const actions = await clientActions;
322+
after(() => completion);
323+
292324
// Filter to script-template only
293325
const scripts = actions.items.filter((i) => i.type === "script-template");
294326
return Response.json({ ok: true, scripts: scripts.length > 0 ? scripts : undefined });

0 commit comments

Comments
 (0)