mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
fix(frontend): fix cross-domain PostHog tracking param names and persist bootstrap IDs across OAuth redirects (#12729)
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user