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