Skip to content

Commit 0d318f0

Browse files
committed
Replace document.write with server-rendered iframe route
Created /playground-preview route that server-renders a proper Crank component with ColorSchemeScript running critically. This eliminates the need for document.write() and HTML string injection. New architecture: 1. JavaScript iframes load /playground-preview (server-rendered) 2. iframe sends "ready" message when loaded 3. Parent sends transformed code via postMessage 4. iframe executes code and reports success/error 5. iframe can be reloaded (src=src) to reset state for re-execution Benefits: - Proper server-side rendering with Root component - ColorSchemeScript runs critically (no FOUC) - Cleaner separation: HTML structure vs. user code - Uses standard Crank infrastructure - Better than about:blank + document.write approach Python iframes still use document.write for PyScript compatibility. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent fa4e901 commit 0d318f0

File tree

3 files changed

+157
-15
lines changed

3 files changed

+157
-15
lines changed

website/src/components/code-preview.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ export function* CodePreview(
258258
const isPython = currentLanguage === "python";
259259

260260
let staticURLs: Record<string, any> | undefined;
261+
let iframeReady = false;
261262
let execute: () => unknown;
262263
let executeDebounced: () => unknown;
263264
if (typeof window !== "undefined") {
@@ -269,27 +270,38 @@ export function* CodePreview(
269270
return;
270271
}
271272

272-
// We have to refresh to change the iframe variable in scope, as the
273-
// previous iframe is destroyed. We would have to await refresh if this
274-
// component was refactored to be async.
275-
iframeID++;
276-
this.refresh();
277-
const document1 = iframe.contentDocument;
278-
if (document1 == null) {
279-
return;
280-
}
281-
282273
let code = value;
283274

284275
if (isPython) {
285-
// Python code - no transformation needed
276+
// Python code - keep using document.write for PyScript
277+
iframeID++;
278+
this.refresh();
279+
const document1 = iframe.contentDocument;
280+
if (document1 == null) {
281+
return;
282+
}
286283
document1.write(generatePythonIFrameHTML(id, code, staticURLs!));
284+
document1.close();
287285
} else {
288-
// JavaScript code - transform with Babel
286+
// JavaScript code - use postMessage to server-rendered iframe
289287
try {
290288
const parsed = transform(value);
291289
code = parsed.code;
292-
document1.write(generateJavaScriptIFrameHTML(id, code, staticURLs!));
290+
291+
if (!iframeReady) {
292+
// Reload iframe to reset state
293+
pendingCode = code;
294+
iframeID++;
295+
iframeReady = false;
296+
this.refresh();
297+
// Code will be sent when iframe sends "ready" message
298+
} else {
299+
// Send code to already-loaded iframe
300+
iframe.contentWindow?.postMessage(
301+
JSON.stringify({type: "execute-code", id, code}),
302+
window.location.origin
303+
);
304+
}
293305
} catch (err: any) {
294306
console.error(err);
295307
loading = false;
@@ -298,17 +310,32 @@ export function* CodePreview(
298310
return;
299311
}
300312
}
301-
302-
document1.close();
303313
};
304314

305315
executeDebounced = debounce(execute, 2000);
306316
}
307317

308318
let height = 100;
319+
let pendingCode: string | null = null;
309320
if (typeof window !== "undefined") {
310321
const onmessage = (ev: any) => {
311322
let data: any = JSON.parse(ev.data);
323+
324+
// Handle messages without id (like "ready")
325+
if (data.type === "ready") {
326+
iframeReady = true;
327+
// Send pending code if we have it
328+
if (pendingCode && iframe.contentWindow) {
329+
iframe.contentWindow.postMessage(
330+
JSON.stringify({type: "execute-code", id, code: pendingCode}),
331+
window.location.origin
332+
);
333+
pendingCode = null;
334+
}
335+
return;
336+
}
337+
338+
// For messages with id, check it matches
312339
if (data.id !== id) {
313340
return;
314341
}
@@ -413,6 +440,7 @@ export function* CodePreview(
413440
<iframe
414441
key=${iframeID}
415442
ref=${(el: HTMLIFrameElement) => (iframe = el)}
443+
src=${isPython ? undefined : "/playground-preview"}
416444
class="
417445
playground-iframe
418446
${css`

website/src/routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import BlogHomeView from "./views/blog-home.js";
44
import GuideView from "./views/guide.js";
55
import BlogView from "./views/blog.js";
66
import PlaygroundView from "./views/playground.js";
7+
import PlaygroundPreviewView from "./views/playground-preview.js";
78

89
// TODO: I am not sure what the value of the route() function is over using the
910
// route config directly.
@@ -28,4 +29,8 @@ export const router = new Router([
2829
name: "playground",
2930
view: PlaygroundView,
3031
}),
32+
route("/playground-preview", {
33+
name: "playground-preview",
34+
view: PlaygroundPreviewView,
35+
}),
3136
]);
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {jsx} from "@b9g/crank/standalone";
2+
import {Root} from "../components/root.js";
3+
4+
/**
5+
* Server-rendered view for playground preview iframes.
6+
* This loads as a proper server route with critical scripts,
7+
* then receives and executes user code via postMessage.
8+
*/
9+
export default function* PlaygroundPreview({context}: {context: any}) {
10+
yield jsx`
11+
<${Root}
12+
title="Playground Preview"
13+
description="Preview iframe for Crank.js playground"
14+
context=${context}
15+
path="/playground-preview"
16+
>
17+
<div id="preview-root"></div>
18+
<script type="module">
19+
// Listen for code from parent window
20+
window.addEventListener("message", async (ev) => {
21+
try {
22+
const data = JSON.parse(ev.data);
23+
24+
if (data.type === "execute-code") {
25+
const code = data.code;
26+
const id = data.id;
27+
28+
try {
29+
// Execute user code as a module
30+
const blob = new Blob([code], { type: 'application/javascript' });
31+
const url = URL.createObjectURL(blob);
32+
await import(url);
33+
URL.revokeObjectURL(url);
34+
35+
// Notify parent of successful execution
36+
window.parent.postMessage(
37+
JSON.stringify({ type: "executed", id }),
38+
window.location.origin
39+
);
40+
} catch (error) {
41+
// Notify parent of error
42+
window.parent.postMessage(
43+
JSON.stringify({
44+
type: "error",
45+
id,
46+
message: error.message || String(error)
47+
}),
48+
window.location.origin
49+
);
50+
}
51+
}
52+
} catch {
53+
// Ignore non-JSON messages
54+
}
55+
});
56+
57+
// Handle errors
58+
window.addEventListener("error", (ev) => {
59+
if (/ResizeObserver loop completed with undelivered notifications/.test(ev.message)) {
60+
return;
61+
}
62+
63+
window.parent.postMessage(
64+
JSON.stringify({ type: "error", message: ev.message }),
65+
window.location.origin
66+
);
67+
});
68+
69+
window.addEventListener("unhandledrejection", (ev) => {
70+
if (/ResizeObserver loop completed with undelivered notifications/.test(ev.reason?.message)) {
71+
return;
72+
}
73+
window.parent.postMessage(
74+
JSON.stringify({
75+
type: "error",
76+
message: ev.reason?.message || String(ev.reason)
77+
}),
78+
window.location.origin
79+
);
80+
});
81+
82+
// Set up resize observer
83+
const obs = new ResizeObserver((entries) => {
84+
const height = Math.max(entries[0].contentRect.height, 100);
85+
if (
86+
document.documentElement.clientHeight <
87+
document.documentElement.scrollHeight
88+
) {
89+
window.parent.postMessage(
90+
JSON.stringify({
91+
type: "resize",
92+
height,
93+
}),
94+
window.location.origin
95+
);
96+
}
97+
});
98+
99+
obs.observe(document.documentElement);
100+
101+
// Signal ready
102+
window.parent.postMessage(
103+
JSON.stringify({ type: "ready" }),
104+
window.location.origin
105+
);
106+
</script>
107+
<//>
108+
`;
109+
}

0 commit comments

Comments
 (0)