A tool for people curious about the React Server Components protocol rscexplorer.dev/
rsc react

fix playback panel jump

+75 -104
+12 -12
src/client/runtime/timeline.ts
··· 80 return this.cachedSnapshot; 81 }; 82 83 - setRender(stream: SteppableStream): void { 84 this.entries = [{ type: "render", stream }]; 85 this.cursor = 0; 86 this.notify(); 87 - } 88 89 - addAction(name: string, args: string, stream: SteppableStream): void { 90 this.entries = [...this.entries, { type: "action", name, args, stream }]; 91 this.notify(); 92 - } 93 94 - deleteEntry(entryIndex: number): void { 95 let chunkStart = 0; 96 for (let i = 0; i < entryIndex; i++) { 97 chunkStart += this.entries[i]!.stream.rows.length; ··· 101 } 102 this.entries = this.entries.filter((_, i) => i !== entryIndex); 103 this.notify(); 104 - } 105 106 - stepForward(): void { 107 let remaining = this.cursor; 108 for (const entry of this.entries) { 109 const count = entry.stream.rows.length; ··· 115 } 116 remaining -= count; 117 } 118 - } 119 120 - skipToEntryEnd(): void { 121 let remaining = this.cursor; 122 for (const entry of this.entries) { 123 const count = entry.stream.rows.length; ··· 131 } 132 remaining -= count; 133 } 134 - } 135 136 - clear(): void { 137 this.entries = []; 138 this.cursor = 0; 139 this.notify(); 140 - } 141 }
··· 80 return this.cachedSnapshot; 81 }; 82 83 + setRender = (stream: SteppableStream): void => { 84 this.entries = [{ type: "render", stream }]; 85 this.cursor = 0; 86 this.notify(); 87 + }; 88 89 + addAction = (name: string, args: string, stream: SteppableStream): void => { 90 this.entries = [...this.entries, { type: "action", name, args, stream }]; 91 this.notify(); 92 + }; 93 94 + deleteEntry = (entryIndex: number): void => { 95 let chunkStart = 0; 96 for (let i = 0; i < entryIndex; i++) { 97 chunkStart += this.entries[i]!.stream.rows.length; ··· 101 } 102 this.entries = this.entries.filter((_, i) => i !== entryIndex); 103 this.notify(); 104 + }; 105 106 + stepForward = (): void => { 107 let remaining = this.cursor; 108 for (const entry of this.entries) { 109 const count = entry.stream.rows.length; ··· 115 } 116 remaining -= count; 117 } 118 + }; 119 120 + skipToEntryEnd = (): void => { 121 let remaining = this.cursor; 122 for (const entry of this.entries) { 123 const count = entry.stream.rows.length; ··· 131 } 132 remaining -= count; 133 } 134 + }; 135 136 + clear = (): void => { 137 this.entries = []; 138 this.cursor = 0; 139 this.notify(); 140 + }; 141 }
+13 -7
src/client/ui/LivePreview.tsx
··· 47 totalChunks: number; 48 isAtStart: boolean; 49 isAtEnd: boolean; 50 onStep: () => void; 51 onSkip: () => void; 52 onReset: () => void; ··· 58 totalChunks, 59 isAtStart, 60 isAtEnd, 61 onStep, 62 onSkip, 63 onReset, ··· 99 }; 100 101 let statusText = ""; 102 - if (isAtStart) { 103 statusText = "Ready"; 104 } else if (isAtEnd) { 105 statusText = "Done"; ··· 114 <button 115 className="LivePreview-controlBtn" 116 onClick={handleReset} 117 - disabled={isAtStart} 118 title="Reset" 119 > 120 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> ··· 124 <button 125 className={`LivePreview-controlBtn${isPlaying ? " LivePreview-controlBtn--playing" : ""}`} 126 onClick={handlePlayPause} 127 - disabled={isAtEnd} 128 title={isPlaying ? "Pause" : "Play"} 129 > 130 {isPlaying ? ( ··· 138 )} 139 </button> 140 <button 141 - className={`LivePreview-controlBtn${!isAtEnd ? " LivePreview-controlBtn--step" : ""}`} 142 onClick={handleStep} 143 - disabled={isAtEnd} 144 title="Step forward" 145 > 146 <svg ··· 157 <button 158 className="LivePreview-controlBtn" 159 onClick={handleSkip} 160 - disabled={isAtEnd} 161 title="Skip to end" 162 > 163 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> ··· 177 <span className="LivePreview-stepInfo">{statusText}</span> 178 </div> 179 <div className="LivePreview-container"> 180 - {showPlaceholder ? ( 181 <span className="LivePreview-empty">{isAtStart ? "Step to begin..." : "Loading..."}</span> 182 ) : flightPromise ? ( 183 <PreviewErrorBoundary>
··· 47 totalChunks: number; 48 isAtStart: boolean; 49 isAtEnd: boolean; 50 + isLoading: boolean; 51 onStep: () => void; 52 onSkip: () => void; 53 onReset: () => void; ··· 59 totalChunks, 60 isAtStart, 61 isAtEnd, 62 + isLoading, 63 onStep, 64 onSkip, 65 onReset, ··· 101 }; 102 103 let statusText = ""; 104 + if (isLoading) { 105 + statusText = "Compiling"; 106 + } else if (isAtStart) { 107 statusText = "Ready"; 108 } else if (isAtEnd) { 109 statusText = "Done"; ··· 118 <button 119 className="LivePreview-controlBtn" 120 onClick={handleReset} 121 + disabled={isLoading || isAtStart} 122 title="Reset" 123 > 124 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> ··· 128 <button 129 className={`LivePreview-controlBtn${isPlaying ? " LivePreview-controlBtn--playing" : ""}`} 130 onClick={handlePlayPause} 131 + disabled={isLoading || isAtEnd} 132 title={isPlaying ? "Pause" : "Play"} 133 > 134 {isPlaying ? ( ··· 142 )} 143 </button> 144 <button 145 + className={`LivePreview-controlBtn${!isLoading && !isAtEnd ? " LivePreview-controlBtn--step" : ""}`} 146 onClick={handleStep} 147 + disabled={isLoading || isAtEnd} 148 title="Step forward" 149 > 150 <svg ··· 161 <button 162 className="LivePreview-controlBtn" 163 onClick={handleSkip} 164 + disabled={isLoading || isAtEnd} 165 title="Skip to end" 166 > 167 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> ··· 181 <span className="LivePreview-stepInfo">{statusText}</span> 182 </div> 183 <div className="LivePreview-container"> 184 + {isLoading ? ( 185 + <span className="LivePreview-empty">Compiling...</span> 186 + ) : showPlaceholder ? ( 187 <span className="LivePreview-empty">{isAtStart ? "Step to begin..." : "Loading..."}</span> 188 ) : flightPromise ? ( 189 <PreviewErrorBoundary>
+33 -83
src/client/ui/Workspace.tsx
··· 1 - import React, { useState, useEffect, useSyncExternalStore, startTransition } from "react"; 2 - import { WorkspaceSession } from "../workspace-session.ts"; 3 import { CodeEditor } from "./CodeEditor.tsx"; 4 import { FlightLog } from "./FlightLog.tsx"; 5 import { LivePreview } from "./LivePreview.tsx"; ··· 26 const abort = new AbortController(); 27 WorkspaceSession.create(serverCode, clientCode, abort.signal).then((nextSession) => { 28 if (!abort.signal.aborted) { 29 - startTransition(() => { 30 - setSession(nextSession); 31 - }); 32 } 33 }); 34 return () => abort.abort(); ··· 48 setResetKey((k) => k + 1); 49 } 50 51 return ( 52 <main className="Workspace"> 53 <div className="Workspace-server"> ··· 56 <div className="Workspace-client"> 57 <CodeEditor label="client" defaultValue={clientCode} onChange={handleClientChange} /> 58 </div> 59 - {session ? ( 60 - <WorkspaceContent session={session} onReset={reset} key={session.id} /> 61 - ) : ( 62 - <WorkspaceLoading /> 63 - )} 64 - </main> 65 - ); 66 - } 67 - 68 - function WorkspaceLoading(): React.ReactElement { 69 - return ( 70 - <> 71 <div className="Workspace-flight"> 72 <Pane label="flight"> 73 - <div className="Workspace-loadingOutput"> 74 - <span className="Workspace-loadingEmpty Workspace-loadingEmpty--waiting"> 75 - Compiling 76 - </span> 77 - </div> 78 - </Pane> 79 - </div> 80 - <div className="Workspace-preview"> 81 - <Pane label="preview"> 82 - <div className="Workspace-loadingPreview"> 83 - <span className="Workspace-loadingEmpty Workspace-loadingEmpty--waiting"> 84 - Compiling 85 - </span> 86 - </div> 87 - </Pane> 88 - </div> 89 - </> 90 - ); 91 - } 92 - 93 - type WorkspaceContentProps = { 94 - session: WorkspaceSession; 95 - onReset: () => void; 96 - }; 97 - 98 - function WorkspaceContent({ session, onReset }: WorkspaceContentProps): React.ReactElement { 99 - const { entries, cursor, totalChunks, isAtStart, isAtEnd } = useSyncExternalStore( 100 - session.timeline.subscribe, 101 - session.timeline.getSnapshot, 102 - ); 103 - 104 - if (session.state.status === "error") { 105 - return ( 106 - <> 107 - <div className="Workspace-flight"> 108 - <Pane label="flight"> 109 - <pre className="Workspace-errorOutput">{session.state.message}</pre> 110 - </Pane> 111 - </div> 112 - <div className="Workspace-preview"> 113 - <Pane label="preview"> 114 - <div className="Workspace-errorPreview"> 115 - <span className="Workspace-errorMessage">Compilation error</span> 116 </div> 117 - </Pane> 118 - </div> 119 - </> 120 - ); 121 - } 122 - 123 - const { availableActions } = session.state; 124 - 125 - return ( 126 - <> 127 - <div className="Workspace-flight"> 128 - <Pane label="flight"> 129 - <FlightLog 130 - entries={entries} 131 - cursor={cursor} 132 - availableActions={availableActions} 133 - onAddRawAction={(name, payload) => session.addRawAction(name, payload)} 134 - onDeleteEntry={(idx) => session.timeline.deleteEntry(idx)} 135 - /> 136 </Pane> 137 </div> 138 <div className="Workspace-preview"> ··· 142 totalChunks={totalChunks} 143 isAtStart={isAtStart} 144 isAtEnd={isAtEnd} 145 - onStep={() => session.timeline.stepForward()} 146 - onSkip={() => session.timeline.skipToEntryEnd()} 147 - onReset={onReset} 148 /> 149 </div> 150 - </> 151 ); 152 }
··· 1 + import React, { useState, useEffect, useSyncExternalStore } from "react"; 2 + import { WorkspaceSession, loadingTimeline } from "../workspace-session.ts"; 3 import { CodeEditor } from "./CodeEditor.tsx"; 4 import { FlightLog } from "./FlightLog.tsx"; 5 import { LivePreview } from "./LivePreview.tsx"; ··· 26 const abort = new AbortController(); 27 WorkspaceSession.create(serverCode, clientCode, abort.signal).then((nextSession) => { 28 if (!abort.signal.aborted) { 29 + setSession(nextSession); 30 } 31 }); 32 return () => abort.abort(); ··· 46 setResetKey((k) => k + 1); 47 } 48 49 + const timeline = session?.timeline ?? loadingTimeline; 50 + const { entries, cursor, totalChunks, isAtStart, isAtEnd } = useSyncExternalStore( 51 + timeline.subscribe, 52 + timeline.getSnapshot, 53 + ); 54 + 55 + const isLoading = !session; 56 + const isError = session?.state.status === "error"; 57 + 58 return ( 59 <main className="Workspace"> 60 <div className="Workspace-server"> ··· 63 <div className="Workspace-client"> 64 <CodeEditor label="client" defaultValue={clientCode} onChange={handleClientChange} /> 65 </div> 66 <div className="Workspace-flight"> 67 <Pane label="flight"> 68 + {isLoading ? ( 69 + <div className="Workspace-loadingOutput"> 70 + <span className="Workspace-loadingEmpty Workspace-loadingEmpty--waiting"> 71 + Compiling 72 + </span> 73 </div> 74 + ) : isError ? ( 75 + <pre className="Workspace-errorOutput">{session.state.message}</pre> 76 + ) : ( 77 + <FlightLog 78 + entries={entries} 79 + cursor={cursor} 80 + availableActions={session.state.availableActions} 81 + onAddRawAction={session.addRawAction} 82 + onDeleteEntry={session.timeline.deleteEntry} 83 + /> 84 + )} 85 </Pane> 86 </div> 87 <div className="Workspace-preview"> ··· 91 totalChunks={totalChunks} 92 isAtStart={isAtStart} 93 isAtEnd={isAtEnd} 94 + isLoading={isLoading || isError} 95 + onStep={timeline.stepForward} 96 + onSkip={timeline.skipToEntryEnd} 97 + onReset={reset} 98 /> 99 </div> 100 + </main> 101 ); 102 }
+17 -2
src/client/workspace-session.ts
··· 15 16 let lastId = 0; 17 18 export class WorkspaceSession { 19 readonly timeline = new Timeline(); 20 readonly state: SessionState; ··· 91 return stream.flightPromise; 92 } 93 94 - async addRawAction(actionName: string, rawPayload: string): Promise<void> { 95 await this.runAction(actionName, { type: "formdata", data: rawPayload }, rawPayload); 96 - } 97 }
··· 15 16 let lastId = 0; 17 18 + const emptySnapshot = { 19 + entries: [] as never[], 20 + cursor: 0, 21 + totalChunks: 0, 22 + isAtStart: true, 23 + isAtEnd: false, 24 + }; 25 + 26 + export const loadingTimeline = { 27 + subscribe: () => () => {}, 28 + getSnapshot: () => emptySnapshot, 29 + stepForward: () => {}, 30 + skipToEntryEnd: () => {}, 31 + }; 32 + 33 export class WorkspaceSession { 34 readonly timeline = new Timeline(); 35 readonly state: SessionState; ··· 106 return stream.flightPromise; 107 } 108 109 + addRawAction = async (actionName: string, rawPayload: string): Promise<void> => { 110 await this.runAction(actionName, { type: "formdata", data: rawPayload }, rawPayload); 111 + }; 112 }