A tool for people curious about the React Server Components protocol

rework embeds

Changed files
+162 -254
src
+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
··· 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
··· 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
··· 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>