+28
src/client/ui/App.css
+28
src/client/ui/App.css
···
197
197
border-color: #555;
198
198
}
199
199
200
+
.App-embedModal-tabs {
201
+
display: inline-flex;
202
+
background: var(--bg);
203
+
border-radius: 6px;
204
+
padding: 3px;
205
+
margin-bottom: 12px;
206
+
}
207
+
208
+
.App-embedModal-tab {
209
+
background: transparent;
210
+
border: none;
211
+
color: var(--text-dim);
212
+
padding: 5px 12px;
213
+
border-radius: 4px;
214
+
font-size: 12px;
215
+
cursor: pointer;
216
+
transition: all 0.15s;
217
+
}
218
+
219
+
.App-embedModal-tab:hover {
220
+
color: var(--text);
221
+
}
222
+
223
+
.App-embedModal-tab--active {
224
+
background: var(--border);
225
+
color: var(--text-bright);
226
+
}
227
+
200
228
.App-embedModal-footer {
201
229
padding: 16px;
202
230
border-top: 1px solid var(--border);
+42
-45
src/client/ui/App.tsx
+42
-45
src/client/ui/App.tsx
···
1
-
import React, { useState, useRef, useEffect, type ChangeEvent, type MouseEvent } from "react";
1
+
import React, { useState, useEffect, type ChangeEvent, type MouseEvent } from "react";
2
2
import { version } from "react";
3
3
import { SAMPLES, type Sample } from "../samples.ts";
4
4
import REACT_VERSIONS from "../../../scripts/versions.json";
···
108
108
client: string;
109
109
};
110
110
111
+
function encodeCode(code: CodeState): string {
112
+
const json = JSON.stringify({ server: code.server, client: code.client });
113
+
return btoa(unescape(encodeURIComponent(json)));
114
+
}
115
+
111
116
type EmbedModalProps = {
112
117
code: CodeState;
113
118
onClose: () => void;
114
119
};
115
120
116
121
function EmbedModal({ code, onClose }: EmbedModalProps): React.ReactElement {
117
-
const textareaRef = useRef<HTMLTextAreaElement>(null);
118
122
const [copied, setCopied] = useState(false);
123
+
const [tab, setTab] = useState<"html" | "jsx">("html");
119
124
120
-
const [embedCode] = useState(() => {
121
-
const base = window.location.origin + window.location.pathname.replace(/\/$/, "");
122
-
const id = Math.random().toString(36).slice(2, 6);
123
-
return `<div id="rsc-${id}" style="height: 500px;"></div>
124
-
<script type="module">
125
-
import { mount } from '${base}/embed.js';
125
+
const base = window.location.origin + window.location.pathname.replace(/\/$/, "");
126
+
const encoded = encodeCode(code);
127
+
const embedUrl = `${base}/embed.html?c=${encodeURIComponent(encoded)}`;
126
128
127
-
mount('#rsc-${id}', {
128
-
server: \`
129
-
${code.server}
130
-
\`,
131
-
client: \`
132
-
${code.client}
133
-
\`
134
-
});
135
-
</script>`;
136
-
});
129
+
const htmlCode = `<iframe
130
+
style="width: 100%; height: 500px; border: 1px solid #eee; border-radius: 8px;"
131
+
src="${embedUrl}"
132
+
></iframe>`;
133
+
134
+
const jsxCode = `<iframe
135
+
style={{ width: "100%", height: 500, border: "1px solid #eee", borderRadius: 8 }}
136
+
src="${embedUrl}"
137
+
/>`;
138
+
139
+
const embedCode = tab === "html" ? htmlCode : jsxCode;
137
140
138
141
const handleCopy = (): void => {
139
142
navigator.clipboard.writeText(embedCode);
···
151
154
</button>
152
155
</div>
153
156
<div className="App-embedModal-body">
154
-
<p className="App-embedModal-description">Copy and paste this code into your HTML:</p>
157
+
<div className="App-embedModal-tabs">
158
+
<button
159
+
className={`App-embedModal-tab ${tab === "html" ? "App-embedModal-tab--active" : ""}`}
160
+
onClick={() => setTab("html")}
161
+
>
162
+
HTML
163
+
</button>
164
+
<button
165
+
className={`App-embedModal-tab ${tab === "jsx" ? "App-embedModal-tab--active" : ""}`}
166
+
onClick={() => setTab("jsx")}
167
+
>
168
+
JSX
169
+
</button>
170
+
</div>
155
171
<textarea
156
-
ref={textareaRef}
157
172
className="App-embedModal-textarea"
158
173
readOnly
159
174
value={embedCode}
···
179
194
});
180
195
const [liveCode, setLiveCode] = useState<CodeState>(workspaceCode);
181
196
const [showEmbedModal, setShowEmbedModal] = useState(false);
182
-
const iframeRef = useRef<HTMLIFrameElement>(null);
183
197
198
+
// Listen for code changes from the embed iframe
184
199
useEffect(() => {
185
200
const handleMessage = (event: MessageEvent): void => {
186
201
const data = event.data as { type?: string; code?: CodeState };
187
-
if (data?.type === "rsc-embed:ready") {
188
-
iframeRef.current?.contentWindow?.postMessage(
189
-
{
190
-
type: "rsc-embed:init",
191
-
code: workspaceCode,
192
-
showFullscreen: false,
193
-
},
194
-
"*",
195
-
);
196
-
}
197
-
if (data?.type === "rsc-embed:code-changed" && data.code) {
202
+
if (data?.type === "rscexplorer:edit" && data.code) {
198
203
setLiveCode(data.code);
199
204
}
200
205
};
201
206
202
207
window.addEventListener("message", handleMessage);
203
208
return () => window.removeEventListener("message", handleMessage);
204
-
}, [workspaceCode]);
209
+
}, []);
205
210
211
+
// Reset liveCode when workspaceCode changes (e.g., sample switch)
206
212
useEffect(() => {
207
-
// eslint-disable-next-line react-hooks/set-state-in-effect
208
213
setLiveCode(workspaceCode);
209
-
if (iframeRef.current?.contentWindow) {
210
-
iframeRef.current.contentWindow.postMessage(
211
-
{
212
-
type: "rsc-embed:init",
213
-
code: workspaceCode,
214
-
showFullscreen: false,
215
-
},
216
-
"*",
217
-
);
218
-
}
219
214
}, [workspaceCode]);
215
+
216
+
const embedUrl = `embed.html?c=${encodeURIComponent(encodeCode(workspaceCode))}`;
220
217
221
218
const handleSave = (): void => {
222
219
saveToUrl(liveCode.server, liveCode.client);
···
325
322
</div>
326
323
<BuildSwitcher />
327
324
</header>
328
-
<iframe ref={iframeRef} src="embed.html" style={{ flex: 1, border: "none", width: "100%" }} />
325
+
<iframe key={embedUrl} src={embedUrl} style={{ flex: 1, border: "none", width: "100%" }} />
329
326
{showEmbedModal && <EmbedModal code={liveCode} onClose={() => setShowEmbedModal(false)} />}
330
327
</>
331
328
);
+57
-101
src/client/ui/EmbedApp.tsx
+57
-101
src/client/ui/EmbedApp.tsx
···
1
-
import React, { useState, useEffect } from "react";
1
+
import React from "react";
2
+
import { SAMPLES } from "../samples.ts";
2
3
import { Workspace } from "./Workspace.tsx";
3
4
import "./EmbedApp.css";
4
5
5
-
const DEFAULT_SERVER = `export default function App() {
6
-
return <h1>RSC Explorer</h1>;
7
-
}`;
8
-
9
-
const DEFAULT_CLIENT = `'use client'
10
-
11
-
export function Button({ children }) {
12
-
return <button>{children}</button>;
13
-
}`;
6
+
const DEFAULT_SAMPLE = SAMPLES.hello as { server: string; client: string };
14
7
15
8
type CodeState = {
16
9
server: string;
17
10
client: string;
18
11
};
19
12
20
-
type EmbedInitMessage = {
21
-
type: "rsc-embed:init";
22
-
code?: {
23
-
server?: string;
24
-
client?: string;
25
-
};
26
-
showFullscreen?: boolean;
27
-
};
13
+
function getCodeFromUrl(): CodeState {
14
+
const params = new URLSearchParams(window.location.search);
15
+
const encoded = params.get("c");
28
16
29
-
type EmbedReadyMessage = {
30
-
type: "rsc-embed:ready";
31
-
};
17
+
if (encoded) {
18
+
try {
19
+
const json = decodeURIComponent(escape(atob(encoded)));
20
+
const parsed = JSON.parse(json) as { server?: string; client?: string };
21
+
return {
22
+
server: (parsed.server ?? DEFAULT_SAMPLE.server).trim(),
23
+
client: (parsed.client ?? DEFAULT_SAMPLE.client).trim(),
24
+
};
25
+
} catch {
26
+
// Fall through to defaults
27
+
}
28
+
}
32
29
33
-
type EmbedCodeChangedMessage = {
34
-
type: "rsc-embed:code-changed";
35
-
code: {
36
-
server: string;
37
-
client: string;
30
+
return {
31
+
server: DEFAULT_SAMPLE.server,
32
+
client: DEFAULT_SAMPLE.client,
38
33
};
39
-
};
40
-
41
-
function isEmbedInitMessage(data: unknown): data is EmbedInitMessage {
42
-
return (
43
-
typeof data === "object" &&
44
-
data !== null &&
45
-
(data as { type?: string }).type === "rsc-embed:init"
46
-
);
47
34
}
48
35
49
-
export function EmbedApp(): React.ReactElement | null {
50
-
const [code, setCode] = useState<CodeState | null>(null);
51
-
const [showFullscreen, setShowFullscreen] = useState(false);
52
-
53
-
useEffect(() => {
54
-
const handleMessage = (event: MessageEvent<unknown>): void => {
55
-
const { data } = event;
56
-
if (isEmbedInitMessage(data)) {
57
-
setCode({
58
-
server: (data.code?.server ?? DEFAULT_SERVER).trim(),
59
-
client: (data.code?.client ?? DEFAULT_CLIENT).trim(),
60
-
});
61
-
if (data.showFullscreen !== false) {
62
-
setShowFullscreen(true);
63
-
}
64
-
}
65
-
};
66
-
67
-
window.addEventListener("message", handleMessage);
68
-
69
-
if (window.parent !== window) {
70
-
const readyMessage: EmbedReadyMessage = { type: "rsc-embed:ready" };
71
-
window.parent.postMessage(readyMessage, "*");
72
-
}
36
+
function getFullscreenUrl(code: CodeState): string {
37
+
const json = JSON.stringify({ server: code.server, client: code.client });
38
+
const encoded = btoa(unescape(encodeURIComponent(json)));
39
+
return `https://rscexplorer.dev/?c=${encodeURIComponent(encoded)}`;
40
+
}
73
41
74
-
return () => window.removeEventListener("message", handleMessage);
75
-
}, []);
42
+
export function EmbedApp(): React.ReactElement {
43
+
const initialCode = getCodeFromUrl();
76
44
77
45
const handleCodeChange = (server: string, client: string): void => {
78
46
if (window.parent !== window) {
79
-
const changedMessage: EmbedCodeChangedMessage = {
80
-
type: "rsc-embed:code-changed",
81
-
code: { server, client },
82
-
};
83
-
window.parent.postMessage(changedMessage, "*");
47
+
window.parent.postMessage(
48
+
{
49
+
type: "rscexplorer:edit",
50
+
code: { server, client },
51
+
},
52
+
"*",
53
+
);
84
54
}
85
55
};
86
56
87
-
const getFullscreenUrl = (): string => {
88
-
if (!code) return "#";
89
-
const json = JSON.stringify({ server: code.server, client: code.client });
90
-
const encoded = encodeURIComponent(btoa(unescape(encodeURIComponent(json))));
91
-
return `https://rscexplorer.dev/?c=${encoded}`;
92
-
};
93
-
94
-
if (!code) {
95
-
return null;
96
-
}
97
-
98
57
return (
99
58
<>
100
-
{showFullscreen && (
101
-
<div className="EmbedApp-header">
102
-
<span className="EmbedApp-title">RSC Explorer</span>
103
-
<a
104
-
href={getFullscreenUrl()}
105
-
target="_blank"
106
-
rel="noopener noreferrer"
107
-
className="EmbedApp-fullscreenLink"
108
-
title="Open in RSC Explorer"
59
+
<div className="EmbedApp-header">
60
+
<span className="EmbedApp-title">RSC Explorer</span>
61
+
<a
62
+
href={getFullscreenUrl(initialCode)}
63
+
target="_blank"
64
+
rel="noopener noreferrer"
65
+
className="EmbedApp-fullscreenLink"
66
+
title="Open in RSC Explorer"
67
+
>
68
+
<svg
69
+
width="14"
70
+
height="14"
71
+
viewBox="0 0 24 24"
72
+
fill="none"
73
+
stroke="currentColor"
74
+
strokeWidth="2"
109
75
>
110
-
<svg
111
-
width="14"
112
-
height="14"
113
-
viewBox="0 0 24 24"
114
-
fill="none"
115
-
stroke="currentColor"
116
-
strokeWidth="2"
117
-
>
118
-
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
119
-
</svg>
120
-
</a>
121
-
</div>
122
-
)}
76
+
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
77
+
</svg>
78
+
</a>
79
+
</div>
123
80
<Workspace
124
-
key={`${code.server}:${code.client}`}
125
-
initialServerCode={code.server}
126
-
initialClientCode={code.client}
81
+
initialServerCode={initialCode.server}
82
+
initialClientCode={initialCode.client}
127
83
onCodeChange={handleCodeChange}
128
84
/>
129
85
</>
+35
-108
test-embed.html
+35
-108
test-embed.html
···
17
17
border-bottom: 1px solid #eee;
18
18
padding-bottom: 8px;
19
19
}
20
-
.embed-container {
20
+
iframe {
21
+
width: 100%;
21
22
height: 500px;
23
+
border: none;
24
+
border-radius: 8px;
22
25
margin-bottom: 40px;
23
26
}
24
27
</style>
···
29
32
30
33
<h2>Hello World</h2>
31
34
<p>The simplest possible server component.</p>
32
-
<div id="hello" class="embed-container"></div>
35
+
<iframe id="hello"></iframe>
33
36
34
37
<h2>Counter</h2>
35
38
<p>A client component with state, rendered from a server component.</p>
36
-
<div id="counter" class="embed-container"></div>
39
+
<iframe id="counter"></iframe>
37
40
38
41
<h2>Async Component</h2>
39
42
<p>Server components can be async and use Suspense for loading states.</p>
40
-
<div id="async" class="embed-container"></div>
43
+
<iframe id="async"></iframe>
41
44
42
45
<h2>Form Action</h2>
43
46
<p>Server actions handle form submissions with useActionState.</p>
44
-
<div id="form" class="embed-container"></div>
45
-
</body>
47
+
<iframe id="form"></iframe>
46
48
47
-
<script type="module">
48
-
import { mount } from "http://localhost:3333/embed.js";
49
+
<script>
50
+
function encodeCode(code) {
51
+
const json = JSON.stringify(code);
52
+
return encodeURIComponent(btoa(unescape(encodeURIComponent(json))));
53
+
}
49
54
50
-
mount("#hello", {
51
-
server: `export default function App() {
55
+
function setEmbed(id, code) {
56
+
document.getElementById(id).src = `/embed.html?c=${encodeCode(code)}`;
57
+
}
58
+
59
+
setEmbed("hello", {
60
+
server: `export default function App() {
52
61
return <h1>Hello World</h1>
53
62
}`,
54
-
client: `'use client'`,
55
-
});
63
+
client: `'use client'`,
64
+
});
56
65
57
-
mount("#counter", {
58
-
server: `import { Counter } from './client'
66
+
setEmbed("counter", {
67
+
server: `import { Counter } from './client'
59
68
60
69
export default function App() {
61
70
return (
···
65
74
</div>
66
75
)
67
76
}`,
68
-
client: `'use client'
77
+
client: `'use client'
69
78
70
79
import { useState } from 'react'
71
80
···
82
91
</div>
83
92
)
84
93
}`,
85
-
});
94
+
});
86
95
87
-
mount("#async", {
88
-
server: `import { Suspense } from 'react'
96
+
setEmbed("async", {
97
+
server: `import { Suspense } from 'react'
89
98
90
99
export default function App() {
91
100
return (
···
102
111
await new Promise(r => setTimeout(r, 500))
103
112
return <p>Data loaded!</p>
104
113
}`,
105
-
client: `'use client'`,
106
-
});
114
+
client: `'use client'`,
115
+
});
107
116
108
-
mount("#form", {
109
-
server: `import { Form } from './client'
117
+
setEmbed("form", {
118
+
server: `import { Form } from './client'
110
119
111
120
export default function App() {
112
121
return (
···
124
133
if (!name) return { message: null, error: 'Please enter a name' }
125
134
return { message: \`Hello, \${name}!\`, error: null }
126
135
}`,
127
-
client: `'use client'
136
+
client: `'use client'
128
137
129
138
import { useActionState } from 'react'
130
139
···
151
160
</form>
152
161
)
153
162
}`,
154
-
});
155
-
</script>
156
-
157
-
<div id="rsc-explorer" style="height: 500px"></div>
158
-
<script type="module">
159
-
import { mount } from 'https://rscexplorer.dev/embed.js';
160
-
161
-
mount('#rsc-explorer', {
162
-
server: `
163
-
import { Suspense } from 'react'
164
-
import { Timer, Router } from './client'
165
-
166
-
export default function App() {
167
-
return (
168
-
<div>
169
-
<h1>Router Refresh!!!</h1>
170
-
<p style={{ marginBottom: 12, color: '#666' }}>
171
-
Client state persists across server navigations
172
-
</p>
173
-
<Suspense fallback={<p>Loading...</p>}>
174
-
<Router initial={renderPage()} refreshAction={renderPage} />
175
-
</Suspense>
176
-
</div>
177
-
)
178
-
}
179
-
180
-
async function renderPage() {
181
-
'use server'
182
-
return <ColorTimer />
183
-
}
184
-
185
-
async function ColorTimer() {
186
-
await new Promise(r => setTimeout(r, 300))
187
-
const hue = Math.floor(Math.random() * 360)
188
-
return <Timer color={`hsl(${hue}, 70%, 85%)`} />
189
-
}
190
-
`,
191
-
client: `
192
-
'use client'
193
-
194
-
import { useState, useEffect, useTransition, use } from 'react'
195
-
196
-
export function Timer({ color }) {
197
-
const [seconds, setSeconds] = useState(0)
198
-
199
-
useEffect(() => {
200
-
const id = setInterval(() => setSeconds(s => s + 1), 1000)
201
-
return () => clearInterval(id)
202
-
}, [])
203
-
204
-
return (
205
-
<div style={{
206
-
background: color,
207
-
padding: 24,
208
-
borderRadius: 8,
209
-
textAlign: 'center'
210
-
}}>
211
-
<p style={{ fontFamily: 'monospace', fontSize: 32, margin: 0 }}>{seconds}s</p>
212
-
</div>
213
-
)
214
-
}
215
-
216
-
export function Router({ initial, refreshAction }) {
217
-
const [contentPromise, setContentPromise] = useState(initial)
218
-
const [isPending, startTransition] = useTransition()
219
-
const content = use(contentPromise)
220
-
221
-
const refresh = () => {
222
-
startTransition(() => {
223
-
setContentPromise(refreshAction())
224
-
})
225
-
}
226
-
227
-
return (
228
-
<div style={{ opacity: isPending ? 0.6 : 1, transition: 'opacity 0.2s' }}>
229
-
{content}
230
-
<button onClick={refresh} disabled={isPending} style={{ marginTop: 12 }}>
231
-
{isPending ? 'Refreshing...' : 'Refresh'}
232
-
</button>
233
-
</div>
234
-
)
235
-
}
236
-
`
237
-
});
238
-
</script>
163
+
});
164
+
</script>
165
+
</body>
239
166
</html>