tangled
alpha
login
or
join now
danabra.mov
/
rscexplorer
37
fork
atom
A tool for people curious about the React Server Components protocol
rscexplorer.dev/
rsc
react
37
fork
atom
overview
issues
pulls
pipelines
fix playback panel jump
danabra.mov
2 months ago
08a0d0c6
0d671040
+75
-104
4 changed files
expand all
collapse all
unified
split
src
client
runtime
timeline.ts
ui
LivePreview.tsx
Workspace.tsx
workspace-session.ts
+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;
0
50
onStep: () => void;
51
onSkip: () => void;
52
onReset: () => void;
···
58
totalChunks,
59
isAtStart,
60
isAtEnd,
0
61
onStep,
62
onSkip,
63
onReset,
···
99
};
100
101
let statusText = "";
102
-
if (isAtStart) {
0
0
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 ? (
0
0
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
0
0
0
0
0
0
0
0
0
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}
0
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);
0
0
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>
0
0
0
0
0
0
0
0
0
0
0
0
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>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
+
)}
0
0
0
0
0
0
0
0
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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
}