Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions products/canvas/backend/tests/test_cloud_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ def test_runtime_bounds_host_side_effects(self) -> None:
self.assertIn('url.hostname.endsWith(".posthog.com")', runtime)
self.assertIn("serialized.length>16384", runtime)

def test_runtime_applies_the_host_theme(self) -> None:
result = run_cloud_builder(self._project('document.body.textContent = "Hello"'))

runtime = next(file["content"] for file in result["files"] if file["path"] == "assets/canvas-runtime.js")
self.assertIn('event.data.type==="set-theme"', runtime)
self.assertIn('classList.toggle("dark",dark)', runtime)
self.assertIn("colorScheme", runtime)

def test_freezes_declared_capabilities_into_manifest(self) -> None:
project = self._project('document.body.textContent = "Hello"')
project["capabilities"] = {
Expand Down
2 changes: 1 addition & 1 deletion products/canvas/packages/canvas_builder/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const htmlAttribute = /([a-zA-Z][\w-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g
const forbiddenHtml = /(?:src|href)\s*=\s*["']\s*(javascript|data:text\/html|vbscript)/i
const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '.css', '.json', '.svg', '.txt']
const runtimePath = 'assets/canvas-runtime.js'
const runtime = `(()=>{const channel="posthog-canvas",pending=new Map;let sequence=0,port;const post=(message)=>port?.postMessage({channel,...message});const call=(method,payload)=>new Promise((resolve,reject)=>{const id=String(++sequence);const timer=setTimeout(()=>{pending.delete(id);reject(new Error("Canvas request timed out"));},30000);pending.set(id,{resolve,reject,timer});post({type:"data-request",id,method,payload});});const receive=(event)=>{if(event.data?.channel!==channel||event.data?.type!=="data-response")return;const request=pending.get(event.data.id);if(!request)return;pending.delete(event.data.id);clearTimeout(request.timer);event.data.ok?request.resolve(event.data.result):request.reject(new Error(event.data.error??"Canvas request failed"));};const capture=(event,properties,distinctId)=>{const normalized=properties??{};let serialized;try{serialized=JSON.stringify(normalized)}catch{throw new Error("Canvas capture properties must be serializable")};if(typeof serialized!=="string"||serialized.length>16384)throw new Error("Canvas capture properties are too large");return call("capture",{event,properties:normalized,distinctId})};const openExternal=(value)=>{const url=new URL(value);if(url.protocol!=="https:"||!(url.hostname==="posthog.com"||url.hostname.endsWith(".posthog.com")))throw new Error("Canvas external URL is not allowed");post({type:"open-external",url:url.href})};window.ph={loadInsight:(shortId,options)=>call("loadInsight",{shortId,dateRange:options?.dateRange}),query:(query,params)=>call("query",typeof query==="string"?{hogql:query,params:params??{}}:{query,params:params??{}}),capture,openExternal};addEventListener("message",(event)=>{if(port||event.source!==parent||event.data?.channel!==channel||event.data?.type!=="connect"||!event.ports[0])return;port=event.ports[0];port.addEventListener("message",receive);port.start();if(document.readyState!=="loading")post({type:"ready"});if(document.readyState==="complete")post({type:"rendered"});});addEventListener("error",(event)=>post({type:"error",message:event.message||"Canvas runtime error",stack:event.error?.stack}));addEventListener("unhandledrejection",(event)=>post({type:"error",message:event.reason instanceof Error?event.reason.message:String(event.reason),stack:event.reason instanceof Error?event.reason.stack:undefined}));addEventListener("DOMContentLoaded",()=>post({type:"ready"}));addEventListener("load",()=>post({type:"rendered"}));})();`
const runtime = `(()=>{const channel="posthog-canvas",pending=new Map;let sequence=0,port;const post=(message)=>port?.postMessage({channel,...message});const call=(method,payload)=>new Promise((resolve,reject)=>{const id=String(++sequence);const timer=setTimeout(()=>{pending.delete(id);reject(new Error("Canvas request timed out"));},30000);pending.set(id,{resolve,reject,timer});post({type:"data-request",id,method,payload});});const applyTheme=(theme)=>{const dark=theme==="dark";document.documentElement.classList.toggle("dark",dark);document.documentElement.style.colorScheme=dark?"dark":"light";};const receive=(event)=>{if(event.data?.channel!==channel)return;if(event.data.type==="set-theme"){applyTheme(event.data.theme);return}if(event.data.type!=="data-response")return;const request=pending.get(event.data.id);if(!request)return;pending.delete(event.data.id);clearTimeout(request.timer);event.data.ok?request.resolve(event.data.result):request.reject(new Error(event.data.error??"Canvas request failed"));};const capture=(event,properties,distinctId)=>{const normalized=properties??{};let serialized;try{serialized=JSON.stringify(normalized)}catch{throw new Error("Canvas capture properties must be serializable")};if(typeof serialized!=="string"||serialized.length>16384)throw new Error("Canvas capture properties are too large");return call("capture",{event,properties:normalized,distinctId})};const openExternal=(value)=>{const url=new URL(value);if(url.protocol!=="https:"||!(url.hostname==="posthog.com"||url.hostname.endsWith(".posthog.com")))throw new Error("Canvas external URL is not allowed");post({type:"open-external",url:url.href})};window.ph={loadInsight:(shortId,options)=>call("loadInsight",{shortId,dateRange:options?.dateRange}),query:(query,params)=>call("query",typeof query==="string"?{hogql:query,params:params??{}}:{query,params:params??{}}),capture,openExternal};addEventListener("message",(event)=>{if(port||event.source!==parent||event.data?.channel!==channel||event.data?.type!=="connect"||!event.ports[0])return;port=event.ports[0];port.addEventListener("message",receive);port.start();if(document.readyState!=="loading")post({type:"ready"});if(document.readyState==="complete")post({type:"rendered"});});addEventListener("error",(event)=>post({type:"error",message:event.message||"Canvas runtime error",stack:event.error?.stack}));addEventListener("unhandledrejection",(event)=>post({type:"error",message:event.reason instanceof Error?event.reason.message:String(event.reason),stack:event.reason instanceof Error?event.reason.stack:undefined}));addEventListener("DOMContentLoaded",()=>post({type:"ready"}));addEventListener("load",()=>post({type:"rendered"}));})();`
const platformStylesheet = `
@import "tailwindcss";
@import "@posthog/quill/tokens.css";
Expand Down
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 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,48 @@ 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");
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
Expand Up @@ -6,7 +6,8 @@ import {
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");
Expand Down Expand Up @@ -93,6 +94,10 @@ export function BuiltCanvas({
onNavigate,
}: BuiltCanvasProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
// 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) => (s.isDarkMode ? "dark" : "light"));
const artifactPortRef = useRef<MessagePort | null>(null);
const hostDocument = buildArtifactHostDocument(artifactUrl);
const latest = useRef({
capabilities,
Expand All @@ -101,6 +106,7 @@ export function BuiltCanvas({
onReady,
onRendered,
onNavigate,
theme,
});
latest.current = {
capabilities,
Expand All @@ -109,15 +115,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 +155,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,19 +182,30 @@ 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}
Expand Down
Loading