diff --git a/e2e/test-app/src/pages/_app.tsx b/e2e/test-app/src/pages/_app.tsx index f53ea50..4bc14a9 100644 --- a/e2e/test-app/src/pages/_app.tsx +++ b/e2e/test-app/src/pages/_app.tsx @@ -18,17 +18,14 @@ function MyApp({ Component, pageProps, nextlyticsCtx }: MyAppProps) { MyApp.getInitialProps = async (appContext: AppContext) => { const { ctx } = appContext; - // Only get nextlytics props on server-side (when req is available) - let nextlyticsCtx: NextlyticsContext = { requestId: "" }; - if (ctx.req) { - nextlyticsCtx = getNextlyticsProps({ req: { headers: ctx.req.headers } }); - } - + // getInitialProps re-runs in the browser on client-side navigation, where + // there is no `ctx.req`. getNextlyticsProps handles that and returns an empty + // context; the client keeps the templates and scripts it already has. return { pageProps: appContext.Component.getInitialProps ? await appContext.Component.getInitialProps(ctx) : {}, - nextlyticsCtx, + nextlyticsCtx: getNextlyticsProps(ctx), }; }; diff --git a/e2e/tests/analytics.test.ts b/e2e/tests/analytics.test.ts index b603b36..0e6352e 100644 --- a/e2e/tests/analytics.test.ts +++ b/e2e/tests/analytics.test.ts @@ -194,6 +194,33 @@ describe.each(versions)("%s", (version) => { await page.close(); }); + it("compiles and runs client-side script templates on initial load", async () => { + // Regression guard for template delivery. App Router gets templates from + // NextlyticsServer; Pages Router can't read config in getNextlyticsProps, + // so it receives them in the /api/event response. Either way the scripts + // must compile and run — without the template, __nextlyticsTestInit stays + // undefined and this times out. + const page = await testApp.newPage(); + + await testApp.visitHome(page); + + await page.waitForFunction(() => window.__nextlyticsTestInit !== undefined, undefined, { + timeout: 5000, + }); + + const counters = await page.evaluate(() => ({ + init: window.__nextlyticsTestInit, + config: window.__nextlyticsTestConfig, + event: window.__nextlyticsTestEvent, + })); + + expect(counters.init).toBeGreaterThanOrEqual(1); + expect(counters.config).toBeGreaterThanOrEqual(1); + expect(counters.event).toBeGreaterThanOrEqual(1); + + await page.close(); + }); + it("script modes work correctly during soft navigation", async () => { // This test only applies to App Router (soft navigation with ) if (routerType !== "app") return; @@ -264,11 +291,11 @@ describe.each(versions)("%s", (version) => { }); }); -// Type augmentation for test globals +// Type augmentation for test globals (set by the console-test backend scripts) declare global { interface Window { - __nextlyticsTestOnce?: number; - __nextlyticsTestParamsChange?: number; - __nextlyticsTestEveryRender?: number; + __nextlyticsTestInit?: number; + __nextlyticsTestConfig?: number; + __nextlyticsTestEvent?: number; } } diff --git a/packages/core/src/api-handler.ts b/packages/core/src/api-handler.ts index f51f91d..3a285e2 100644 --- a/packages/core/src/api-handler.ts +++ b/packages/core/src/api-handler.ts @@ -8,6 +8,7 @@ import type { ClientContext, ClientRequest, DispatchResult, + JavascriptTemplate, PageViewDelivery, NextlyticsEvent, RequestContext, @@ -30,6 +31,9 @@ export type UpdateEvent = ( ctx: RequestContext ) => Promise; +/** Collect the client-side templates from the configured backends. */ +export type CollectTemplates = (ctx: RequestContext) => Record; + type HandlerContext = { pageRenderId: string; isSoftNavigation: boolean; @@ -39,8 +43,26 @@ type HandlerContext = { config: NextlyticsConfigWithDefaults; dispatchEvent: DispatchEvent; updateEvent: UpdateEvent; + collectTemplates: CollectTemplates; + /** Template ids the client already holds (from the known-templates header). */ + knownTemplateIds: Set; }; +/** + * Collect the templates for this request and drop the ones the client already + * has. App Router clients already hold the full set (from the ctx prop), so they + * get nothing back; a Pages Router client receives each template once. Returns + * undefined when there is nothing new, to keep it out of the JSON response. + */ +function newTemplatesFor(hctx: HandlerContext): Record | undefined { + const all = hctx.collectTemplates(hctx.ctx); + const missing: Record = {}; + for (const [id, template] of Object.entries(all)) { + if (!hctx.knownTemplateIds.has(id)) missing[id] = template; + } + return Object.keys(missing).length > 0 ? missing : undefined; +} + function createRequestContext(request: NextRequest): RequestContext { return { headers: request.headers, @@ -158,6 +180,7 @@ async function handleClientInit( return Response.json({ ok: true, items: filterScripts(actions), + templates: newTemplatesFor(hctx), }); } @@ -167,7 +190,7 @@ async function handleClientInit( after(() => completion); after(() => updateEvent(pageRenderId, { clientContext, userContext, anonymousUserId }, ctx)); - return Response.json({ ok: true }); + return Response.json({ ok: true, templates: newTemplatesFor(hctx) }); } async function handleClientEvent( @@ -208,14 +231,19 @@ async function handleClientEvent( const actions = await clientActions; after(() => completion); - return Response.json({ ok: true, items: filterScripts(actions) }); + return Response.json({ + ok: true, + items: filterScripts(actions), + templates: newTemplatesFor(hctx), + }); } export async function handleEventPost( request: NextRequest, config: NextlyticsConfigWithDefaults, dispatchEvent: DispatchEvent, - updateEvent: UpdateEvent + updateEvent: UpdateEvent, + collectTemplates: CollectTemplates ): Promise { const softNavHeader = request.headers.get(analyticsHeaders.isSoftNavigation); const isSoftNavigation = softNavHeader === "1"; @@ -235,6 +263,16 @@ export async function handleEventPost( const apiCallServerContext = createServerContext(request); const userContext = await getUserContext(config, ctx); + const knownTemplatesHeader = request.headers.get(analyticsHeaders.knownTemplates); + const knownTemplateIds = new Set( + knownTemplatesHeader + ? knownTemplatesHeader + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + : [] + ); + const cookiePageRenderId = request.cookies.get(LAST_PAGE_RENDER_ID_COOKIE)?.value; const pageRenderId = isSoftNavigation ? (cookiePageRenderId ?? generateId()) : pageRenderIdHeader; if (isSoftNavigation && !cookiePageRenderId && config.debug) { @@ -252,6 +290,8 @@ export async function handleEventPost( config, dispatchEvent, updateEvent, + collectTemplates, + knownTemplateIds, }; const bodyType = body.type; diff --git a/packages/core/src/client.tsx b/packages/core/src/client.tsx index da963f3..e5f1321 100644 --- a/packages/core/src/client.tsx +++ b/packages/core/src/client.tsx @@ -9,6 +9,7 @@ import { useMemo, useReducer, useRef, + useState, } from "react"; import { useNavigation, debug, InjectScript, type InjectScriptProps } from "./client-utils"; import type { @@ -41,6 +42,8 @@ type NextlyticsContextValue = { addScripts: (scripts: TemplatizedScriptInsertion[]) => void; scriptsRef: React.MutableRefObject[]>; subscribersRef: React.MutableRefObject void>>; + mergeTemplates: (incoming?: Record) => void; + knownTemplateIdsRef: React.MutableRefObject; }; const NextlyticsContext = createContext(null); @@ -227,7 +230,11 @@ function NextlyticsScripts({ async function sendEventToServer( requestId: string, request: ClientRequest, - { signal, isSoftNavigation }: { signal?: AbortSignal; isSoftNavigation?: boolean } = {} + { + signal, + isSoftNavigation, + knownTemplateIds, + }: { signal?: AbortSignal; isSoftNavigation?: boolean; knownTemplateIds?: string[] } = {} ): Promise { try { const headers: Record = { @@ -237,6 +244,11 @@ async function sendEventToServer( if (isSoftNavigation) { headers[headerNames.isSoftNavigation] = "1"; } + // Tell the server which templates we already have so it only sends new ones. + // App Router clients already hold the full set, so nothing comes back. + if (knownTemplateIds?.length) { + headers[headerNames.knownTemplates] = knownTemplateIds.join(","); + } const response = await fetch("/api/event", { method: "POST", headers, @@ -251,9 +263,9 @@ async function sendEventToServer( return { ok: false }; } - // Parse response to get scripts + // Parse response to get scripts (and templates, for Pages Router clients) const data = await response.json().catch(() => ({ ok: response.ok })); - return { ok: data.ok ?? response.ok, items: data.items }; + return { ok: data.ok ?? response.ok, items: data.items, templates: data.templates }; } catch (error) { if (error instanceof Error && error.name === "AbortError") { return { ok: false }; @@ -264,12 +276,43 @@ async function sendEventToServer( } export function NextlyticsClient(props: { ctx: NextlyticsContext; children?: ReactNode }) { - const { requestId, scripts: initialScripts = [], templates = {} } = props.ctx; + const { requestId, scripts: initialScripts = [] } = props.ctx; // Refs for dynamic scripts (from sendEvent calls) - stable, no re-renders const scriptsRef = useRef[]>([]); const subscribersRef = useRef void>>(new Set()); + // Templates can arrive from two places: the ctx prop (App Router's + // NextlyticsServer collects them from config) and the /api/event response + // (Pages Router, where getNextlyticsProps has no access to config). Hold them + // in state and merge from whichever source supplies them, so they survive + // client-side navigations regardless of router. + const [templates, setTemplates] = useState>( + () => props.ctx.templates ?? {} + ); + const mergeTemplates = useCallback((incoming?: Record) => { + if (!incoming) return; + const keys = Object.keys(incoming); + if (keys.length === 0) return; + setTemplates((prev) => { + const hasNew = keys.some((k) => prev[k] !== incoming[k]); + return hasNew ? { ...prev, ...incoming } : prev; + }); + }, []); + + // Merge templates supplied via the ctx prop (App Router). + useEffect(() => { + mergeTemplates(props.ctx.templates); + }, [props.ctx.templates, mergeTemplates]); + + // Mirror the template ids into a ref so sendEventToServer can tell the server + // which templates we already have (sent as a header), without re-creating the + // request callbacks on every merge. + const knownTemplateIdsRef = useRef(Object.keys(props.ctx.templates ?? {})); + useEffect(() => { + knownTemplateIdsRef.current = Object.keys(templates); + }, [templates]); + const addScripts = useCallback((newScripts: TemplatizedScriptInsertion[]) => { debug("Adding scripts", { newCount: newScripts.length, @@ -281,8 +324,16 @@ export function NextlyticsClient(props: { ctx: NextlyticsContext; children?: Rea // Context value is stable - refs don't change identity const contextValue = useMemo( - () => ({ requestId, templates, addScripts, scriptsRef, subscribersRef }), - [requestId, templates, addScripts] + () => ({ + requestId, + templates, + addScripts, + scriptsRef, + subscribersRef, + mergeTemplates, + knownTemplateIdsRef, + }), + [requestId, templates, addScripts, mergeTemplates] ); // Send page-view on mount and soft navigations @@ -292,9 +343,10 @@ export function NextlyticsClient(props: { ctx: NextlyticsContext; children?: Rea sendEventToServer( requestId, { type: "page-view", clientContext, softNavigation: softNavigation || undefined }, - { signal, isSoftNavigation: softNavigation } - ).then(({ items }) => { + { signal, isSoftNavigation: softNavigation, knownTemplateIds: knownTemplateIdsRef.current } + ).then(({ items, templates: responseTemplates }) => { debug("page-view response", { scriptsCount: items?.length ?? 0 }); + mergeTemplates(responseTemplates); if (items?.length) addScripts(items); }); }); @@ -324,28 +376,33 @@ export function useNextlytics(): NextlyticsClientApi { ); } - const { requestId, addScripts } = context; + const { requestId, addScripts, mergeTemplates, knownTemplateIdsRef } = context; const sendEvent = useCallback( async ( eventName: string, opts?: { props?: Record } ): Promise<{ ok: boolean }> => { - const result = await sendEventToServer(requestId, { - type: "custom-event", - name: eventName, - props: opts?.props, - collectedAt: new Date().toISOString(), - clientContext: createClientContext(), - }); + const result = await sendEventToServer( + requestId, + { + type: "custom-event", + name: eventName, + props: opts?.props, + collectedAt: new Date().toISOString(), + clientContext: createClientContext(), + }, + { knownTemplateIds: knownTemplateIdsRef.current } + ); + mergeTemplates(result.templates); if (result.items && result.items.length > 0) { addScripts(result.items); } return { ok: result.ok }; }, - [requestId, addScripts] + [requestId, addScripts, mergeTemplates, knownTemplateIdsRef] ); return { sendEvent }; diff --git a/packages/core/src/middleware.ts b/packages/core/src/middleware.ts index 0ba2604..6a9a9a2 100644 --- a/packages/core/src/middleware.ts +++ b/packages/core/src/middleware.ts @@ -19,6 +19,7 @@ import { handleEventPost, getUserContext, getEventProps, + type CollectTemplates, type DispatchEvent, type UpdateEvent, } from "./api-handler"; @@ -34,7 +35,8 @@ function createRequestContext(request: NextRequest): RequestContext { export function createNextlyticsMiddleware( config: NextlyticsConfigWithDefaults, dispatchEvent: DispatchEvent, - updateEvent: UpdateEvent + updateEvent: UpdateEvent, + collectTemplates: CollectTemplates ): NextMiddleware { const { eventEndpoint } = config; @@ -74,7 +76,7 @@ export function createNextlyticsMiddleware( // Handle event endpoint directly in middleware if (pathname === eventEndpoint) { if (request.method === "POST") { - return handleEventPost(request, config, dispatchEvent, updateEvent); + return handleEventPost(request, config, dispatchEvent, updateEvent, collectTemplates); } return Response.json({ error: "Method not allowed" }, { status: 405 }); } diff --git a/packages/core/src/pages-router.ts b/packages/core/src/pages-router.ts index c259bb5..b598fd5 100644 --- a/packages/core/src/pages-router.ts +++ b/packages/core/src/pages-router.ts @@ -2,16 +2,29 @@ import type { NextlyticsContext } from "./client"; import { restoreServerComponentContext } from "./server-component-context"; export type PagesRouterContext = { - req: { headers: Record; cookies?: Record }; + req?: { + headers: Record; + cookies?: Record; + }; }; /** * Get Nextlytics props for Pages Router _app.tsx. * Reads context from headers set by middleware. + * + * `_app`'s getInitialProps re-runs on every client-side navigation, where there + * is no `req`. Return an empty context in that case rather than throwing — the + * client already has its scripts and templates from the initial render and the + * /api/event round-trip. */ export function getNextlyticsProps(ctx: PagesRouterContext): NextlyticsContext { + const reqHeaders = ctx?.req?.headers; + if (!reqHeaders) { + return { requestId: "" }; + } + const headersList = new Headers(); - for (const [key, value] of Object.entries(ctx.req.headers)) { + for (const [key, value] of Object.entries(reqHeaders)) { if (value) { headersList.set(key, Array.isArray(value) ? value[0] : value); } diff --git a/packages/core/src/server-component-context.ts b/packages/core/src/server-component-context.ts index 8b8257a..f637c33 100644 --- a/packages/core/src/server-component-context.ts +++ b/packages/core/src/server-component-context.ts @@ -10,6 +10,9 @@ const headerNames = { isSoftNavigation: `${HEADER_PREFIX}is-soft-nav`, active: `${HEADER_PREFIX}active`, scripts: `${HEADER_PREFIX}scripts`, + /** Comma-separated template ids the client already holds, so the server only + * returns the ones it's missing (see api-handler / client templates merge). */ + knownTemplates: `${HEADER_PREFIX}known-templates`, } as const; export const LAST_PAGE_RENDER_ID_COOKIE = "last-page-render-id"; diff --git a/packages/core/src/server.tsx b/packages/core/src/server.tsx index f6a2e33..5fe93c9 100644 --- a/packages/core/src/server.tsx +++ b/packages/core/src/server.tsx @@ -235,7 +235,12 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult { return updateEventInternal(eventId, patch, ctx); }; - const middleware = createNextlyticsMiddleware(config, dispatchEventInternal, updateEventInternal); + const middleware = createNextlyticsMiddleware( + config, + dispatchEventInternal, + updateEventInternal, + (ctx) => collectTemplates(config, ctx) + ); /** Server component that provides analytics context to the app */ async function Server({ children }: { children: ReactNode }) { @@ -245,7 +250,9 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult { if (!ctx) { // x-nl-page-render-id absent → check if middleware is at least active if (!headersList.get(headerNames.active)) { - console.warn("[Nextlytics] nextlyticsMiddleware should be added in order for Server to work"); + console.warn( + "[Nextlytics] nextlyticsMiddleware should be added in order for Server to work" + ); } return <>{children}; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0632fa3..e3260c7 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -292,6 +292,9 @@ export type ClientRequest = export type ClientRequestResult = { ok: boolean; items?: ClientActionItem[]; + /** Client-side templates from the backends, so Pages Router clients (which + * can't read them from config) can compile the script insertions. */ + templates?: Record; }; /** Return value from Nextlytics() */