From 2b253237d4942710b2f34115a20a7bcb4a616117 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 09:29:33 -0400 Subject: [PATCH 1/6] fix(canvas): navigate share links in-app instead of via the browser Clicking a canvas/channel share link in a channel thread bounced out to the browser (PostHog Cloud interstitial) which then deep-linked back into the app. The thread feed renders server-emitted announcement messages whose content embeds a portable https share link; those links were rendered as plain external anchors with no in-app interception. Add a client-side parser that recognizes our own share-link URLs (/code/canvas/... and /code/channel/...) and navigates via the in-app router, short-circuiting the browser round-trip. Wired into MentionText (thread + channel surfaces) and MarkdownRenderer (agent chat). External links are untouched. Generated-By: PostHog Code Task-Id: a3001315-524a-4902-b23c-5c6f5b010827 --- .../canvas/components/MentionText.test.tsx | 39 +++++- .../canvas/components/MentionText.tsx | 2 + .../editor/components/MarkdownRenderer.tsx | 2 + packages/ui/src/utils/shareLinks.test.ts | 111 ++++++++++++++++++ packages/ui/src/utils/shareLinks.ts | 107 +++++++++++++++++ 5 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/utils/shareLinks.test.ts create mode 100644 packages/ui/src/utils/shareLinks.ts diff --git a/packages/ui/src/features/canvas/components/MentionText.test.tsx b/packages/ui/src/features/canvas/components/MentionText.test.tsx index 6c9588e4d3..bf01e82421 100644 --- a/packages/ui/src/features/canvas/components/MentionText.test.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.test.tsx @@ -1,7 +1,20 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { MentionText } from "./MentionText"; +const navigateToChannelDashboard = vi.fn(); + +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToChannel: vi.fn(), + navigateToChannelDashboard: (...args: unknown[]) => + navigateToChannelDashboard(...args), + navigateToChannelTask: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + describe("MentionText", () => { it("uses the shared mention styles and emphasizes the current user", () => { render( @@ -42,6 +55,28 @@ describe("MentionText", () => { expect(screen.queryByText("@agent")).not.toBeInTheDocument(); }); + it("navigates in-app instead of the browser for a canvas share link", () => { + render( + , + ); + + const link = screen.getByRole("link", { name: "Signups" }); + const defaultAllowed = fireEvent.click(link); + + expect(defaultAllowed).toBe(false); // preventDefault was called + expect(navigateToChannelDashboard).toHaveBeenCalledWith("chan1", "dash1"); + }); + + it("leaves an external link opening in the browser", () => { + render(); + + const link = screen.getByRole("link", { name: "Docs" }); + const defaultAllowed = fireEvent.click(link); + + expect(defaultAllowed).toBe(true); // default not prevented + expect(navigateToChannelDashboard).not.toHaveBeenCalled(); + }); + it("inherits the surrounding message text size", () => { render(); diff --git a/packages/ui/src/features/canvas/components/MentionText.tsx b/packages/ui/src/features/canvas/components/MentionText.tsx index 41214cf80e..8fbe4bea7f 100644 --- a/packages/ui/src/features/canvas/components/MentionText.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.tsx @@ -1,5 +1,6 @@ import { splitMentionSegments } from "@posthog/shared"; import { splitLinkSegments } from "@posthog/ui/features/canvas/utils/linkify"; +import { handleShareLinkClick } from "@posthog/ui/utils/shareLinks"; import { Fragment, useMemo } from "react"; import "./mention-chip.css"; @@ -105,6 +106,7 @@ export function MentionText({ handleShareLinkClick(segment.href, event)} target="_blank" rel="noopener noreferrer" className="text-[var(--accent-11)] underline underline-offset-2 hover:text-[var(--accent-12)]" diff --git a/packages/ui/src/features/editor/components/MarkdownRenderer.tsx b/packages/ui/src/features/editor/components/MarkdownRenderer.tsx index e024b2d08c..32a43862a0 100644 --- a/packages/ui/src/features/editor/components/MarkdownRenderer.tsx +++ b/packages/ui/src/features/editor/components/MarkdownRenderer.tsx @@ -5,6 +5,7 @@ import { CodeBlock } from "@posthog/ui/primitives/CodeBlock"; import { Divider } from "@posthog/ui/primitives/Divider"; import { HighlightedCode } from "@posthog/ui/primitives/HighlightedCode"; import { List, ListItem } from "@posthog/ui/primitives/List"; +import { handleShareLinkClick } from "@posthog/ui/utils/shareLinks"; import { Blockquote, Checkbox, Code, Kbd, Text } from "@radix-ui/themes"; import { memo, useMemo } from "react"; import type { Components } from "react-markdown"; @@ -91,6 +92,7 @@ export const baseComponents: Components = { { + if (handleShareLinkClick(href, event)) return; if (!isDeeplink || !href) return; event.preventDefault(); openExternalUrl(href); diff --git a/packages/ui/src/utils/shareLinks.test.ts b/packages/ui/src/utils/shareLinks.test.ts new file mode 100644 index 0000000000..ca9b064cc7 --- /dev/null +++ b/packages/ui/src/utils/shareLinks.test.ts @@ -0,0 +1,111 @@ +import { + handleShareLinkClick, + parseShareLink, +} from "@posthog/ui/utils/shareLinks"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const navigateToChannel = vi.fn(); +const navigateToChannelDashboard = vi.fn(); +const navigateToChannelTask = vi.fn(); + +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToChannel: (...args: unknown[]) => navigateToChannel(...args), + navigateToChannelDashboard: (...args: unknown[]) => + navigateToChannelDashboard(...args), + navigateToChannelTask: (...args: unknown[]) => navigateToChannelTask(...args), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("parseShareLink", () => { + it.each([ + [ + "canvas link", + "https://us.posthog.com/code/canvas/chan1/dash1", + { kind: "canvas", channelId: "chan1", dashboardId: "dash1" }, + ], + [ + "canvas link with encoded ids", + "https://us.posthog.com/code/canvas/chan%2F1/dash%202", + { kind: "canvas", channelId: "chan/1", dashboardId: "dash 2" }, + ], + [ + "channel link on the eu host", + "https://eu.posthog.com/code/channel/chan1", + { kind: "channel", channelId: "chan1" }, + ], + [ + "channel thread link", + "https://us.posthog.com/code/channel/chan1/tasks/task1", + { kind: "channel", channelId: "chan1", taskId: "task1" }, + ], + ])("parses a %s", (_label, href, expected) => { + expect(parseShareLink(href)).toEqual(expected); + }); + + it.each([ + ["a non-PostHog host", "https://evil.com/code/canvas/chan1/dash1"], + [ + "an unrelated PostHog path", + "https://us.posthog.com/project/2/dashboard/1", + ], + [ + "a canvas link missing the dashboard id", + "https://us.posthog.com/code/canvas/chan1", + ], + [ + "a channel thread link with a malformed tail", + "https://us.posthog.com/code/channel/chan1/foo/task1", + ], + ["a malformed url", "not a url"], + ])("returns null for %s", (_label, href) => { + expect(parseShareLink(href)).toBeNull(); + }); +}); + +describe("handleShareLinkClick", () => { + it("navigates in-app and cancels the default open for a share link", () => { + const event = { preventDefault: vi.fn() }; + + const handled = handleShareLinkClick( + "https://us.posthog.com/code/canvas/chan1/dash1", + event, + ); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(navigateToChannelDashboard).toHaveBeenCalledWith("chan1", "dash1"); + }); + + it("routes a channel thread link to the task navigator", () => { + const event = { preventDefault: vi.fn() }; + + handleShareLinkClick( + "https://us.posthog.com/code/channel/chan1/tasks/task1", + event, + ); + + expect(navigateToChannelTask).toHaveBeenCalledWith("chan1", "task1"); + }); + + it("leaves an external link alone", () => { + const event = { preventDefault: vi.fn() }; + + const handled = handleShareLinkClick("https://example.com/docs", event); + + expect(handled).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToChannel).not.toHaveBeenCalled(); + expect(navigateToChannelDashboard).not.toHaveBeenCalled(); + expect(navigateToChannelTask).not.toHaveBeenCalled(); + }); + + it("returns false for a missing href", () => { + const event = { preventDefault: vi.fn() }; + + expect(handleShareLinkClick(undefined, event)).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/utils/shareLinks.ts b/packages/ui/src/utils/shareLinks.ts new file mode 100644 index 0000000000..73673231cb --- /dev/null +++ b/packages/ui/src/utils/shareLinks.ts @@ -0,0 +1,107 @@ +import { type CloudRegion, getCloudUrlFromRegion } from "@posthog/shared"; +import { + navigateToChannel, + navigateToChannelDashboard, + navigateToChannelTask, +} from "@posthog/ui/router/navigationBridge"; + +// The in-app destination a PostHog Code share link points at. The inverse of the +// `canvasShareUrl` / `channelShareUrl` builders in `posthogLinks.ts`. +export type ShareLinkTarget = + | { kind: "canvas"; channelId: string; dashboardId: string } + | { kind: "channel"; channelId: string; taskId?: string }; + +const REGIONS: CloudRegion[] = ["us", "eu", "dev"]; + +// Hosts we recognise as PostHog share-link origins. We match every region (not +// just the signed-in one) so a link works in-app regardless of which instance +// it was minted on — the inbound deep-link handlers already navigate by id +// against the current session, so bouncing through the browser buys nothing. +const POSTHOG_HOSTS = new Set( + REGIONS.map((region) => { + try { + return new URL(getCloudUrlFromRegion(region)).host; + } catch { + return ""; + } + }).filter(Boolean), +); + +/** + * Parse a PostHog Code share link into its in-app navigation target, or `null` + * if it isn't one. Recognises `/code/canvas//` and + * `/code/channel/[/tasks/]` on a known PostHog host. The + * host check keeps us from hijacking unrelated links that happen to share the + * path shape. + */ +export function parseShareLink(href: string): ShareLinkTarget | null { + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + if (!POSTHOG_HOSTS.has(url.host)) return null; + + // Split the still-encoded pathname first, then decode each segment, so an id + // containing an encoded slash (`%2F`) stays a single segment. + const segments = url.pathname + .split("/") + .filter(Boolean) + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }); + + if (segments[0] !== "code") return null; + + if (segments[1] === "canvas" && segments.length === 4) { + return { kind: "canvas", channelId: segments[2], dashboardId: segments[3] }; + } + + if (segments[1] === "channel") { + if (segments.length === 3) { + return { kind: "channel", channelId: segments[2] }; + } + if (segments.length === 5 && segments[3] === "tasks") { + return { kind: "channel", channelId: segments[2], taskId: segments[4] }; + } + } + + return null; +} + +export function navigateToShareTarget(target: ShareLinkTarget): void { + switch (target.kind) { + case "canvas": + navigateToChannelDashboard(target.channelId, target.dashboardId); + break; + case "channel": + if (target.taskId) { + navigateToChannelTask(target.channelId, target.taskId); + } else { + navigateToChannel(target.channelId); + } + break; + } +} + +/** + * If `href` is a PostHog Code share link, navigate to it in-app and return true + * (cancelling the click's default open-in-browser). Otherwise return false so + * the caller lets the link open externally as usual. + */ +export function handleShareLinkClick( + href: string | undefined, + event: { preventDefault: () => void }, +): boolean { + if (!href) return false; + const target = parseShareLink(href); + if (!target) return false; + event.preventDefault(); + navigateToShareTarget(target); + return true; +} From 3e645f823f46780fb50b1b026879cc94cdc58ba2 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 09:36:26 -0400 Subject: [PATCH 2/6] refactor(canvas): co-locate share-link parser with its builders Move parseShareLink next to canvasShareUrl/channelShareUrl in posthogLinks.ts so the /code/canvas and /code/channel path shapes live in one place (the parser is the inverse of the builders). Derive the recognized-host set from REGION_LABELS instead of a hardcoded region list. shareLinks.ts is now just the navigation glue (navigateToShareTarget/handleShareLinkClick). Generated-By: PostHog Code Task-Id: a3001315-524a-4902-b23c-5c6f5b010827 --- packages/ui/src/utils/posthogLinks.test.ts | 47 +++++++++++++ packages/ui/src/utils/posthogLinks.ts | 76 +++++++++++++++++++++- packages/ui/src/utils/shareLinks.test.ts | 51 +-------------- packages/ui/src/utils/shareLinks.ts | 74 ++------------------- 4 files changed, 127 insertions(+), 121 deletions(-) diff --git a/packages/ui/src/utils/posthogLinks.test.ts b/packages/ui/src/utils/posthogLinks.test.ts index cc3acef9d9..0d84e0a7f8 100644 --- a/packages/ui/src/utils/posthogLinks.test.ts +++ b/packages/ui/src/utils/posthogLinks.test.ts @@ -1,6 +1,7 @@ import { canvasShareUrl, errorTrackingIssueUrl, + parseShareLink, } from "@posthog/ui/utils/posthogLinks"; import { describe, expect, it, vi } from "vitest"; @@ -16,6 +17,52 @@ describe("canvasShareUrl", () => { }); }); +describe("parseShareLink", () => { + it.each([ + [ + "canvas link", + "https://us.posthog.com/code/canvas/chan1/dash1", + { kind: "canvas", channelId: "chan1", dashboardId: "dash1" }, + ], + [ + "canvas link with encoded ids", + "https://us.posthog.com/code/canvas/chan%2F1/dash%202", + { kind: "canvas", channelId: "chan/1", dashboardId: "dash 2" }, + ], + [ + "channel link on the eu host", + "https://eu.posthog.com/code/channel/chan1", + { kind: "channel", channelId: "chan1" }, + ], + [ + "channel thread link", + "https://us.posthog.com/code/channel/chan1/tasks/task1", + { kind: "channel", channelId: "chan1", taskId: "task1" }, + ], + ])("parses a %s", (_label, href, expected) => { + expect(parseShareLink(href)).toEqual(expected); + }); + + it.each([ + ["a non-PostHog host", "https://evil.com/code/canvas/chan1/dash1"], + [ + "an unrelated PostHog path", + "https://us.posthog.com/project/2/dashboard/1", + ], + [ + "a canvas link missing the dashboard id", + "https://us.posthog.com/code/canvas/chan1", + ], + [ + "a channel thread link with a malformed tail", + "https://us.posthog.com/code/channel/chan1/foo/task1", + ], + ["a malformed url", "not a url"], + ])("returns null for %s", (_label, href) => { + expect(parseShareLink(href)).toBeNull(); + }); +}); + describe("errorTrackingIssueUrl", () => { it("links to the issue when no fingerprint is provided", () => { expect( diff --git a/packages/ui/src/utils/posthogLinks.ts b/packages/ui/src/utils/posthogLinks.ts index b5df06abf1..2e16408984 100644 --- a/packages/ui/src/utils/posthogLinks.ts +++ b/packages/ui/src/utils/posthogLinks.ts @@ -1,4 +1,8 @@ -import type { CloudRegion } from "@posthog/shared"; +import { + type CloudRegion, + getCloudUrlFromRegion, + REGION_LABELS, +} from "@posthog/shared"; import { useAuthStore } from "@posthog/ui/features/auth/store"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; @@ -117,6 +121,76 @@ export function channelShareUrl( ); } +/** + * The in-app destination a PostHog Code share link points at — the inverse of + * the `canvasShareUrl` / `channelShareUrl` builders above. + */ +export type ShareLinkTarget = + | { kind: "canvas"; channelId: string; dashboardId: string } + | { kind: "channel"; channelId: string; taskId?: string }; + +// Hosts we recognise as PostHog share-link origins, one per cloud region. The +// host check keeps `parseShareLink` from hijacking unrelated links that happen +// to share the `/code/...` path shape. +const POSTHOG_HOSTS = new Set( + (Object.keys(REGION_LABELS) as CloudRegion[]) + .map((region) => { + try { + return new URL(getCloudUrlFromRegion(region)).host; + } catch { + return ""; + } + }) + .filter(Boolean), +); + +/** + * Parse a PostHog Code share link into its in-app navigation target, or `null` + * if it isn't one. Recognises the `/code/canvas/...` and `/code/channel/...` + * links built above, on any region's host — the inbound deep-link handlers + * navigate by id against the current session, so bouncing through the browser + * to reach the app buys nothing. + */ +export function parseShareLink(href: string): ShareLinkTarget | null { + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + if (!POSTHOG_HOSTS.has(url.host)) return null; + + // Split the still-encoded pathname first, then decode each segment, so an id + // containing an encoded slash (`%2F`) stays a single segment. + const segments = url.pathname + .split("/") + .filter(Boolean) + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }); + + if (segments[0] !== "code") return null; + + if (segments[1] === "canvas" && segments.length === 4) { + return { kind: "canvas", channelId: segments[2], dashboardId: segments[3] }; + } + + if (segments[1] === "channel") { + if (segments.length === 3) { + return { kind: "channel", channelId: segments[2] }; + } + if (segments.length === 5 && segments[3] === "tasks") { + return { kind: "channel", channelId: segments[2], taskId: segments[4] }; + } + } + + return null; +} + export function errorTrackingIssueUrl( issueId: string, overrides?: ErrorTrackingIssueLinkOverrides, diff --git a/packages/ui/src/utils/shareLinks.test.ts b/packages/ui/src/utils/shareLinks.test.ts index ca9b064cc7..2bad7e8da4 100644 --- a/packages/ui/src/utils/shareLinks.test.ts +++ b/packages/ui/src/utils/shareLinks.test.ts @@ -1,7 +1,4 @@ -import { - handleShareLinkClick, - parseShareLink, -} from "@posthog/ui/utils/shareLinks"; +import { handleShareLinkClick } from "@posthog/ui/utils/shareLinks"; import { beforeEach, describe, expect, it, vi } from "vitest"; const navigateToChannel = vi.fn(); @@ -19,52 +16,6 @@ beforeEach(() => { vi.clearAllMocks(); }); -describe("parseShareLink", () => { - it.each([ - [ - "canvas link", - "https://us.posthog.com/code/canvas/chan1/dash1", - { kind: "canvas", channelId: "chan1", dashboardId: "dash1" }, - ], - [ - "canvas link with encoded ids", - "https://us.posthog.com/code/canvas/chan%2F1/dash%202", - { kind: "canvas", channelId: "chan/1", dashboardId: "dash 2" }, - ], - [ - "channel link on the eu host", - "https://eu.posthog.com/code/channel/chan1", - { kind: "channel", channelId: "chan1" }, - ], - [ - "channel thread link", - "https://us.posthog.com/code/channel/chan1/tasks/task1", - { kind: "channel", channelId: "chan1", taskId: "task1" }, - ], - ])("parses a %s", (_label, href, expected) => { - expect(parseShareLink(href)).toEqual(expected); - }); - - it.each([ - ["a non-PostHog host", "https://evil.com/code/canvas/chan1/dash1"], - [ - "an unrelated PostHog path", - "https://us.posthog.com/project/2/dashboard/1", - ], - [ - "a canvas link missing the dashboard id", - "https://us.posthog.com/code/canvas/chan1", - ], - [ - "a channel thread link with a malformed tail", - "https://us.posthog.com/code/channel/chan1/foo/task1", - ], - ["a malformed url", "not a url"], - ])("returns null for %s", (_label, href) => { - expect(parseShareLink(href)).toBeNull(); - }); -}); - describe("handleShareLinkClick", () => { it("navigates in-app and cancels the default open for a share link", () => { const event = { preventDefault: vi.fn() }; diff --git a/packages/ui/src/utils/shareLinks.ts b/packages/ui/src/utils/shareLinks.ts index 73673231cb..75e89481e7 100644 --- a/packages/ui/src/utils/shareLinks.ts +++ b/packages/ui/src/utils/shareLinks.ts @@ -1,78 +1,12 @@ -import { type CloudRegion, getCloudUrlFromRegion } from "@posthog/shared"; import { navigateToChannel, navigateToChannelDashboard, navigateToChannelTask, } from "@posthog/ui/router/navigationBridge"; - -// The in-app destination a PostHog Code share link points at. The inverse of the -// `canvasShareUrl` / `channelShareUrl` builders in `posthogLinks.ts`. -export type ShareLinkTarget = - | { kind: "canvas"; channelId: string; dashboardId: string } - | { kind: "channel"; channelId: string; taskId?: string }; - -const REGIONS: CloudRegion[] = ["us", "eu", "dev"]; - -// Hosts we recognise as PostHog share-link origins. We match every region (not -// just the signed-in one) so a link works in-app regardless of which instance -// it was minted on — the inbound deep-link handlers already navigate by id -// against the current session, so bouncing through the browser buys nothing. -const POSTHOG_HOSTS = new Set( - REGIONS.map((region) => { - try { - return new URL(getCloudUrlFromRegion(region)).host; - } catch { - return ""; - } - }).filter(Boolean), -); - -/** - * Parse a PostHog Code share link into its in-app navigation target, or `null` - * if it isn't one. Recognises `/code/canvas//` and - * `/code/channel/[/tasks/]` on a known PostHog host. The - * host check keeps us from hijacking unrelated links that happen to share the - * path shape. - */ -export function parseShareLink(href: string): ShareLinkTarget | null { - let url: URL; - try { - url = new URL(href); - } catch { - return null; - } - if (!POSTHOG_HOSTS.has(url.host)) return null; - - // Split the still-encoded pathname first, then decode each segment, so an id - // containing an encoded slash (`%2F`) stays a single segment. - const segments = url.pathname - .split("/") - .filter(Boolean) - .map((segment) => { - try { - return decodeURIComponent(segment); - } catch { - return segment; - } - }); - - if (segments[0] !== "code") return null; - - if (segments[1] === "canvas" && segments.length === 4) { - return { kind: "canvas", channelId: segments[2], dashboardId: segments[3] }; - } - - if (segments[1] === "channel") { - if (segments.length === 3) { - return { kind: "channel", channelId: segments[2] }; - } - if (segments.length === 5 && segments[3] === "tasks") { - return { kind: "channel", channelId: segments[2], taskId: segments[4] }; - } - } - - return null; -} +import { + parseShareLink, + type ShareLinkTarget, +} from "@posthog/ui/utils/posthogLinks"; export function navigateToShareTarget(target: ShareLinkTarget): void { switch (target.kind) { From 089ba240c02c44a74bd5b7bc16bb32948918409b Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 09:39:09 -0400 Subject: [PATCH 3/6] chore(canvas): drop comments from share-link code Generated-By: PostHog Code Task-Id: a3001315-524a-4902-b23c-5c6f5b010827 --- .../canvas/components/MentionText.test.tsx | 4 ++-- packages/ui/src/utils/posthogLinks.ts | 16 ---------------- packages/ui/src/utils/shareLinks.ts | 5 ----- 3 files changed, 2 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/features/canvas/components/MentionText.test.tsx b/packages/ui/src/features/canvas/components/MentionText.test.tsx index bf01e82421..e924736b4e 100644 --- a/packages/ui/src/features/canvas/components/MentionText.test.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.test.tsx @@ -63,7 +63,7 @@ describe("MentionText", () => { const link = screen.getByRole("link", { name: "Signups" }); const defaultAllowed = fireEvent.click(link); - expect(defaultAllowed).toBe(false); // preventDefault was called + expect(defaultAllowed).toBe(false); expect(navigateToChannelDashboard).toHaveBeenCalledWith("chan1", "dash1"); }); @@ -73,7 +73,7 @@ describe("MentionText", () => { const link = screen.getByRole("link", { name: "Docs" }); const defaultAllowed = fireEvent.click(link); - expect(defaultAllowed).toBe(true); // default not prevented + expect(defaultAllowed).toBe(true); expect(navigateToChannelDashboard).not.toHaveBeenCalled(); }); diff --git a/packages/ui/src/utils/posthogLinks.ts b/packages/ui/src/utils/posthogLinks.ts index 2e16408984..723aa92862 100644 --- a/packages/ui/src/utils/posthogLinks.ts +++ b/packages/ui/src/utils/posthogLinks.ts @@ -121,17 +121,10 @@ export function channelShareUrl( ); } -/** - * The in-app destination a PostHog Code share link points at — the inverse of - * the `canvasShareUrl` / `channelShareUrl` builders above. - */ export type ShareLinkTarget = | { kind: "canvas"; channelId: string; dashboardId: string } | { kind: "channel"; channelId: string; taskId?: string }; -// Hosts we recognise as PostHog share-link origins, one per cloud region. The -// host check keeps `parseShareLink` from hijacking unrelated links that happen -// to share the `/code/...` path shape. const POSTHOG_HOSTS = new Set( (Object.keys(REGION_LABELS) as CloudRegion[]) .map((region) => { @@ -144,13 +137,6 @@ const POSTHOG_HOSTS = new Set( .filter(Boolean), ); -/** - * Parse a PostHog Code share link into its in-app navigation target, or `null` - * if it isn't one. Recognises the `/code/canvas/...` and `/code/channel/...` - * links built above, on any region's host — the inbound deep-link handlers - * navigate by id against the current session, so bouncing through the browser - * to reach the app buys nothing. - */ export function parseShareLink(href: string): ShareLinkTarget | null { let url: URL; try { @@ -160,8 +146,6 @@ export function parseShareLink(href: string): ShareLinkTarget | null { } if (!POSTHOG_HOSTS.has(url.host)) return null; - // Split the still-encoded pathname first, then decode each segment, so an id - // containing an encoded slash (`%2F`) stays a single segment. const segments = url.pathname .split("/") .filter(Boolean) diff --git a/packages/ui/src/utils/shareLinks.ts b/packages/ui/src/utils/shareLinks.ts index 75e89481e7..323872a182 100644 --- a/packages/ui/src/utils/shareLinks.ts +++ b/packages/ui/src/utils/shareLinks.ts @@ -23,11 +23,6 @@ export function navigateToShareTarget(target: ShareLinkTarget): void { } } -/** - * If `href` is a PostHog Code share link, navigate to it in-app and return true - * (cancelling the click's default open-in-browser). Otherwise return false so - * the caller lets the link open externally as usual. - */ export function handleShareLinkClick( href: string | undefined, event: { preventDefault: () => void }, From 6b0e4e793b33bd5cb9f2cf07a1cb3b5e83ea94cc Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 09:44:05 -0400 Subject: [PATCH 4/6] refactor(canvas): split share-link parsing into per-type helpers Generated-By: PostHog Code Task-Id: a3001315-524a-4902-b23c-5c6f5b010827 --- packages/ui/src/utils/posthogLinks.ts | 50 ++++++++++++++++----------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/utils/posthogLinks.ts b/packages/ui/src/utils/posthogLinks.ts index 723aa92862..f7f48acc27 100644 --- a/packages/ui/src/utils/posthogLinks.ts +++ b/packages/ui/src/utils/posthogLinks.ts @@ -137,16 +137,8 @@ const POSTHOG_HOSTS = new Set( .filter(Boolean), ); -export function parseShareLink(href: string): ShareLinkTarget | null { - let url: URL; - try { - url = new URL(href); - } catch { - return null; - } - if (!POSTHOG_HOSTS.has(url.host)) return null; - - const segments = url.pathname +function decodePathSegments(pathname: string): string[] { + return pathname .split("/") .filter(Boolean) .map((segment) => { @@ -156,23 +148,39 @@ export function parseShareLink(href: string): ShareLinkTarget | null { return segment; } }); +} - if (segments[0] !== "code") return null; +function parseCanvasShareLink(segments: string[]): ShareLinkTarget | null { + const [root, kind, channelId, dashboardId] = segments; + if (root === "code" && kind === "canvas" && segments.length === 4) { + return { kind: "canvas", channelId, dashboardId }; + } + return null; +} - if (segments[1] === "canvas" && segments.length === 4) { - return { kind: "canvas", channelId: segments[2], dashboardId: segments[3] }; +function parseChannelShareLink(segments: string[]): ShareLinkTarget | null { + const [root, kind, channelId, maybeTasks, taskId] = segments; + if (root !== "code" || kind !== "channel") return null; + if (segments.length === 3) { + return { kind: "channel", channelId }; + } + if (segments.length === 5 && maybeTasks === "tasks") { + return { kind: "channel", channelId, taskId }; } + return null; +} - if (segments[1] === "channel") { - if (segments.length === 3) { - return { kind: "channel", channelId: segments[2] }; - } - if (segments.length === 5 && segments[3] === "tasks") { - return { kind: "channel", channelId: segments[2], taskId: segments[4] }; - } +export function parseShareLink(href: string): ShareLinkTarget | null { + let url: URL; + try { + url = new URL(href); + } catch { + return null; } + if (!POSTHOG_HOSTS.has(url.host)) return null; - return null; + const segments = decodePathSegments(url.pathname); + return parseCanvasShareLink(segments) ?? parseChannelShareLink(segments); } export function errorTrackingIssueUrl( From 4ce850e7299da227676267dc21a21f3cffc77ad0 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 09:47:51 -0400 Subject: [PATCH 5/6] refactor(canvas): match share links against a route table Generated-By: PostHog Code Task-Id: a3001315-524a-4902-b23c-5c6f5b010827 --- packages/ui/src/utils/posthogLinks.ts | 61 +++++++++++++++++++-------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/utils/posthogLinks.ts b/packages/ui/src/utils/posthogLinks.ts index f7f48acc27..4db4fb3aec 100644 --- a/packages/ui/src/utils/posthogLinks.ts +++ b/packages/ui/src/utils/posthogLinks.ts @@ -137,6 +137,30 @@ const POSTHOG_HOSTS = new Set( .filter(Boolean), ); +interface ShareLinkRoute { + pattern: string[]; + build: (params: Record) => ShareLinkTarget; +} + +const SHARE_LINK_ROUTES: ShareLinkRoute[] = [ + { + pattern: ["code", "canvas", ":channelId", ":dashboardId"], + build: ({ channelId, dashboardId }) => ({ + kind: "canvas", + channelId, + dashboardId, + }), + }, + { + pattern: ["code", "channel", ":channelId"], + build: ({ channelId }) => ({ kind: "channel", channelId }), + }, + { + pattern: ["code", "channel", ":channelId", "tasks", ":taskId"], + build: ({ channelId, taskId }) => ({ kind: "channel", channelId, taskId }), + }, +]; + function decodePathSegments(pathname: string): string[] { return pathname .split("/") @@ -150,24 +174,21 @@ function decodePathSegments(pathname: string): string[] { }); } -function parseCanvasShareLink(segments: string[]): ShareLinkTarget | null { - const [root, kind, channelId, dashboardId] = segments; - if (root === "code" && kind === "canvas" && segments.length === 4) { - return { kind: "canvas", channelId, dashboardId }; +function matchRoute( + segments: string[], + route: ShareLinkRoute, +): ShareLinkTarget | null { + if (segments.length !== route.pattern.length) return null; + const params: Record = {}; + for (const [index, token] of route.pattern.entries()) { + const segment = segments[index]; + if (token.startsWith(":")) { + params[token.slice(1)] = segment; + } else if (token !== segment) { + return null; + } } - return null; -} - -function parseChannelShareLink(segments: string[]): ShareLinkTarget | null { - const [root, kind, channelId, maybeTasks, taskId] = segments; - if (root !== "code" || kind !== "channel") return null; - if (segments.length === 3) { - return { kind: "channel", channelId }; - } - if (segments.length === 5 && maybeTasks === "tasks") { - return { kind: "channel", channelId, taskId }; - } - return null; + return route.build(params); } export function parseShareLink(href: string): ShareLinkTarget | null { @@ -180,7 +201,11 @@ export function parseShareLink(href: string): ShareLinkTarget | null { if (!POSTHOG_HOSTS.has(url.host)) return null; const segments = decodePathSegments(url.pathname); - return parseCanvasShareLink(segments) ?? parseChannelShareLink(segments); + for (const route of SHARE_LINK_ROUTES) { + const target = matchRoute(segments, route); + if (target) return target; + } + return null; } export function errorTrackingIssueUrl( From 01765468f44be2c33dd5498a77e0db8669a29c84 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 10:19:45 -0400 Subject: [PATCH 6/6] fix(canvas): let modified clicks on share links open in a new tab A Cmd/Ctrl/Shift/middle click on a share link is explicit intent to open it elsewhere; don't hijack it for in-app navigation. handleShareLinkClick now bails on modified clicks so the anchor's target="_blank" default runs. Generated-By: PostHog Code Task-Id: a3001315-524a-4902-b23c-5c6f5b010827 --- packages/ui/src/utils/shareLinks.test.ts | 21 +++++++++++++++++++++ packages/ui/src/utils/shareLinks.ts | 23 +++++++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/utils/shareLinks.test.ts b/packages/ui/src/utils/shareLinks.test.ts index 2bad7e8da4..7184867d6a 100644 --- a/packages/ui/src/utils/shareLinks.test.ts +++ b/packages/ui/src/utils/shareLinks.test.ts @@ -41,6 +41,27 @@ describe("handleShareLinkClick", () => { expect(navigateToChannelTask).toHaveBeenCalledWith("chan1", "task1"); }); + it.each([ + ["meta", { metaKey: true }], + ["ctrl", { ctrlKey: true }], + ["shift", { shiftKey: true }], + ["a middle button", { button: 1 }], + ])( + "leaves a %s-modified click to open in a new tab/window", + (_label, modifier) => { + const event = { preventDefault: vi.fn(), ...modifier }; + + const handled = handleShareLinkClick( + "https://us.posthog.com/code/canvas/chan1/dash1", + event, + ); + + expect(handled).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToChannelDashboard).not.toHaveBeenCalled(); + }, + ); + it("leaves an external link alone", () => { const event = { preventDefault: vi.fn() }; diff --git a/packages/ui/src/utils/shareLinks.ts b/packages/ui/src/utils/shareLinks.ts index 323872a182..62f0b4b17c 100644 --- a/packages/ui/src/utils/shareLinks.ts +++ b/packages/ui/src/utils/shareLinks.ts @@ -23,11 +23,30 @@ export function navigateToShareTarget(target: ShareLinkTarget): void { } } +interface ShareLinkClickEvent { + preventDefault: () => void; + metaKey?: boolean; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + button?: number; +} + +function isModifiedClick(event: ShareLinkClickEvent): boolean { + return Boolean( + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + (event.button != null && event.button !== 0), + ); +} + export function handleShareLinkClick( href: string | undefined, - event: { preventDefault: () => void }, + event: ShareLinkClickEvent, ): boolean { - if (!href) return false; + if (!href || isModifiedClick(event)) return false; const target = parseShareLink(href); if (!target) return false; event.preventDefault();