Skip to content

Commit af58a5b

Browse files
authored
fix(canvas): decode unicode escapes in freeform sandbox JSX text (#3214)
1 parent be37f66 commit af58a5b

2 files changed

Lines changed: 102 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
buildSandboxDocument,
4+
decodeJsxUnicodeEscapes,
5+
} from "./sandboxRuntime";
6+
7+
describe("decodeJsxUnicodeEscapes", () => {
8+
it.each([
9+
{
10+
name: "decodes 4-hex escapes",
11+
input: "Survey started Jun 20, 2026 \\u00b7 live \\u00b7 data",
12+
expected: "Survey started Jun 20, 2026 · live · data",
13+
},
14+
{ name: "decodes braced code points", input: "\\u{1F600}", expected: "😀" },
15+
{
16+
name: "decodes surrogate pairs",
17+
input: "\\ud83d\\ude00",
18+
expected: "😀",
19+
},
20+
{
21+
name: "decodes braced escapes shorter than 4 digits",
22+
input: "\\u{b7}",
23+
expected: "·",
24+
},
25+
{
26+
name: "leaves out-of-range code points intact",
27+
input: "\\u{110000}",
28+
expected: "\\u{110000}",
29+
},
30+
{
31+
name: "leaves incomplete escapes intact",
32+
input: "\\u00 and \\uZZZZ",
33+
expected: "\\u00 and \\uZZZZ",
34+
},
35+
{
36+
name: "leaves already-decoded text untouched",
37+
input: "plain · text",
38+
expected: "plain · text",
39+
},
40+
{
41+
name: "decodes valid escapes next to invalid ones",
42+
input: "\\u00b7 then \\u{110000}",
43+
expected: "· then \\u{110000}",
44+
},
45+
])("$name", ({ input, expected }) => {
46+
expect(decodeJsxUnicodeEscapes(input)).toBe(expected);
47+
});
48+
});
49+
50+
describe("buildSandboxDocument", () => {
51+
it("inlines the unicode-escape decoder into the bootstrap", () => {
52+
const html = buildSandboxDocument("edit");
53+
expect(html).toContain(
54+
"const decodeUnicodeEscapes = function decodeJsxUnicodeEscapes(",
55+
);
56+
expect(html).toContain("jsxUnicodeEscapesPlugin");
57+
});
58+
});

packages/ui/src/features/canvas/freeform/sandboxRuntime.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,22 @@ const TAILWIND_V3 = `<script src="https://cdn.tailwindcss.com"></script>
127127
} } };
128128
</script>`;
129129

130+
// Decodes literal \uXXXX / \u{...} escape sequences in a string. Exported for
131+
// tests; its source is interpolated into the sandbox bootstrap below so the
132+
// iframe runs this exact implementation.
133+
export function decodeJsxUnicodeEscapes(value: string): string {
134+
return value.replace(
135+
/\\u\{([0-9a-fA-F]{1,6})\}|\\u([0-9a-fA-F]{4})/g,
136+
(match, braced, plain) => {
137+
try {
138+
return String.fromCodePoint(Number.parseInt(braced || plain, 16));
139+
} catch {
140+
return match;
141+
}
142+
},
143+
);
144+
}
145+
130146
export function buildSandboxDocument(
131147
mode: SandboxMode,
132148
// The PostHog host, when in-iframe analytics/replay is enabled. Opens CSP for
@@ -269,6 +285,33 @@ export function buildSandboxDocument(
269285
),
270286
);
271287
288+
// JSX text and attribute strings never process \\uXXXX escapes (they render
289+
// verbatim, e.g. "\\u00b7" instead of "·"), but generated canvases still
290+
// contain them despite the prompt rules — decode at transpile time so both
291+
// new and already-saved canvases render the real characters. Escapes inside
292+
// JS string/template literals are untouched (Babel already decoded those).
293+
const decodeUnicodeEscapes = ${decodeJsxUnicodeEscapes.toString()};
294+
const jsxUnicodeEscapesPlugin = () => ({
295+
visitor: {
296+
JSXText(path) {
297+
const decoded = decodeUnicodeEscapes(path.node.value);
298+
if (decoded !== path.node.value) {
299+
path.node.value = decoded;
300+
}
301+
},
302+
JSXAttribute(path) {
303+
const v = path.node.value;
304+
if (v && v.type === "StringLiteral") {
305+
const decoded = decodeUnicodeEscapes(v.value);
306+
if (decoded !== v.value) {
307+
v.value = decoded;
308+
v.extra = undefined; // drop stale raw so the decoded value is emitted
309+
}
310+
}
311+
},
312+
},
313+
});
314+
272315
let root = null;
273316
// mount() is async and is called once per streamed code snapshot, so several
274317
// runs overlap on their awaits. Without ordering, a slower EARLIER (partial,
@@ -282,6 +325,7 @@ export function buildSandboxDocument(
282325
try {
283326
const out = Babel.transform(code, {
284327
filename: "canvas.tsx",
328+
plugins: [jsxUnicodeEscapesPlugin],
285329
presets: [
286330
["react", { runtime: "automatic" }],
287331
["typescript", { isTSX: true, allExtensions: true, onlyRemoveTypeImports: true }],

0 commit comments

Comments
 (0)