Skip to content

Commit b515be4

Browse files
fix: deliver client-side templates to Pages Router clients (#37)
* fix: deliver client-side templates to Pages Router clients Pages Router's getNextlyticsProps runs in getInitialProps without access to config, so it can't collect client-side templates the way App Router's NextlyticsServer does. Include templates in the /api/event response and merge them on the client from whichever source supplies them, so script insertions compile on both routers. Also stop getNextlyticsProps from throwing on client-side navigations, where _app's getInitialProps re-runs with no req. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf: only return templates the client is missing The client now sends the template ids it already holds in a header, and the server diffs against the templates resolved for the request, returning only the new ones. App Router clients hold the full set from the ctx prop, so they get an empty response; a Pages Router client receives each template once instead of on every event. The diff is per-request, so it stays correct when backend resolution (and thus the template set) varies by request. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(e2e): cover Pages Router script delivery, simplify _app example getNextlyticsProps now tolerates a missing req, so the _app example no longer needs to guard ctx.req itself — call it directly, which doubles as the documented usage. Add an initial-load test (both routers) asserting the client-side script templates compile and run. On Pages Router this only works once templates arrive via /api/event, so it guards the fix; the prior script test was App-Router-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style: apply prettier formatting Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4b9d61d commit b515be4

9 files changed

Lines changed: 186 additions & 37 deletions

File tree

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

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,14 @@ function MyApp({ Component, pageProps, nextlyticsCtx }: MyAppProps) {
1818
MyApp.getInitialProps = async (appContext: AppContext) => {
1919
const { ctx } = appContext;
2020

21-
// Only get nextlytics props on server-side (when req is available)
22-
let nextlyticsCtx: NextlyticsContext = { requestId: "" };
23-
if (ctx.req) {
24-
nextlyticsCtx = getNextlyticsProps({ req: { headers: ctx.req.headers } });
25-
}
26-
21+
// getInitialProps re-runs in the browser on client-side navigation, where
22+
// there is no `ctx.req`. getNextlyticsProps handles that and returns an empty
23+
// context; the client keeps the templates and scripts it already has.
2724
return {
2825
pageProps: appContext.Component.getInitialProps
2926
? await appContext.Component.getInitialProps(ctx)
3027
: {},
31-
nextlyticsCtx,
28+
nextlyticsCtx: getNextlyticsProps(ctx),
3229
};
3330
};
3431

e2e/tests/analytics.test.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,33 @@ describe.each(versions)("%s", (version) => {
194194
await page.close();
195195
});
196196

197+
it("compiles and runs client-side script templates on initial load", async () => {
198+
// Regression guard for template delivery. App Router gets templates from
199+
// NextlyticsServer; Pages Router can't read config in getNextlyticsProps,
200+
// so it receives them in the /api/event response. Either way the scripts
201+
// must compile and run — without the template, __nextlyticsTestInit stays
202+
// undefined and this times out.
203+
const page = await testApp.newPage();
204+
205+
await testApp.visitHome(page);
206+
207+
await page.waitForFunction(() => window.__nextlyticsTestInit !== undefined, undefined, {
208+
timeout: 5000,
209+
});
210+
211+
const counters = await page.evaluate(() => ({
212+
init: window.__nextlyticsTestInit,
213+
config: window.__nextlyticsTestConfig,
214+
event: window.__nextlyticsTestEvent,
215+
}));
216+
217+
expect(counters.init).toBeGreaterThanOrEqual(1);
218+
expect(counters.config).toBeGreaterThanOrEqual(1);
219+
expect(counters.event).toBeGreaterThanOrEqual(1);
220+
221+
await page.close();
222+
});
223+
197224
it("script modes work correctly during soft navigation", async () => {
198225
// This test only applies to App Router (soft navigation with <Link>)
199226
if (routerType !== "app") return;
@@ -264,11 +291,11 @@ describe.each(versions)("%s", (version) => {
264291
});
265292
});
266293

267-
// Type augmentation for test globals
294+
// Type augmentation for test globals (set by the console-test backend scripts)
268295
declare global {
269296
interface Window {
270-
__nextlyticsTestOnce?: number;
271-
__nextlyticsTestParamsChange?: number;
272-
__nextlyticsTestEveryRender?: number;
297+
__nextlyticsTestInit?: number;
298+
__nextlyticsTestConfig?: number;
299+
__nextlyticsTestEvent?: number;
273300
}
274301
}

packages/core/src/api-handler.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
ClientContext,
99
ClientRequest,
1010
DispatchResult,
11+
JavascriptTemplate,
1112
PageViewDelivery,
1213
NextlyticsEvent,
1314
RequestContext,
@@ -30,6 +31,9 @@ export type UpdateEvent = (
3031
ctx: RequestContext
3132
) => Promise<void>;
3233

34+
/** Collect the client-side templates from the configured backends. */
35+
export type CollectTemplates = (ctx: RequestContext) => Record<string, JavascriptTemplate>;
36+
3337
type HandlerContext = {
3438
pageRenderId: string;
3539
isSoftNavigation: boolean;
@@ -39,8 +43,26 @@ type HandlerContext = {
3943
config: NextlyticsConfigWithDefaults;
4044
dispatchEvent: DispatchEvent;
4145
updateEvent: UpdateEvent;
46+
collectTemplates: CollectTemplates;
47+
/** Template ids the client already holds (from the known-templates header). */
48+
knownTemplateIds: Set<string>;
4249
};
4350

51+
/**
52+
* Collect the templates for this request and drop the ones the client already
53+
* has. App Router clients already hold the full set (from the ctx prop), so they
54+
* get nothing back; a Pages Router client receives each template once. Returns
55+
* undefined when there is nothing new, to keep it out of the JSON response.
56+
*/
57+
function newTemplatesFor(hctx: HandlerContext): Record<string, JavascriptTemplate> | undefined {
58+
const all = hctx.collectTemplates(hctx.ctx);
59+
const missing: Record<string, JavascriptTemplate> = {};
60+
for (const [id, template] of Object.entries(all)) {
61+
if (!hctx.knownTemplateIds.has(id)) missing[id] = template;
62+
}
63+
return Object.keys(missing).length > 0 ? missing : undefined;
64+
}
65+
4466
function createRequestContext(request: NextRequest): RequestContext {
4567
return {
4668
headers: request.headers,
@@ -158,6 +180,7 @@ async function handleClientInit(
158180
return Response.json({
159181
ok: true,
160182
items: filterScripts(actions),
183+
templates: newTemplatesFor(hctx),
161184
});
162185
}
163186

@@ -167,7 +190,7 @@ async function handleClientInit(
167190
after(() => completion);
168191
after(() => updateEvent(pageRenderId, { clientContext, userContext, anonymousUserId }, ctx));
169192

170-
return Response.json({ ok: true });
193+
return Response.json({ ok: true, templates: newTemplatesFor(hctx) });
171194
}
172195

173196
async function handleClientEvent(
@@ -208,14 +231,19 @@ async function handleClientEvent(
208231
const actions = await clientActions;
209232
after(() => completion);
210233

211-
return Response.json({ ok: true, items: filterScripts(actions) });
234+
return Response.json({
235+
ok: true,
236+
items: filterScripts(actions),
237+
templates: newTemplatesFor(hctx),
238+
});
212239
}
213240

214241
export async function handleEventPost(
215242
request: NextRequest,
216243
config: NextlyticsConfigWithDefaults,
217244
dispatchEvent: DispatchEvent,
218-
updateEvent: UpdateEvent
245+
updateEvent: UpdateEvent,
246+
collectTemplates: CollectTemplates
219247
): Promise<Response> {
220248
const softNavHeader = request.headers.get(analyticsHeaders.isSoftNavigation);
221249
const isSoftNavigation = softNavHeader === "1";
@@ -235,6 +263,16 @@ export async function handleEventPost(
235263
const apiCallServerContext = createServerContext(request);
236264
const userContext = await getUserContext(config, ctx);
237265

266+
const knownTemplatesHeader = request.headers.get(analyticsHeaders.knownTemplates);
267+
const knownTemplateIds = new Set(
268+
knownTemplatesHeader
269+
? knownTemplatesHeader
270+
.split(",")
271+
.map((id) => id.trim())
272+
.filter(Boolean)
273+
: []
274+
);
275+
238276
const cookiePageRenderId = request.cookies.get(LAST_PAGE_RENDER_ID_COOKIE)?.value;
239277
const pageRenderId = isSoftNavigation ? (cookiePageRenderId ?? generateId()) : pageRenderIdHeader;
240278
if (isSoftNavigation && !cookiePageRenderId && config.debug) {
@@ -252,6 +290,8 @@ export async function handleEventPost(
252290
config,
253291
dispatchEvent,
254292
updateEvent,
293+
collectTemplates,
294+
knownTemplateIds,
255295
};
256296

257297
const bodyType = body.type;

packages/core/src/client.tsx

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
useMemo,
1010
useReducer,
1111
useRef,
12+
useState,
1213
} from "react";
1314
import { useNavigation, debug, InjectScript, type InjectScriptProps } from "./client-utils";
1415
import type {
@@ -41,6 +42,8 @@ type NextlyticsContextValue = {
4142
addScripts: (scripts: TemplatizedScriptInsertion<unknown>[]) => void;
4243
scriptsRef: React.MutableRefObject<TemplatizedScriptInsertion<unknown>[]>;
4344
subscribersRef: React.MutableRefObject<Set<() => void>>;
45+
mergeTemplates: (incoming?: Record<string, JavascriptTemplate>) => void;
46+
knownTemplateIdsRef: React.MutableRefObject<string[]>;
4447
};
4548

4649
const NextlyticsContext = createContext<NextlyticsContextValue | null>(null);
@@ -227,7 +230,11 @@ function NextlyticsScripts({
227230
async function sendEventToServer(
228231
requestId: string,
229232
request: ClientRequest,
230-
{ signal, isSoftNavigation }: { signal?: AbortSignal; isSoftNavigation?: boolean } = {}
233+
{
234+
signal,
235+
isSoftNavigation,
236+
knownTemplateIds,
237+
}: { signal?: AbortSignal; isSoftNavigation?: boolean; knownTemplateIds?: string[] } = {}
231238
): Promise<ClientRequestResult> {
232239
try {
233240
const headers: Record<string, string> = {
@@ -237,6 +244,11 @@ async function sendEventToServer(
237244
if (isSoftNavigation) {
238245
headers[headerNames.isSoftNavigation] = "1";
239246
}
247+
// Tell the server which templates we already have so it only sends new ones.
248+
// App Router clients already hold the full set, so nothing comes back.
249+
if (knownTemplateIds?.length) {
250+
headers[headerNames.knownTemplates] = knownTemplateIds.join(",");
251+
}
240252
const response = await fetch("/api/event", {
241253
method: "POST",
242254
headers,
@@ -251,9 +263,9 @@ async function sendEventToServer(
251263
return { ok: false };
252264
}
253265

254-
// Parse response to get scripts
266+
// Parse response to get scripts (and templates, for Pages Router clients)
255267
const data = await response.json().catch(() => ({ ok: response.ok }));
256-
return { ok: data.ok ?? response.ok, items: data.items };
268+
return { ok: data.ok ?? response.ok, items: data.items, templates: data.templates };
257269
} catch (error) {
258270
if (error instanceof Error && error.name === "AbortError") {
259271
return { ok: false };
@@ -264,12 +276,43 @@ async function sendEventToServer(
264276
}
265277

266278
export function NextlyticsClient(props: { ctx: NextlyticsContext; children?: ReactNode }) {
267-
const { requestId, scripts: initialScripts = [], templates = {} } = props.ctx;
279+
const { requestId, scripts: initialScripts = [] } = props.ctx;
268280

269281
// Refs for dynamic scripts (from sendEvent calls) - stable, no re-renders
270282
const scriptsRef = useRef<TemplatizedScriptInsertion<unknown>[]>([]);
271283
const subscribersRef = useRef<Set<() => void>>(new Set());
272284

285+
// Templates can arrive from two places: the ctx prop (App Router's
286+
// NextlyticsServer collects them from config) and the /api/event response
287+
// (Pages Router, where getNextlyticsProps has no access to config). Hold them
288+
// in state and merge from whichever source supplies them, so they survive
289+
// client-side navigations regardless of router.
290+
const [templates, setTemplates] = useState<Record<string, JavascriptTemplate>>(
291+
() => props.ctx.templates ?? {}
292+
);
293+
const mergeTemplates = useCallback((incoming?: Record<string, JavascriptTemplate>) => {
294+
if (!incoming) return;
295+
const keys = Object.keys(incoming);
296+
if (keys.length === 0) return;
297+
setTemplates((prev) => {
298+
const hasNew = keys.some((k) => prev[k] !== incoming[k]);
299+
return hasNew ? { ...prev, ...incoming } : prev;
300+
});
301+
}, []);
302+
303+
// Merge templates supplied via the ctx prop (App Router).
304+
useEffect(() => {
305+
mergeTemplates(props.ctx.templates);
306+
}, [props.ctx.templates, mergeTemplates]);
307+
308+
// Mirror the template ids into a ref so sendEventToServer can tell the server
309+
// which templates we already have (sent as a header), without re-creating the
310+
// request callbacks on every merge.
311+
const knownTemplateIdsRef = useRef<string[]>(Object.keys(props.ctx.templates ?? {}));
312+
useEffect(() => {
313+
knownTemplateIdsRef.current = Object.keys(templates);
314+
}, [templates]);
315+
273316
const addScripts = useCallback((newScripts: TemplatizedScriptInsertion<unknown>[]) => {
274317
debug("Adding scripts", {
275318
newCount: newScripts.length,
@@ -281,8 +324,16 @@ export function NextlyticsClient(props: { ctx: NextlyticsContext; children?: Rea
281324

282325
// Context value is stable - refs don't change identity
283326
const contextValue = useMemo<NextlyticsContextValue>(
284-
() => ({ requestId, templates, addScripts, scriptsRef, subscribersRef }),
285-
[requestId, templates, addScripts]
327+
() => ({
328+
requestId,
329+
templates,
330+
addScripts,
331+
scriptsRef,
332+
subscribersRef,
333+
mergeTemplates,
334+
knownTemplateIdsRef,
335+
}),
336+
[requestId, templates, addScripts, mergeTemplates]
286337
);
287338

288339
// Send page-view on mount and soft navigations
@@ -292,9 +343,10 @@ export function NextlyticsClient(props: { ctx: NextlyticsContext; children?: Rea
292343
sendEventToServer(
293344
requestId,
294345
{ type: "page-view", clientContext, softNavigation: softNavigation || undefined },
295-
{ signal, isSoftNavigation: softNavigation }
296-
).then(({ items }) => {
346+
{ signal, isSoftNavigation: softNavigation, knownTemplateIds: knownTemplateIdsRef.current }
347+
).then(({ items, templates: responseTemplates }) => {
297348
debug("page-view response", { scriptsCount: items?.length ?? 0 });
349+
mergeTemplates(responseTemplates);
298350
if (items?.length) addScripts(items);
299351
});
300352
});
@@ -324,28 +376,33 @@ export function useNextlytics(): NextlyticsClientApi {
324376
);
325377
}
326378

327-
const { requestId, addScripts } = context;
379+
const { requestId, addScripts, mergeTemplates, knownTemplateIdsRef } = context;
328380

329381
const sendEvent = useCallback(
330382
async (
331383
eventName: string,
332384
opts?: { props?: Record<string, unknown> }
333385
): Promise<{ ok: boolean }> => {
334-
const result = await sendEventToServer(requestId, {
335-
type: "custom-event",
336-
name: eventName,
337-
props: opts?.props,
338-
collectedAt: new Date().toISOString(),
339-
clientContext: createClientContext(),
340-
});
386+
const result = await sendEventToServer(
387+
requestId,
388+
{
389+
type: "custom-event",
390+
name: eventName,
391+
props: opts?.props,
392+
collectedAt: new Date().toISOString(),
393+
clientContext: createClientContext(),
394+
},
395+
{ knownTemplateIds: knownTemplateIdsRef.current }
396+
);
341397

398+
mergeTemplates(result.templates);
342399
if (result.items && result.items.length > 0) {
343400
addScripts(result.items);
344401
}
345402

346403
return { ok: result.ok };
347404
},
348-
[requestId, addScripts]
405+
[requestId, addScripts, mergeTemplates, knownTemplateIdsRef]
349406
);
350407

351408
return { sendEvent };

packages/core/src/middleware.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
handleEventPost,
2020
getUserContext,
2121
getEventProps,
22+
type CollectTemplates,
2223
type DispatchEvent,
2324
type UpdateEvent,
2425
} from "./api-handler";
@@ -34,7 +35,8 @@ function createRequestContext(request: NextRequest): RequestContext {
3435
export function createNextlyticsMiddleware(
3536
config: NextlyticsConfigWithDefaults,
3637
dispatchEvent: DispatchEvent,
37-
updateEvent: UpdateEvent
38+
updateEvent: UpdateEvent,
39+
collectTemplates: CollectTemplates
3840
): NextMiddleware {
3941
const { eventEndpoint } = config;
4042

@@ -74,7 +76,7 @@ export function createNextlyticsMiddleware(
7476
// Handle event endpoint directly in middleware
7577
if (pathname === eventEndpoint) {
7678
if (request.method === "POST") {
77-
return handleEventPost(request, config, dispatchEvent, updateEvent);
79+
return handleEventPost(request, config, dispatchEvent, updateEvent, collectTemplates);
7880
}
7981
return Response.json({ error: "Method not allowed" }, { status: 405 });
8082
}

0 commit comments

Comments
 (0)