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 8f7f6fee9b..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 @@ -269,6 +285,33 @@ 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 = ${decodeJsxUnicodeEscapes.toString()}; + const jsxUnicodeEscapesPlugin = () => ({ + visitor: { + JSXText(path) { + const decoded = decodeUnicodeEscapes(path.node.value); + if (decoded !== path.node.value) { + path.node.value = decoded; + } + }, + 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 +325,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 }],