From 9d8e4c44cc8df8e6c3f405e34822d142a2f46e85 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:15:53 +0400 Subject: [PATCH] fix(frontend): fix cross-domain PostHog tracking param names and persist bootstrap IDs across OAuth redirects (#12729) --- .../providers/posthog-wrapper.test.tsx | 105 ++++++++++++++++-- .../components/providers/posthog-wrapper.tsx | 29 +++-- 2 files changed, 118 insertions(+), 16 deletions(-) diff --git a/frontend/__tests__/components/providers/posthog-wrapper.test.tsx b/frontend/__tests__/components/providers/posthog-wrapper.test.tsx index 15ea5ec17a..bfcf00554f 100644 --- a/frontend/__tests__/components/providers/posthog-wrapper.test.tsx +++ b/frontend/__tests__/components/providers/posthog-wrapper.test.tsx @@ -17,6 +17,8 @@ describe("PostHogWrapper", () => { vi.clearAllMocks(); // Reset URL hash window.location.hash = ""; + // Clear sessionStorage + sessionStorage.clear(); // Mock the config fetch // @ts-expect-error - partial mock vi.spyOn(OptionService, "getConfig").mockResolvedValue({ @@ -24,9 +26,9 @@ describe("PostHogWrapper", () => { }); }); - it("should initialize PostHog with bootstrap IDs from URL hash", async () => { - // Set up URL hash with cross-domain tracking params - window.location.hash = "ph_distinct_id=user-123&ph_session_id=session-456"; + it("should initialize PostHog with bootstrap IDs from URL hash (without ph_ prefix)", async () => { + // Webflow sends distinct_id and session_id without the ph_ prefix + window.location.hash = "distinct_id=user-123&session_id=session-456"; render( @@ -34,10 +36,8 @@ describe("PostHogWrapper", () => { , ); - // Wait for async config fetch and PostHog initialization await screen.findByTestId("child"); - // Verify PostHogProvider was called with bootstrap options expect(mockPostHogProvider).toHaveBeenCalledWith( expect.objectContaining({ options: expect.objectContaining({ @@ -51,8 +51,7 @@ describe("PostHogWrapper", () => { }); it("should clean up URL hash after extracting bootstrap IDs", async () => { - // Set up URL hash with cross-domain tracking params - window.location.hash = "ph_distinct_id=user-123&ph_session_id=session-456"; + window.location.hash = "distinct_id=user-123&session_id=session-456"; render( @@ -60,10 +59,98 @@ describe("PostHogWrapper", () => { , ); - // Wait for async config fetch and PostHog initialization await screen.findByTestId("child"); - // Verify URL hash was cleaned up expect(window.location.hash).toBe(""); }); + + it("should persist bootstrap IDs to sessionStorage for OAuth survival", async () => { + window.location.hash = "distinct_id=user-123&session_id=session-456"; + + render( + +
+ , + ); + + await screen.findByTestId("child"); + + // After extracting from hash, IDs should NOT remain in sessionStorage + // because they were already consumed during this page load. + // But if a full-page redirect happened before PostHog init, + // sessionStorage would still have them for the next load. + // We verify the write happened by checking the provider received the IDs. + expect(mockPostHogProvider).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + bootstrap: { + distinctID: "user-123", + sessionID: "session-456", + }, + }), + }), + ); + }); + + it("should read bootstrap IDs from sessionStorage when hash is absent (post-OAuth)", async () => { + // Simulate returning from OAuth: no hash, but sessionStorage has the IDs + sessionStorage.setItem( + "posthog_bootstrap", + JSON.stringify({ distinctID: "user-123", sessionID: "session-456" }), + ); + + render( + +
+ , + ); + + await screen.findByTestId("child"); + + expect(mockPostHogProvider).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + bootstrap: { + distinctID: "user-123", + sessionID: "session-456", + }, + }), + }), + ); + }); + + it("should clean up sessionStorage after consuming bootstrap IDs", async () => { + sessionStorage.setItem( + "posthog_bootstrap", + JSON.stringify({ distinctID: "user-123", sessionID: "session-456" }), + ); + + render( + +
+ , + ); + + await screen.findByTestId("child"); + + expect(sessionStorage.getItem("posthog_bootstrap")).toBeNull(); + }); + + it("should initialize without bootstrap when neither hash nor sessionStorage has IDs", async () => { + render( + +
+ , + ); + + await screen.findByTestId("child"); + + expect(mockPostHogProvider).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + bootstrap: undefined, + }), + }), + ); + }); }); diff --git a/frontend/src/components/providers/posthog-wrapper.tsx b/frontend/src/components/providers/posthog-wrapper.tsx index 7799fe1713..62f3a0e2ea 100644 --- a/frontend/src/components/providers/posthog-wrapper.tsx +++ b/frontend/src/components/providers/posthog-wrapper.tsx @@ -3,22 +3,37 @@ import { PostHogProvider } from "posthog-js/react"; import OptionService from "#/api/option-service/option-service.api"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; -function getBootstrapFromHash() { +const POSTHOG_BOOTSTRAP_KEY = "posthog_bootstrap"; + +function getBootstrapIds() { + // Try to extract from URL hash (e.g. #distinct_id=abc&session_id=xyz) const hash = window.location.hash.substring(1); const params = new URLSearchParams(hash); - const distinctId = params.get("ph_distinct_id"); - const sessionId = params.get("ph_session_id"); + const distinctId = params.get("distinct_id"); + const sessionId = params.get("session_id"); if (distinctId && sessionId) { - // Remove the PostHog tracking params from URL hash to keep URL clean - // replaceState(state, unused, url) - we pass null state, empty title (ignored by browsers), and the clean URL + const bootstrap = { distinctID: distinctId, sessionID: sessionId }; + + // Persist to sessionStorage so IDs survive full-page OAuth redirects + sessionStorage.setItem(POSTHOG_BOOTSTRAP_KEY, JSON.stringify(bootstrap)); + + // Clean the hash from the URL window.history.replaceState( null, "", window.location.pathname + window.location.search, ); - return { distinctID: distinctId, sessionID: sessionId }; + return bootstrap; } + + // Fallback: check sessionStorage (covers return from OAuth redirect) + const stored = sessionStorage.getItem(POSTHOG_BOOTSTRAP_KEY); + if (stored) { + sessionStorage.removeItem(POSTHOG_BOOTSTRAP_KEY); + return JSON.parse(stored) as { distinctID: string; sessionID: string }; + } + return undefined; } @@ -27,7 +42,7 @@ export function PostHogWrapper({ children }: { children: React.ReactNode }) { null, ); const [isLoading, setIsLoading] = React.useState(true); - const bootstrapIds = React.useMemo(() => getBootstrapFromHash(), []); + const bootstrapIds = React.useMemo(() => getBootstrapIds(), []); React.useEffect(() => { (async () => {