Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions e2e/test-app/src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
};

Expand Down
35 changes: 31 additions & 4 deletions e2e/tests/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Link>)
if (routerType !== "app") return;
Expand Down Expand Up @@ -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;
}
}
46 changes: 43 additions & 3 deletions packages/core/src/api-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
ClientContext,
ClientRequest,
DispatchResult,
JavascriptTemplate,
PageViewDelivery,
NextlyticsEvent,
RequestContext,
Expand All @@ -30,6 +31,9 @@ export type UpdateEvent = (
ctx: RequestContext
) => Promise<void>;

/** Collect the client-side templates from the configured backends. */
export type CollectTemplates = (ctx: RequestContext) => Record<string, JavascriptTemplate>;

type HandlerContext = {
pageRenderId: string;
isSoftNavigation: boolean;
Expand All @@ -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<string>;
};

/**
* 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<string, JavascriptTemplate> | undefined {
const all = hctx.collectTemplates(hctx.ctx);
const missing: Record<string, JavascriptTemplate> = {};
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,
Expand Down Expand Up @@ -158,6 +180,7 @@ async function handleClientInit(
return Response.json({
ok: true,
items: filterScripts(actions),
templates: newTemplatesFor(hctx),
});
}

Expand All @@ -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(
Expand Down Expand Up @@ -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<Response> {
const softNavHeader = request.headers.get(analyticsHeaders.isSoftNavigation);
const isSoftNavigation = softNavHeader === "1";
Expand All @@ -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) {
Expand All @@ -252,6 +290,8 @@ export async function handleEventPost(
config,
dispatchEvent,
updateEvent,
collectTemplates,
knownTemplateIds,
};

const bodyType = body.type;
Expand Down
91 changes: 74 additions & 17 deletions packages/core/src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
useMemo,
useReducer,
useRef,
useState,
} from "react";
import { useNavigation, debug, InjectScript, type InjectScriptProps } from "./client-utils";
import type {
Expand Down Expand Up @@ -41,6 +42,8 @@ type NextlyticsContextValue = {
addScripts: (scripts: TemplatizedScriptInsertion<unknown>[]) => void;
scriptsRef: React.MutableRefObject<TemplatizedScriptInsertion<unknown>[]>;
subscribersRef: React.MutableRefObject<Set<() => void>>;
mergeTemplates: (incoming?: Record<string, JavascriptTemplate>) => void;
knownTemplateIdsRef: React.MutableRefObject<string[]>;
};

const NextlyticsContext = createContext<NextlyticsContextValue | null>(null);
Expand Down Expand Up @@ -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<ClientRequestResult> {
try {
const headers: Record<string, string> = {
Expand All @@ -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,
Expand All @@ -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 };
Expand All @@ -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<TemplatizedScriptInsertion<unknown>[]>([]);
const subscribersRef = useRef<Set<() => 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<Record<string, JavascriptTemplate>>(
() => props.ctx.templates ?? {}
);
const mergeTemplates = useCallback((incoming?: Record<string, JavascriptTemplate>) => {
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<string[]>(Object.keys(props.ctx.templates ?? {}));
useEffect(() => {
knownTemplateIdsRef.current = Object.keys(templates);
}, [templates]);

const addScripts = useCallback((newScripts: TemplatizedScriptInsertion<unknown>[]) => {
debug("Adding scripts", {
newCount: newScripts.length,
Expand All @@ -281,8 +324,16 @@ export function NextlyticsClient(props: { ctx: NextlyticsContext; children?: Rea

// Context value is stable - refs don't change identity
const contextValue = useMemo<NextlyticsContextValue>(
() => ({ 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
Expand All @@ -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);
});
});
Expand Down Expand Up @@ -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<string, unknown> }
): 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 };
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
handleEventPost,
getUserContext,
getEventProps,
type CollectTemplates,
type DispatchEvent,
type UpdateEvent,
} from "./api-handler";
Expand All @@ -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;

Expand Down Expand Up @@ -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 });
}
Expand Down
Loading
Loading