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
2 changes: 1 addition & 1 deletion apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export default async function RootLayout({
}

return (
<html lang="en">
<html lang="en" translate="no">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased ${wrappedTheme.background}`}
>
Expand Down
40 changes: 12 additions & 28 deletions apps/web/src/components/PlausibleProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
"use client";

import { Suspense, useEffect, useRef } from "react";
import { Suspense, useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { initializePlausible, trackPlausible } from "@/lib/plausible";

/**
* Inner component that uses useSearchParams (requires Suspense boundary).
*/
function PlausibleTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
const isInitialized = useRef(false);
const initialization = useRef<Promise<void> | null>(null);

// Initialize Plausible once
useEffect(() => {
const domain = process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN;

Expand All @@ -23,46 +21,32 @@ function PlausibleTracker() {
return;
}

initialization.current = import("@plausible-analytics/tracker").then(({ init }) => {
if (!isInitialized.current) {
init({
domain,
captureOnLocalhost: process.env.NEXT_PUBLIC_PLAUSIBLE_CAPTURE_LOCALHOST === "true",
outboundLinks: true,
fileDownloads: true,
formSubmissions: true,
autoCapturePageviews: false,
});
isInitialized.current = true;
}
initializePlausible({
domain,
captureOnLocalhost:
process.env.NEXT_PUBLIC_PLAUSIBLE_CAPTURE_LOCALHOST === "true",
outboundLinks: true,
fileDownloads: true,
formSubmissions: true,
autoCapturePageviews: false,
});
}, []);

// Track page views on route changes
useEffect(() => {
if (!process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || !initialization.current) {
if (!process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN) {
return;
}

// Build the full URL for tracking
const url = searchParams.toString()
? `${pathname}?${searchParams.toString()}`
: pathname;

// Track pageview with the current URL
void initialization.current.then(async () => {
const { track } = await import("@plausible-analytics/tracker");
track("pageview", { url });
});
void trackPlausible("pageview", { url });
}, [pathname, searchParams]);

return null;
}

/**
* Initializes Plausible Analytics and tracks page views on route changes.
* Must be rendered within the app to enable tracking.
*/
export function PlausibleProvider() {
return (
<Suspense fallback={null}>
Expand Down
37 changes: 36 additions & 1 deletion apps/web/src/lib/__tests__/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,43 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";

const init = vi.fn();
const track = vi.fn();

vi.mock("@plausible-analytics/tracker", () => ({
init,
track,
}));

afterEach(() => {
vi.clearAllMocks();
Reflect.deleteProperty(globalThis, "window");
});

describe("analytics modules", () => {
it("can load during server rendering without browser globals", async () => {
await expect(import("../analytics")).resolves.toBeDefined();
await expect(import("../../components/PlausibleProvider")).resolves.toBeDefined();
});

it("initializes Plausible once across repeated callers", async () => {
globalThis.window = {} as Window & typeof globalThis;
const { initializePlausible } = await import("../plausible");
const config = { domain: "example.com" };

const first = initializePlausible(config);
const second = initializePlausible(config);

expect(first).toBe(second);
await first;
expect(init).toHaveBeenCalledOnce();
});

it("does not track custom events before initialization", async () => {
globalThis.window = {} as Window & typeof globalThis;
const { trackPlausible } = await import("../plausible");

await trackPlausible("signup", {});

expect(track).not.toHaveBeenCalled();
});
});
11 changes: 5 additions & 6 deletions apps/web/src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,10 @@ export function trackEvent(
return;
}

void import("@plausible-analytics/tracker").then(({ track }) => {
track(eventName, {
props,
interactive: options?.interactive,
revenue: options?.revenue,
});
void trackPlausible(eventName, {
props,
interactive: options?.interactive,
revenue: options?.revenue,
});
}

Expand Down Expand Up @@ -64,3 +62,4 @@ export const AnalyticsEvents = {
} as const;

export type AnalyticsEvent = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
import { trackPlausible } from "./plausible";
40 changes: 40 additions & 0 deletions apps/web/src/lib/plausible.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { PlausibleConfig } from "@plausible-analytics/tracker";

declare global {
interface Window {
__vcpPlausibleInitialization?: Promise<void>;
}
}

export function initializePlausible(
config: PlausibleConfig
): Promise<void> | null {
if (typeof window === "undefined") {
return null;
}

window.__vcpPlausibleInitialization ??= import(
"@plausible-analytics/tracker"
).then(({ init }) => {
init(config);
});

return window.__vcpPlausibleInitialization;
}

export async function trackPlausible(
eventName: string,
options: Parameters<
typeof import("@plausible-analytics/tracker").track
>[1]
): Promise<void> {
const initialization = window.__vcpPlausibleInitialization;

if (!initialization) {
return;
}

await initialization;
const { track } = await import("@plausible-analytics/tracker");
track(eventName, options);
}
Loading