From 249d06dc8c2bf5c292cfd3c97a1cefe8297d51ae Mon Sep 17 00:00:00 2001 From: Abou Kone Date: Sun, 21 Jun 2026 22:56:23 -0400 Subject: [PATCH] fix(web): prevent recurring analytics errors Use one browser-global Plausible initialization promise so remounts and custom events cannot reinitialize or race the tracker. Disable automatic browser translation to protect React-owned DOM nodes from external mutation. Co-Authored-By: Claude --- apps/web/src/app/layout.tsx | 2 +- apps/web/src/components/PlausibleProvider.tsx | 40 ++++++------------- apps/web/src/lib/__tests__/analytics.test.ts | 37 ++++++++++++++++- apps/web/src/lib/analytics.ts | 11 +++-- apps/web/src/lib/plausible.ts | 40 +++++++++++++++++++ 5 files changed, 94 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/lib/plausible.ts diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index e684bac..e68d62d 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -88,7 +88,7 @@ export default async function RootLayout({ } return ( - + diff --git a/apps/web/src/components/PlausibleProvider.tsx b/apps/web/src/components/PlausibleProvider.tsx index 5f7964c..83dee19 100644 --- a/apps/web/src/components/PlausibleProvider.tsx +++ b/apps/web/src/components/PlausibleProvider.tsx @@ -1,7 +1,8 @@ "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). @@ -9,10 +10,7 @@ import { usePathname, useSearchParams } from "next/navigation"; function PlausibleTracker() { const pathname = usePathname(); const searchParams = useSearchParams(); - const isInitialized = useRef(false); - const initialization = useRef | null>(null); - // Initialize Plausible once useEffect(() => { const domain = process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN; @@ -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 ( diff --git a/apps/web/src/lib/__tests__/analytics.test.ts b/apps/web/src/lib/__tests__/analytics.test.ts index 6b3ee8c..a03f5c0 100644 --- a/apps/web/src/lib/__tests__/analytics.test.ts +++ b/apps/web/src/lib/__tests__/analytics.test.ts @@ -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(); + }); }); diff --git a/apps/web/src/lib/analytics.ts b/apps/web/src/lib/analytics.ts index e112213..ec8b43b 100644 --- a/apps/web/src/lib/analytics.ts +++ b/apps/web/src/lib/analytics.ts @@ -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, }); } @@ -64,3 +62,4 @@ export const AnalyticsEvents = { } as const; export type AnalyticsEvent = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents]; +import { trackPlausible } from "./plausible"; diff --git a/apps/web/src/lib/plausible.ts b/apps/web/src/lib/plausible.ts new file mode 100644 index 0000000..6eb8634 --- /dev/null +++ b/apps/web/src/lib/plausible.ts @@ -0,0 +1,40 @@ +import type { PlausibleConfig } from "@plausible-analytics/tracker"; + +declare global { + interface Window { + __vcpPlausibleInitialization?: Promise; + } +} + +export function initializePlausible( + config: PlausibleConfig +): Promise | 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 { + const initialization = window.__vcpPlausibleInitialization; + + if (!initialization) { + return; + } + + await initialization; + const { track } = await import("@plausible-analytics/tracker"); + track(eventName, options); +}