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
80
return this.cachedSnapshot;
81
81
};
82
82
83
83
-
setRender(stream: SteppableStream): void {
83
83
+
setRender = (stream: SteppableStream): void => {
84
84
this.entries = [{ type: "render", stream }];
85
85
this.cursor = 0;
86
86
this.notify();
87
87
-
}
87
87
+
};
88
88
89
89
-
addAction(name: string, args: string, stream: SteppableStream): void {
89
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
-
}
92
92
+
};
93
93
94
94
-
deleteEntry(entryIndex: number): void {
94
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
-
}
104
104
+
};
105
105
106
106
-
stepForward(): void {
106
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
-
}
118
118
+
};
119
119
120
120
-
skipToEntryEnd(): void {
120
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
-
}
134
134
+
};
135
135
136
136
-
clear(): void {
136
136
+
clear = (): void => {
137
137
this.entries = [];
138
138
this.cursor = 0;
139
139
this.notify();
140
140
-
}
140
140
+
};
141
141
}
+13
-7
src/client/ui/LivePreview.tsx
···
47
47
totalChunks: number;
48
48
isAtStart: boolean;
49
49
isAtEnd: boolean;
50
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
62
+
isLoading,
61
63
onStep,
62
64
onSkip,
63
65
onReset,
···
99
101
};
100
102
101
103
let statusText = "";
102
102
-
if (isAtStart) {
104
104
+
if (isLoading) {
105
105
+
statusText = "Compiling";
106
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
117
-
disabled={isAtStart}
121
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
127
-
disabled={isAtEnd}
131
131
+
disabled={isLoading || isAtEnd}
128
132
title={isPlaying ? "Pause" : "Play"}
129
133
>
130
134
{isPlaying ? (
···
138
142
)}
139
143
</button>
140
144
<button
141
141
-
className={`LivePreview-controlBtn${!isAtEnd ? " LivePreview-controlBtn--step" : ""}`}
145
145
+
className={`LivePreview-controlBtn${!isLoading && !isAtEnd ? " LivePreview-controlBtn--step" : ""}`}
142
146
onClick={handleStep}
143
143
-
disabled={isAtEnd}
147
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
160
-
disabled={isAtEnd}
164
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
180
-
{showPlaceholder ? (
184
184
+
{isLoading ? (
185
185
+
<span className="LivePreview-empty">Compiling...</span>
186
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
1
-
import React, { useState, useEffect, useSyncExternalStore, startTransition } from "react";
2
2
-
import { WorkspaceSession } from "../workspace-session.ts";
1
1
+
import React, { useState, useEffect, useSyncExternalStore } from "react";
2
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
29
-
startTransition(() => {
30
30
-
setSession(nextSession);
31
31
-
});
29
29
+
setSession(nextSession);
32
30
}
33
31
});
34
32
return () => abort.abort();
···
48
46
setResetKey((k) => k + 1);
49
47
}
50
48
49
49
+
const timeline = session?.timeline ?? loadingTimeline;
50
50
+
const { entries, cursor, totalChunks, isAtStart, isAtEnd } = useSyncExternalStore(
51
51
+
timeline.subscribe,
52
52
+
timeline.getSnapshot,
53
53
+
);
54
54
+
55
55
+
const isLoading = !session;
56
56
+
const isError = session?.state.status === "error";
57
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
59
-
{session ? (
60
60
-
<WorkspaceContent session={session} onReset={reset} key={session.id} />
61
61
-
) : (
62
62
-
<WorkspaceLoading />
63
63
-
)}
64
64
-
</main>
65
65
-
);
66
66
-
}
67
67
-
68
68
-
function WorkspaceLoading(): React.ReactElement {
69
69
-
return (
70
70
-
<>
71
66
<div className="Workspace-flight">
72
67
<Pane label="flight">
73
73
-
<div className="Workspace-loadingOutput">
74
74
-
<span className="Workspace-loadingEmpty Workspace-loadingEmpty--waiting">
75
75
-
Compiling
76
76
-
</span>
77
77
-
</div>
78
78
-
</Pane>
79
79
-
</div>
80
80
-
<div className="Workspace-preview">
81
81
-
<Pane label="preview">
82
82
-
<div className="Workspace-loadingPreview">
83
83
-
<span className="Workspace-loadingEmpty Workspace-loadingEmpty--waiting">
84
84
-
Compiling
85
85
-
</span>
86
86
-
</div>
87
87
-
</Pane>
88
88
-
</div>
89
89
-
</>
90
90
-
);
91
91
-
}
92
92
-
93
93
-
type WorkspaceContentProps = {
94
94
-
session: WorkspaceSession;
95
95
-
onReset: () => void;
96
96
-
};
97
97
-
98
98
-
function WorkspaceContent({ session, onReset }: WorkspaceContentProps): React.ReactElement {
99
99
-
const { entries, cursor, totalChunks, isAtStart, isAtEnd } = useSyncExternalStore(
100
100
-
session.timeline.subscribe,
101
101
-
session.timeline.getSnapshot,
102
102
-
);
103
103
-
104
104
-
if (session.state.status === "error") {
105
105
-
return (
106
106
-
<>
107
107
-
<div className="Workspace-flight">
108
108
-
<Pane label="flight">
109
109
-
<pre className="Workspace-errorOutput">{session.state.message}</pre>
110
110
-
</Pane>
111
111
-
</div>
112
112
-
<div className="Workspace-preview">
113
113
-
<Pane label="preview">
114
114
-
<div className="Workspace-errorPreview">
115
115
-
<span className="Workspace-errorMessage">Compilation error</span>
68
68
+
{isLoading ? (
69
69
+
<div className="Workspace-loadingOutput">
70
70
+
<span className="Workspace-loadingEmpty Workspace-loadingEmpty--waiting">
71
71
+
Compiling
72
72
+
</span>
116
73
</div>
117
117
-
</Pane>
118
118
-
</div>
119
119
-
</>
120
120
-
);
121
121
-
}
122
122
-
123
123
-
const { availableActions } = session.state;
124
124
-
125
125
-
return (
126
126
-
<>
127
127
-
<div className="Workspace-flight">
128
128
-
<Pane label="flight">
129
129
-
<FlightLog
130
130
-
entries={entries}
131
131
-
cursor={cursor}
132
132
-
availableActions={availableActions}
133
133
-
onAddRawAction={(name, payload) => session.addRawAction(name, payload)}
134
134
-
onDeleteEntry={(idx) => session.timeline.deleteEntry(idx)}
135
135
-
/>
74
74
+
) : isError ? (
75
75
+
<pre className="Workspace-errorOutput">{session.state.message}</pre>
76
76
+
) : (
77
77
+
<FlightLog
78
78
+
entries={entries}
79
79
+
cursor={cursor}
80
80
+
availableActions={session.state.availableActions}
81
81
+
onAddRawAction={session.addRawAction}
82
82
+
onDeleteEntry={session.timeline.deleteEntry}
83
83
+
/>
84
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
145
-
onStep={() => session.timeline.stepForward()}
146
146
-
onSkip={() => session.timeline.skipToEntryEnd()}
147
147
-
onReset={onReset}
94
94
+
isLoading={isLoading || isError}
95
95
+
onStep={timeline.stepForward}
96
96
+
onSkip={timeline.skipToEntryEnd}
97
97
+
onReset={reset}
148
98
/>
149
99
</div>
150
150
-
</>
100
100
+
</main>
151
101
);
152
102
}
+17
-2
src/client/workspace-session.ts
···
15
15
16
16
let lastId = 0;
17
17
18
18
+
const emptySnapshot = {
19
19
+
entries: [] as never[],
20
20
+
cursor: 0,
21
21
+
totalChunks: 0,
22
22
+
isAtStart: true,
23
23
+
isAtEnd: false,
24
24
+
};
25
25
+
26
26
+
export const loadingTimeline = {
27
27
+
subscribe: () => () => {},
28
28
+
getSnapshot: () => emptySnapshot,
29
29
+
stepForward: () => {},
30
30
+
skipToEntryEnd: () => {},
31
31
+
};
32
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
94
-
async addRawAction(actionName: string, rawPayload: string): Promise<void> {
109
109
+
addRawAction = async (actionName: string, rawPayload: string): Promise<void> => {
95
110
await this.runAction(actionName, { type: "formdata", data: rawPayload }, rawPayload);
96
96
-
}
111
111
+
};
97
112
}