Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
58 changes: 58 additions & 0 deletions packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
44 changes: 44 additions & 0 deletions packages/ui/src/features/canvas/freeform/sandboxRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,22 @@ const TAILWIND_V3 = `<script src="https://cdn.tailwindcss.com"></script>
} } };
</script>`;

// 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
Expand Down Expand Up @@ -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,
Expand All @@ -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 }],
Expand Down
Loading