From c8f1963ca2b6026a3a35c23651d8d69f1fb1ef19 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Tue, 7 Jul 2026 10:13:32 +0100 Subject: [PATCH 1/2] fix(canvas): decode unicode escapes in freeform sandbox JSX text Generated canvases sometimes contain \uXXXX escapes in JSX text and attribute strings, which JSX renders verbatim. Decode them in a Babel pre-pass inside the sandbox so both new and already-saved canvases render the intended glyphs. Escapes in real JS string literals are left untouched. Generated-By: PostHog Code Task-Id: 20fa55b3-faa2-41b2-91a4-a6bab80ec174 --- .../canvas/freeform/sandboxRuntime.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts index 8f7f6fee9b..22540e9968 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts @@ -269,6 +269,40 @@ export function buildSandboxDocument( ), ); + // JSX text and attribute strings never process \\uXXXX escapes (they render + // verbatim, e.g. "\\u00b7" instead of "·"), but generated canvases still + // contain them despite the prompt rules — decode at transpile time so both + // new and already-saved canvases render the real characters. Escapes inside + // JS string/template literals are untouched (Babel already decoded those). + const decodeUnicodeEscapes = (value) => + value.replace( + /\\\\u\\{([0-9a-fA-F]{1,6})\\}|\\\\u([0-9a-fA-F]{4})/g, + (match, braced, plain) => { + try { + return String.fromCodePoint(parseInt(braced || plain, 16)); + } catch { + return match; + } + }, + ); + const jsxUnicodeEscapesPlugin = () => ({ + visitor: { + JSXText(path) { + path.node.value = decodeUnicodeEscapes(path.node.value); + }, + JSXAttribute(path) { + const v = path.node.value; + if (v && v.type === "StringLiteral") { + const decoded = decodeUnicodeEscapes(v.value); + if (decoded !== v.value) { + v.value = decoded; + v.extra = undefined; // drop stale raw so the decoded value is emitted + } + } + }, + }, + }); + let root = null; // mount() is async and is called once per streamed code snapshot, so several // runs overlap on their awaits. Without ordering, a slower EARLIER (partial, @@ -282,6 +316,7 @@ export function buildSandboxDocument( try { const out = Babel.transform(code, { filename: "canvas.tsx", + plugins: [jsxUnicodeEscapesPlugin], presets: [ ["react", { runtime: "automatic" }], ["typescript", { isTSX: true, allExtensions: true, onlyRemoveTypeImports: true }], From 68f692b5bfce90de958880fd67a1eabbc84bf3ff Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Tue, 7 Jul 2026 10:39:03 +0100 Subject: [PATCH 2/2] test(canvas): cover unicode-escape decoding, guard JSXText write-back Lift the decoder out of the bootstrap string as an exported function (interpolated back in via toString) so it can be unit-tested, add parameterised tests over the decode cases, and only write JSXText values back when they actually changed. Addresses Greptile review. Generated-By: PostHog Code Task-Id: 20fa55b3-faa2-41b2-91a4-a6bab80ec174 --- .../canvas/freeform/sandboxRuntime.test.ts | 58 +++++++++++++++++++ .../canvas/freeform/sandboxRuntime.ts | 33 +++++++---- 2 files changed, 79 insertions(+), 12 deletions(-) create mode 100644 packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts new file mode 100644 index 0000000000..214cca4461 --- /dev/null +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + buildSandboxDocument, + decodeJsxUnicodeEscapes, +} from "./sandboxRuntime"; + +describe("decodeJsxUnicodeEscapes", () => { + it.each([ + { + name: "decodes 4-hex escapes", + input: "Survey started Jun 20, 2026 \\u00b7 live \\u00b7 data", + expected: "Survey started Jun 20, 2026 · live · data", + }, + { name: "decodes braced code points", input: "\\u{1F600}", expected: "😀" }, + { + name: "decodes surrogate pairs", + input: "\\ud83d\\ude00", + expected: "😀", + }, + { + name: "decodes braced escapes shorter than 4 digits", + input: "\\u{b7}", + expected: "·", + }, + { + name: "leaves out-of-range code points intact", + input: "\\u{110000}", + expected: "\\u{110000}", + }, + { + name: "leaves incomplete escapes intact", + input: "\\u00 and \\uZZZZ", + expected: "\\u00 and \\uZZZZ", + }, + { + name: "leaves already-decoded text untouched", + input: "plain · text", + expected: "plain · text", + }, + { + name: "decodes valid escapes next to invalid ones", + input: "\\u00b7 then \\u{110000}", + expected: "· then \\u{110000}", + }, + ])("$name", ({ input, expected }) => { + expect(decodeJsxUnicodeEscapes(input)).toBe(expected); + }); +}); + +describe("buildSandboxDocument", () => { + it("inlines the unicode-escape decoder into the bootstrap", () => { + const html = buildSandboxDocument("edit"); + expect(html).toContain( + "const decodeUnicodeEscapes = function decodeJsxUnicodeEscapes(", + ); + expect(html).toContain("jsxUnicodeEscapesPlugin"); + }); +}); diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts index 22540e9968..78b26e0f26 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts @@ -127,6 +127,22 @@ const TAILWIND_V3 = ` } } }; `; +// Decodes literal \uXXXX / \u{...} escape sequences in a string. Exported for +// tests; its source is interpolated into the sandbox bootstrap below so the +// iframe runs this exact implementation. +export function decodeJsxUnicodeEscapes(value: string): string { + return value.replace( + /\\u\{([0-9a-fA-F]{1,6})\}|\\u([0-9a-fA-F]{4})/g, + (match, braced, plain) => { + try { + return String.fromCodePoint(Number.parseInt(braced || plain, 16)); + } catch { + return match; + } + }, + ); +} + export function buildSandboxDocument( mode: SandboxMode, // The PostHog host, when in-iframe analytics/replay is enabled. Opens CSP for @@ -274,21 +290,14 @@ export function buildSandboxDocument( // contain them despite the prompt rules — decode at transpile time so both // new and already-saved canvases render the real characters. Escapes inside // JS string/template literals are untouched (Babel already decoded those). - const decodeUnicodeEscapes = (value) => - value.replace( - /\\\\u\\{([0-9a-fA-F]{1,6})\\}|\\\\u([0-9a-fA-F]{4})/g, - (match, braced, plain) => { - try { - return String.fromCodePoint(parseInt(braced || plain, 16)); - } catch { - return match; - } - }, - ); + const decodeUnicodeEscapes = ${decodeJsxUnicodeEscapes.toString()}; const jsxUnicodeEscapesPlugin = () => ({ visitor: { JSXText(path) { - path.node.value = decodeUnicodeEscapes(path.node.value); + const decoded = decodeUnicodeEscapes(path.node.value); + if (decoded !== path.node.value) { + path.node.value = decoded; + } }, JSXAttribute(path) { const v = path.node.value;