A tool for people curious about the React Server Components protocol
rscexplorer.dev/
rsc
react
1import type { SteppableStream, Thenable } from "./steppable-stream.ts";
2
3type InternalEntry =
4 | { type: "render"; stream: SteppableStream }
5 | { type: "action"; name: string; args: string; stream: SteppableStream };
6
7export type RowView = {
8 display: string;
9 hexStart: number;
10};
11
12export type EntryView = {
13 type: "render" | "action";
14 name?: string;
15 args?: string;
16 rows: readonly RowView[];
17 flightPromise: Thenable<unknown> | undefined;
18 error: Error | null;
19 chunkStart: number;
20 chunkCount: number;
21 canDelete: boolean;
22 isActive: boolean;
23 isDone: boolean;
24};
25
26export interface TimelineSnapshot {
27 entries: EntryView[];
28 cursor: number;
29 totalChunks: number;
30 isAtStart: boolean;
31 isAtEnd: boolean;
32 isStreaming: boolean;
33}
34
35type Listener = () => void;
36
37export class Timeline {
38 private entries: InternalEntry[] = [];
39 private cursor = 0;
40 private listeners = new Set<Listener>();
41 private cachedSnapshot: TimelineSnapshot | null = null;
42
43 private notify(): void {
44 this.cachedSnapshot = null;
45 for (const fn of this.listeners) {
46 fn();
47 }
48 }
49
50 subscribe = (listener: Listener): (() => void) => {
51 this.listeners.add(listener);
52 return () => this.listeners.delete(listener);
53 };
54
55 getSnapshot = (): TimelineSnapshot => {
56 if (this.cachedSnapshot) {
57 return this.cachedSnapshot;
58 }
59
60 let chunkStart = 0;
61 const entries: EntryView[] = this.entries.map((entry) => {
62 const { stream } = entry;
63 const chunkCount = stream.rows.length;
64 const chunkEnd = chunkStart + chunkCount;
65 const base = {
66 rows: stream.rows.map((r) => ({ display: r.display, hexStart: r.hexStart })),
67 flightPromise: stream.flightPromise,
68 error: stream.error,
69 chunkStart,
70 chunkCount,
71 canDelete: this.cursor <= chunkStart,
72 isActive: this.cursor >= chunkStart && this.cursor < chunkEnd,
73 isDone: this.cursor >= chunkEnd,
74 };
75 chunkStart = chunkEnd;
76 if (entry.type === "action") {
77 return { type: "action" as const, name: entry.name, args: entry.args, ...base };
78 }
79 return { type: "render" as const, ...base };
80 });
81
82 this.cachedSnapshot = {
83 entries,
84 cursor: this.cursor,
85 totalChunks: chunkStart,
86 isAtStart: this.cursor === 0,
87 isAtEnd: this.cursor >= chunkStart,
88 isStreaming: this.entries.some((e) => !e.stream.done),
89 };
90 return this.cachedSnapshot;
91 };
92
93 setRender = (stream: SteppableStream): void => {
94 this.entries = [{ type: "render", stream }];
95 this.cursor = 0;
96 this.watchStream(stream);
97 this.notify();
98 };
99
100 addAction = (name: string, args: string, stream: SteppableStream): void => {
101 this.entries = [...this.entries, { type: "action", name, args, stream }];
102 this.watchStream(stream);
103 this.notify();
104 };
105
106 private async watchStream(stream: SteppableStream): Promise<void> {
107 try {
108 for await (const _ of stream) {
109 this.notify();
110 }
111 this.notify();
112 } catch {
113 this.notify();
114 }
115 }
116
117 deleteEntry = (entryIndex: number): void => {
118 let chunkStart = 0;
119 for (let i = 0; i < entryIndex; i++) {
120 chunkStart += this.entries[i]!.stream.rows.length;
121 }
122 if (this.cursor > chunkStart) {
123 return;
124 }
125 this.entries = this.entries.filter((_, i) => i !== entryIndex);
126 this.notify();
127 };
128
129 stepForward = (): void => {
130 let remaining = this.cursor;
131 for (const entry of this.entries) {
132 const count = entry.stream.rows.length;
133 if (remaining < count) {
134 entry.stream.release(remaining + 1);
135 this.cursor++;
136 this.notify();
137 return;
138 }
139 remaining -= count;
140 }
141 };
142
143 skipToEntryEnd = (): void => {
144 let remaining = this.cursor;
145 for (const entry of this.entries) {
146 const count = entry.stream.rows.length;
147 if (remaining < count) {
148 for (let local = remaining; local < count; local++) {
149 entry.stream.release(local + 1);
150 }
151 this.cursor += count - remaining;
152 this.notify();
153 return;
154 }
155 remaining -= count;
156 }
157 };
158
159 clear = (): void => {
160 this.entries = [];
161 this.cursor = 0;
162 this.notify();
163 };
164}