fix(frontend): fix cross-domain PostHog tracking param names and persist bootstrap IDs across OAuth redirects (#12729)

This commit is contained in:
sp.wack
2026-02-04 16:15:53 +04:00
committed by GitHub
parent 25cc55e558
commit 9d8e4c44cc
2 changed files with 118 additions and 16 deletions

View File

@@ -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(
<PostHogWrapper>
@@ -34,10 +36,8 @@ describe("PostHogWrapper", () => {
</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(
<PostHogWrapper>
@@ -60,10 +59,98 @@ describe("PostHogWrapper", () => {
</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(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
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(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
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(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
await screen.findByTestId("child");
expect(sessionStorage.getItem("posthog_bootstrap")).toBeNull();
});
it("should initialize without bootstrap when neither hash nor sessionStorage has IDs", async () => {
render(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
await screen.findByTestId("child");
expect(mockPostHogProvider).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
bootstrap: undefined,
}),
}),
);
});
});

View File

@@ -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 () => {