Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useThemeStore } from "@posthog/ui/shell/themeStore";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { BuiltCanvas } from "./BuiltCanvas";

describe("BuiltCanvas", () => {
const capabilities = {
posthog: { insights: [], inlineQueries: false, captureEvents: [] },
network: { origins: [] },
};
const initialIsDarkMode = useThemeStore.getState().isDarkMode;
afterEach(() => useThemeStore.setState({ isDarkMode: initialIsDarkMode }));

it("loads an immutable artifact without granting origin or popup access", () => {
render(
Expand All @@ -20,7 +29,7 @@ describe("BuiltCanvas", () => {
const hostDocument = screen.getByTitle("Canvas").getAttribute("srcdoc");
expect(hostDocument).toContain("frame-src https://usercontent.example");
expect(hostDocument).toContain(
'artifactFrame.src = "https://usercontent.example/build/index.html"',
'artifactFrame.src = "https://usercontent.example/build/index.html#theme=light"',
);
expect(hostDocument).not.toContain("frame-src *");
expect(screen.getByTitle("Canvas")).toHaveAttribute(
Expand Down Expand Up @@ -100,9 +109,10 @@ describe("BuiltCanvas", () => {
][];
const canvasPort = calls.at(-1)?.[2]?.[0] as MessagePort;
const responses: unknown[] = [];
canvasPort.addEventListener("message", (event) =>
responses.push((event as MessageEvent).data),
);
canvasPort.addEventListener("message", (event) => {
const data = (event as MessageEvent).data as { type?: string };
if (data?.type === "data-response") responses.push(data);
});
canvasPort.start();

canvasPort.postMessage({
Expand All @@ -122,4 +132,50 @@ describe("BuiltCanvas", () => {
});
expect(onDataRequest).not.toHaveBeenCalled();
});

it("mirrors the host theme over the artifact bridge", async () => {
act(() => useThemeStore.setState({ isDarkMode: true }));
render(
<BuiltCanvas
artifactUrl="https://usercontent.example/build/index.html"
capabilities={capabilities}
onDataRequest={vi.fn()}
/>,
);
const iframe = screen.getByTitle("Canvas") as HTMLIFrameElement;
if (!iframe.contentWindow) throw new Error("Canvas iframe has no window");
expect(iframe.getAttribute("srcdoc")).toContain("#theme=dark");
expect(iframe.style.colorScheme).toBe("dark");
const postMessage = vi
.spyOn(iframe.contentWindow, "postMessage")
.mockImplementation(() => undefined);

fireEvent.load(iframe);
const calls = postMessage.mock.calls as unknown as [
unknown,
string,
Transferable[],
][];
const canvasPort = calls.at(-1)?.[2]?.[0] as MessagePort;
const frames: unknown[] = [];
canvasPort.addEventListener("message", (event) =>
frames.push((event as MessageEvent).data),
);
canvasPort.start();

await waitFor(() => expect(frames).toHaveLength(1));
expect(frames[0]).toEqual({
channel: "posthog-canvas",
type: "set-theme",
theme: "dark",
});

act(() => useThemeStore.setState({ isDarkMode: false }));
await waitFor(() => expect(frames).toHaveLength(2));
expect(frames[1]).toEqual({
channel: "posthog-canvas",
type: "set-theme",
theme: "light",
});
});
});
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
import { assertCanvasCapability } from "@posthog/core/canvas/canvasCapabilities";
import {
type CanvasNavIntent,
type CanvasTheme,
canvasToHostMessageSchema,
} from "@posthog/core/canvas/freeformSchemas";
import type { CanvasCapabilities } from "@posthog/shared";
import { logger } from "@posthog/ui/shell/logger";
import { openExternalUrl } from "@posthog/ui/shell/openExternal";
import { useLayoutEffect, useRef } from "react";
import { useThemeStore } from "@posthog/ui/shell/themeStore";
import { useEffect, useLayoutEffect, useRef } from "react";
import { createCanvasHostMessageRouter } from "./canvasHostMessageRouter";

const log = logger.scope("built-canvas");

function buildArtifactHostDocument(artifactUrl: string): string {
function buildArtifactHostDocument(
artifactUrl: string,
theme: CanvasTheme,
): string {
const artifactOrigin = new URL(artifactUrl).origin;
const serializedArtifactUrl = JSON.stringify(artifactUrl).replaceAll(
// The theme rides the fragment so the artifact runtime (a synchronous head
// script) applies `.dark` before first paint — the bridge port only connects
// at the load event, far too late to prevent a light flash. Fragments don't
// reach the server, so signed artifact URLs stay valid.
const themedUrl = new URL(artifactUrl);
themedUrl.hash = `theme=${theme}`;
const serializedArtifactUrl = JSON.stringify(themedUrl.href).replaceAll(
"<",
"\\u003c",
);
Expand Down Expand Up @@ -93,14 +104,24 @@ export function BuiltCanvas({
onNavigate,
}: BuiltCanvasProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const hostDocument = buildArtifactHostDocument(artifactUrl);
// Mirrors the host's light/dark theme, like FreeformCanvas — sent over the
// artifact bridge port right after connect and again on every change.
const theme = useThemeStore(
(s): CanvasTheme => (s.isDarkMode ? "dark" : "light"),
);
const artifactPortRef = useRef<MessagePort | null>(null);
// The srcDoc bakes in the mount-time theme only — folding the live theme in
// would reload the artifact on every toggle. Live changes go over the port.
const initialTheme = useRef(theme).current;
const hostDocument = buildArtifactHostDocument(artifactUrl, initialTheme);
const latest = useRef({
capabilities,
onDataRequest,
onError,
onReady,
onRendered,
onNavigate,
theme,
});
latest.current = {
capabilities,
Expand All @@ -109,15 +130,15 @@ export function BuiltCanvas({
onReady,
onRendered,
onNavigate,
theme,
};

// biome-ignore lint/correctness/useExhaustiveDependencies: a new host document needs a fresh bridge even though the effect reads it only through the iframe.
useLayoutEffect(() => {
const iframe = iframeRef.current;
let artifactPort: MessagePort | null = null;

const route = createCanvasHostMessageRouter({
post: (message) => artifactPort?.postMessage(message),
post: (message) => artifactPortRef.current?.postMessage(message),
callbacks: () => ({
onDataRequest: (method, payload) => {
// Gating lives here so every consumer of BuiltCanvas gets it by
Expand Down Expand Up @@ -149,16 +170,23 @@ export function BuiltCanvas({
};

const onLoad = () => {
if (artifactPort) return;
if (artifactPortRef.current) return;
const bridge = new MessageChannel();
artifactPort = bridge.port1;
artifactPort.addEventListener("message", onMessage);
artifactPort.start();
artifactPortRef.current = bridge.port1;
artifactPortRef.current.addEventListener("message", onMessage);
artifactPortRef.current.start();
iframe?.contentWindow?.postMessage(
{ channel: "posthog-canvas-host", type: "connect" },
"*",
[bridge.port2],
);
// Queued on the port until the artifact runtime starts it, so the first
// themed paint happens before any data renders.
artifactPortRef.current.postMessage({
channel: "posthog-canvas",
type: "set-theme",
theme: latest.current.theme,
});
Comment thread
charlesvien marked this conversation as resolved.
};

const onHostMessage = (event: MessageEvent) => {
Expand All @@ -169,26 +197,41 @@ export function BuiltCanvas({
) {
return;
}
artifactPort?.close();
artifactPort = null;
artifactPortRef.current?.close();
artifactPortRef.current = null;
};

iframe?.addEventListener("load", onLoad);
window.addEventListener("message", onHostMessage);
return () => {
iframe?.removeEventListener("load", onLoad);
window.removeEventListener("message", onHostMessage);
artifactPort?.close();
artifactPortRef.current?.close();
artifactPortRef.current = null;
};
}, [hostDocument]);

// Live theme change: re-theme the running artifact without reloading it. On
// mount the port is still null — the initial theme goes out in onLoad above.
useEffect(() => {
artifactPortRef.current?.postMessage({
channel: "posthog-canvas",
type: "set-theme",
theme,
});
}, [theme]);

return (
<iframe
ref={iframeRef}
title="Canvas"
sandbox="allow-scripts"
srcDoc={hostDocument}
referrerPolicy="no-referrer"
// Like FreeformCanvas: without a matching color-scheme the UA paints the
// embedded documents' base canvas opaque white, flashing over a dark app
// before the artifact's stylesheets and theme land.
style={{ colorScheme: theme }}
className="h-full w-full border-0 bg-background"
/>
);
Expand Down
Loading