From c9b10da8e8bdf65154bf5ab74d2ea1f07ab9d233 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 5 Aug 2026 23:22:36 -0700 Subject: [PATCH 1/2] fix(canvas): mirror host dark mode into built canvas artifacts --- .../backend/tests/test_cloud_builder.py | 8 +++ .../canvas/packages/canvas_builder/build.mjs | 2 +- .../canvas/freeform/BuiltCanvas.test.tsx | 64 +++++++++++++++++-- .../features/canvas/freeform/BuiltCanvas.tsx | 44 ++++++++++--- 4 files changed, 102 insertions(+), 16 deletions(-) diff --git a/products/canvas/backend/tests/test_cloud_builder.py b/products/canvas/backend/tests/test_cloud_builder.py index 2337bc96dc5f..271090165943 100644 --- a/products/canvas/backend/tests/test_cloud_builder.py +++ b/products/canvas/backend/tests/test_cloud_builder.py @@ -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"] = { diff --git a/products/canvas/packages/canvas_builder/build.mjs b/products/canvas/packages/canvas_builder/build.mjs index 04f06d4e630e..54fe38b3c41f 100644 --- a/products/canvas/packages/canvas_builder/build.mjs +++ b/products/canvas/packages/canvas_builder/build.mjs @@ -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"; diff --git a/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.test.tsx b/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.test.tsx index efbadfa48e49..ddcd55491f15 100644 --- a/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.test.tsx +++ b/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.test.tsx @@ -1,5 +1,12 @@ -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", () => { @@ -7,6 +14,8 @@ describe("BuiltCanvas", () => { 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( @@ -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({ @@ -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( + , + ); + 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", + }); + }); }); diff --git a/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.tsx b/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.tsx index 865393d7c5b9..1ed86f2e50ea 100644 --- a/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.tsx +++ b/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.tsx @@ -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"); @@ -93,6 +94,10 @@ export function BuiltCanvas({ onNavigate, }: BuiltCanvasProps) { const iframeRef = useRef(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(null); const hostDocument = buildArtifactHostDocument(artifactUrl); const latest = useRef({ capabilities, @@ -101,6 +106,7 @@ export function BuiltCanvas({ onReady, onRendered, onNavigate, + theme, }); latest.current = { capabilities, @@ -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 @@ -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, + }); }; const onHostMessage = (event: MessageEvent) => { @@ -169,8 +182,8 @@ export function BuiltCanvas({ ) { return; } - artifactPort?.close(); - artifactPort = null; + artifactPortRef.current?.close(); + artifactPortRef.current = null; }; iframe?.addEventListener("load", onLoad); @@ -178,10 +191,21 @@ export function BuiltCanvas({ 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 (