From 9afe98eebb526bd55a673970b61d27c3ce34b31c Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 5 Aug 2026 23:38:25 -0700 Subject: [PATCH 1/3] fix(canvas): apply host theme in built artifact runtime --- products/canvas/backend/tests/test_cloud_builder.py | 9 +++++++++ products/canvas/packages/canvas_builder/build.mjs | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/products/canvas/backend/tests/test_cloud_builder.py b/products/canvas/backend/tests/test_cloud_builder.py index 2337bc96dc5f..320faad66c78 100644 --- a/products/canvas/backend/tests/test_cloud_builder.py +++ b/products/canvas/backend/tests/test_cloud_builder.py @@ -105,6 +105,15 @@ 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) + self.assertIn("location.hash", 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..47da5aab95b0 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 initialTheme=new URLSearchParams(location.hash.slice(1)).get("theme");if(initialTheme==="dark"||initialTheme==="light")applyTheme(initialTheme);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"; From fc235ff552aa97924457c2cc88bc9906761b4ce0 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 5 Aug 2026 23:54:10 -0700 Subject: [PATCH 2/3] fix(canvas): exercise emitted runtime theme behavior in test --- .../backend/tests/test_cloud_builder.py | 54 +++++++++++++++++-- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/products/canvas/backend/tests/test_cloud_builder.py b/products/canvas/backend/tests/test_cloud_builder.py index 320faad66c78..41c6b9613712 100644 --- a/products/canvas/backend/tests/test_cloud_builder.py +++ b/products/canvas/backend/tests/test_cloud_builder.py @@ -1,6 +1,7 @@ import os import hashlib import tempfile +import subprocess from pathlib import Path from typing import Any @@ -10,7 +11,7 @@ from parameterized import parameterized -from products.canvas.backend.build_service import run_cloud_builder, validate_builder_output +from products.canvas.backend.build_service import node_executable, run_cloud_builder, validate_builder_output from products.canvas.backend.presentation.serializers import CanvasSourceProjectSerializer from products.canvas.backend.source import synthetic_source_project, validate_source_project @@ -109,10 +110,53 @@ 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) - self.assertIn("location.hash", runtime) + harness = """ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const classes = new Set(); +const style = {}; +const listeners = { message: [] }; +globalThis.window = globalThis; +globalThis.parent = {}; +globalThis.location = { hash: "#theme=dark" }; +globalThis.document = { + readyState: "complete", + documentElement: { + classList: { toggle: (name, force) => void (force ? classes.add(name) : classes.delete(name)) }, + style, + }, +}; +globalThis.addEventListener = (type, handler) => (listeners[type] ??= []).push(handler); + +new Function(readFileSync(new URL("./runtime.js", import.meta.url), "utf8"))(); + +assert.ok(classes.has("dark"), "the theme fragment did not add the dark class before connect"); +assert.equal(style.colorScheme, "dark"); + +const bridge = new MessageChannel(); +for (const handler of listeners.message) { + handler({ source: globalThis.parent, data: { channel: "posthog-canvas", type: "connect" }, ports: [bridge.port2] }); +} +bridge.port1.postMessage({ channel: "posthog-canvas", type: "set-theme", theme: "light" }); +const deadline = Date.now() + 5000; +while (classes.has("dark") && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); +} +assert.ok(!classes.has("dark"), "the set-theme frame did not remove the dark class"); +assert.equal(style.colorScheme, "light"); +bridge.port1.close(); +""" + with tempfile.TemporaryDirectory() as directory: + (Path(directory) / "runtime.js").write_text(runtime) + (Path(directory) / "harness.mjs").write_text(harness) + process = subprocess.run( + [node_executable(), str(Path(directory) / "harness.mjs")], + capture_output=True, + text=True, + timeout=30, + ) + self.assertEqual(process.returncode, 0, process.stderr) def test_freezes_declared_capabilities_into_manifest(self) -> None: project = self._project('document.body.textContent = "Hello"') From bbeda002bd97656e410e87cab328d705f0bd795a Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 6 Aug 2026 00:04:15 -0700 Subject: [PATCH 3/3] fix(canvas): ignore invalid theme values in artifact runtime --- .../canvas/backend/tests/test_cloud_builder.py | 14 +++++++++++++- products/canvas/packages/canvas_builder/build.mjs | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/products/canvas/backend/tests/test_cloud_builder.py b/products/canvas/backend/tests/test_cloud_builder.py index 41c6b9613712..3691dab3d0c1 100644 --- a/products/canvas/backend/tests/test_cloud_builder.py +++ b/products/canvas/backend/tests/test_cloud_builder.py @@ -115,6 +115,7 @@ def test_runtime_applies_the_host_theme(self) -> None: import { readFileSync } from "node:fs"; const classes = new Set(); +const toggles = []; const style = {}; const listeners = { message: [] }; globalThis.window = globalThis; @@ -123,7 +124,12 @@ def test_runtime_applies_the_host_theme(self) -> None: globalThis.document = { readyState: "complete", documentElement: { - classList: { toggle: (name, force) => void (force ? classes.add(name) : classes.delete(name)) }, + classList: { + toggle: (name, force) => { + toggles.push([name, force]); + force ? classes.add(name) : classes.delete(name); + }, + }, style, }, }; @@ -138,6 +144,8 @@ def test_runtime_applies_the_host_theme(self) -> None: for (const handler of listeners.message) { handler({ source: globalThis.parent, data: { channel: "posthog-canvas", type: "connect" }, ports: [bridge.port2] }); } +bridge.port1.postMessage({ channel: "posthog-canvas", type: "set-theme", theme: "solarized" }); +bridge.port1.postMessage({ channel: "posthog-canvas", type: "set-theme" }); bridge.port1.postMessage({ channel: "posthog-canvas", type: "set-theme", theme: "light" }); const deadline = Date.now() + 5000; while (classes.has("dark") && Date.now() < deadline) { @@ -145,6 +153,10 @@ def test_runtime_applies_the_host_theme(self) -> None: } assert.ok(!classes.has("dark"), "the set-theme frame did not remove the dark class"); assert.equal(style.colorScheme, "light"); +// Port delivery is ordered, so by the light flip the invalid frames were +// already processed — exactly two toggles proves they were ignored, not +// coerced to light. +assert.deepEqual(toggles, [["dark", true], ["dark", false]]); bridge.port1.close(); """ with tempfile.TemporaryDirectory() as directory: diff --git a/products/canvas/packages/canvas_builder/build.mjs b/products/canvas/packages/canvas_builder/build.mjs index 47da5aab95b0..4a2665d9b871 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 applyTheme=(theme)=>{const dark=theme==="dark";document.documentElement.classList.toggle("dark",dark);document.documentElement.style.colorScheme=dark?"dark":"light";};const initialTheme=new URLSearchParams(location.hash.slice(1)).get("theme");if(initialTheme==="dark"||initialTheme==="light")applyTheme(initialTheme);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 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)=>{if(theme!=="dark"&&theme!=="light")return;const dark=theme==="dark";document.documentElement.classList.toggle("dark",dark);document.documentElement.style.colorScheme=dark?"dark":"light";};applyTheme(new URLSearchParams(location.hash.slice(1)).get("theme"));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";