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
tweak styles and fix tests
danabra.mov
2 months ago
9b26164e
f10f49e4
+149
-97
11 changed files
expand all
collapse all
unified
split
src
client
ui
App.css
EmbedApp.css
FlightLog.css
FlightLog.tsx
LivePreview.css
LivePreview.tsx
tests
bound.spec.ts
counter.spec.ts
form.spec.ts
helpers.ts
pagination.spec.ts
+3
-1
src/client/ui/App.css
···
218
218
border-radius: 4px;
219
219
font-size: 12px;
220
220
cursor: pointer;
221
221
-
transition: all 0.15s;
221
221
+
transition:
222
222
+
background-color 0.15s,
223
223
+
color 0.15s;
222
224
}
223
225
224
226
.App-embedModal-tab:hover {
+3
-1
src/client/ui/EmbedApp.css
···
28
28
border-radius: 4px;
29
29
color: var(--text-dim);
30
30
text-decoration: none;
31
31
-
transition: all 0.15s;
31
31
+
transition:
32
32
+
background-color 0.15s,
33
33
+
color 0.15s;
32
34
}
33
35
34
36
.EmbedApp-fullscreenLink:hover {
+11
-3
src/client/ui/FlightLog.css
···
54
54
background: var(--surface);
55
55
border: 1px solid var(--border);
56
56
border-radius: 4px;
57
57
-
transition: all 0.15s ease;
57
57
+
transition:
58
58
+
border-color 0.15s ease,
59
59
+
opacity 0.15s ease;
58
60
border-left: 3px solid #555;
59
61
}
60
62
···
175
177
word-break: break-all;
176
178
white-space: pre-wrap;
177
179
border-left: 2px solid transparent;
178
178
-
transition: all 0.15s ease;
180
180
+
transition:
181
181
+
background-color 0.15s ease,
182
182
+
color 0.15s ease,
183
183
+
border-color 0.15s ease;
179
184
}
180
185
181
186
.FlightLog-line:last-child {
···
301
306
cursor: pointer;
302
307
font-size: 14px;
303
308
line-height: 22px;
304
304
-
transition: all 0.15s;
309
309
+
transition:
310
310
+
background-color 0.15s,
311
311
+
border-color 0.15s,
312
312
+
color 0.15s;
305
313
}
306
314
307
315
.FlightLog-addButton:hover {
+16
-11
src/client/ui/FlightLog.tsx
···
42
42
<div className="FlightLog-renderView-split">
43
43
<div className="FlightLog-linesWrapper">
44
44
<pre className="FlightLog-lines">
45
45
-
{rows.map((line, i) => (
46
46
-
<span
47
47
-
key={i}
48
48
-
ref={i === nextLineIndex ? activeRef : null}
49
49
-
className={`FlightLog-line ${getLineClass(i)}`}
50
50
-
>
51
51
-
{escapeHtml(line)}
52
52
-
</span>
53
53
-
))}
45
45
+
{rows.map((line, i) => {
46
46
+
const isCurrent = i === nextLineIndex;
47
47
+
return (
48
48
+
<span
49
49
+
key={i}
50
50
+
ref={isCurrent ? activeRef : null}
51
51
+
className={`FlightLog-line ${getLineClass(i)}`}
52
52
+
data-testid="flight-line"
53
53
+
aria-current={isCurrent ? "step" : undefined}
54
54
+
>
55
55
+
{escapeHtml(line)}
56
56
+
</span>
57
57
+
);
58
58
+
})}
54
59
</pre>
55
60
</div>
56
56
-
<div className="FlightLog-tree">
61
61
+
<div className="FlightLog-tree" data-testid="flight-tree">
57
62
{showTree && <FlightTreeView flightPromise={flightPromise ?? null} inEntry />}
58
63
</div>
59
64
</div>
···
81
86
: "FlightLog-entry--pending";
82
87
83
88
return (
84
84
-
<div className={`FlightLog-entry ${modifierClass}`}>
89
89
+
<div className={`FlightLog-entry ${modifierClass}`} data-testid="flight-entry">
85
90
<div className="FlightLog-entry-header">
86
91
<span className="FlightLog-entry-label">
87
92
{entry.type === "render" ? "Render" : `Action: ${entry.name}`}
+22
-33
src/client/ui/LivePreview.css
···
19
19
background: transparent;
20
20
border: none;
21
21
color: var(--text);
22
22
-
width: 30px;
23
23
-
height: 30px;
24
24
-
border-radius: 4px;
22
22
+
width: 44px;
23
23
+
height: 44px;
24
24
+
border-radius: 6px;
25
25
cursor: pointer;
26
26
display: flex;
27
27
align-items: center;
28
28
justify-content: center;
29
29
-
transition: all 0.1s;
29
29
+
transition:
30
30
+
background-color 0.1s,
31
31
+
color 0.1s;
30
32
}
31
33
32
34
.LivePreview-controlBtn svg {
33
33
-
width: 16px;
34
34
-
height: 16px;
35
35
+
width: 20px;
36
36
+
height: 20px;
35
37
}
36
38
37
39
.LivePreview-controlBtn:hover:not(:disabled) {
···
117
119
font-family: var(--font-mono);
118
120
min-width: 60px;
119
121
text-align: right;
122
122
+
white-space: nowrap;
120
123
}
121
124
122
125
.LivePreview-container {
···
177
180
/* Responsive */
178
181
179
182
@media (max-width: 768px) {
180
180
-
.LivePreview-playback {
181
181
-
padding: 6px 8px;
182
182
-
gap: 6px;
183
183
-
}
184
184
-
185
185
-
.LivePreview-controls {
186
186
-
gap: 2px;
187
187
-
}
188
188
-
189
189
-
.LivePreview-controlBtn {
190
190
-
width: 26px;
191
191
-
height: 26px;
183
183
+
.LivePreview-slider {
184
184
+
display: none;
192
185
}
193
186
194
187
.LivePreview-stepInfo {
195
195
-
min-width: 50px;
196
196
-
font-size: 10px;
188
188
+
display: none;
197
189
}
198
190
}
199
191
200
200
-
@media (max-width: 480px) {
201
201
-
.LivePreview-stepInfo {
202
202
-
display: none;
192
192
+
@media (max-width: 400px) {
193
193
+
.LivePreview-playback {
194
194
+
padding: 6px;
203
195
}
204
196
205
205
-
.LivePreview-playback {
206
206
-
padding: 4px 6px;
207
207
-
gap: 4px;
197
197
+
.LivePreview-controls {
198
198
+
flex: 1;
199
199
+
justify-content: space-between;
200
200
+
gap: 0;
208
201
}
209
202
210
203
.LivePreview-controlBtn {
211
211
-
width: 24px;
212
212
-
height: 24px;
213
213
-
}
214
214
-
215
215
-
.LivePreview-controlBtn svg {
216
216
-
width: 14px;
217
217
-
height: 14px;
204
204
+
flex: 1;
205
205
+
width: auto;
206
206
+
max-width: 44px;
218
207
}
219
208
}
+32
-7
src/client/ui/LivePreview.tsx
···
114
114
return (
115
115
<Pane label="preview">
116
116
<div className="LivePreview-playback">
117
117
-
<div className="LivePreview-controls">
117
117
+
<div className="LivePreview-controls" role="toolbar" aria-label="Playback controls">
118
118
<button
119
119
className="LivePreview-controlBtn"
120
120
onClick={handleReset}
121
121
disabled={isLoading || isAtStart}
122
122
+
aria-label="Reset"
122
123
title="Reset"
123
124
>
124
124
-
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
125
125
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
125
126
<path d="M8 1a7 7 0 1 1-7 7h1.5a5.5 5.5 0 1 0 1.6-3.9L6 6H1V1l1.6 1.6A7 7 0 0 1 8 1z" />
126
127
</svg>
127
128
</button>
···
129
130
className={`LivePreview-controlBtn${isPlaying ? " LivePreview-controlBtn--playing" : ""}`}
130
131
onClick={handlePlayPause}
131
132
disabled={isLoading || isAtEnd}
133
133
+
aria-label={isPlaying ? "Pause" : "Play"}
132
134
title={isPlaying ? "Pause" : "Play"}
133
135
>
134
136
{isPlaying ? (
135
135
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
137
137
+
<svg
138
138
+
width="16"
139
139
+
height="16"
140
140
+
viewBox="0 0 24 24"
141
141
+
fill="currentColor"
142
142
+
aria-hidden="true"
143
143
+
>
136
144
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
137
145
</svg>
138
146
) : (
139
139
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
147
147
+
<svg
148
148
+
width="16"
149
149
+
height="16"
150
150
+
viewBox="0 0 24 24"
151
151
+
fill="currentColor"
152
152
+
aria-hidden="true"
153
153
+
>
140
154
<path d="M8 5v14l11-7L8 5z" />
141
155
</svg>
142
156
)}
···
145
159
className={`LivePreview-controlBtn${!isLoading && !isAtEnd ? " LivePreview-controlBtn--step" : ""}`}
146
160
onClick={handleStep}
147
161
disabled={isLoading || isAtEnd}
162
162
+
aria-label="Step forward"
148
163
title="Step forward"
149
164
>
150
165
<svg
···
154
169
fill="none"
155
170
stroke="currentColor"
156
171
strokeWidth="2.5"
172
172
+
aria-hidden="true"
157
173
>
158
174
<path d="M9 6l6 6-6 6" />
159
175
</svg>
···
162
178
className="LivePreview-controlBtn"
163
179
onClick={handleSkip}
164
180
disabled={isLoading || isAtEnd}
181
181
+
aria-label="Skip to end"
165
182
title="Skip to end"
166
183
>
167
167
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
184
184
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
168
185
<path d="M5.5 18V6l9 6-9 6zm9-12h2v12h-2V6z" />
169
186
</svg>
170
187
</button>
···
177
194
onChange={() => {}}
178
195
disabled
179
196
className="LivePreview-slider"
197
197
+
aria-label="Playback progress"
180
198
/>
181
181
-
<span className="LivePreview-stepInfo">{statusText}</span>
199
199
+
<span
200
200
+
className="LivePreview-stepInfo"
201
201
+
data-testid="step-info"
202
202
+
role="status"
203
203
+
aria-live="polite"
204
204
+
>
205
205
+
{statusText}
206
206
+
</span>
182
207
</div>
183
183
-
<div className="LivePreview-container">
208
208
+
<div className="LivePreview-container" data-testid="preview-container">
184
209
{isLoading ? (
185
210
<span className="LivePreview-empty">Loading...</span>
186
211
) : showPlaceholder ? (
+2
-2
tests/bound.spec.ts
···
72
72
);
73
73
74
74
// Fill all three inputs and submit all three forms
75
75
-
const inputs = h.frame().locator(".preview-container input");
76
76
-
const buttons = h.frame().locator(".preview-container button");
75
75
+
const inputs = h.frame().getByTestId("preview-container").locator("input");
76
76
+
const buttons = h.frame().getByTestId("preview-container").locator("button");
77
77
78
78
await inputs.nth(0).fill("Alice");
79
79
await inputs.nth(1).fill("Bob");
+1
-1
tests/counter.spec.ts
···
39
39
`);
40
40
41
41
// Client interactivity works
42
42
-
await h.frame().locator(".preview-container button").last().click();
42
42
+
await h.frame().getByTestId("preview-container").locator("button").last().click();
43
43
expect(await h.preview("Count: 1")).toMatchInlineSnapshot(`
44
44
"Counter
45
45
+2
-2
tests/form.spec.ts
···
35
35
`);
36
36
37
37
// Submit form
38
38
-
await h.frame().locator('.preview-container input[name="name"]').fill("World");
39
39
-
await h.frame().locator(".preview-container button").click();
38
38
+
await h.frame().getByTestId("preview-container").locator('input[name="name"]').fill("World");
39
39
+
await h.frame().getByTestId("preview-container").locator("button").click();
40
40
expect(await h.preview("Sending")).toMatchInlineSnapshot(`
41
41
"Form Action
42
42
Sending..."
+52
-33
tests/helpers.ts
···
44
44
const iframe = page.frameLocator("iframe");
45
45
frameRef = iframe;
46
46
// Wait for content inside iframe
47
47
-
await iframe.locator(".log-entry").first().waitFor({ timeout: 10000 });
47
47
+
await iframe.getByTestId("flight-entry").first().waitFor({ timeout: 10000 });
48
48
await page.waitForTimeout(100);
49
49
prevRowTexts = [];
50
50
prevStatuses = [];
···
54
54
55
55
async function getPreviewText(): Promise<string> {
56
56
if (!frameRef) throw new Error("frameRef not initialized");
57
57
-
return (await frameRef.locator(".preview-container").innerText()).trim();
57
57
+
return (await frameRef.getByTestId("preview-container").innerText()).trim();
58
58
+
}
59
59
+
60
60
+
function getStepButton() {
61
61
+
if (!frameRef) throw new Error("frameRef not initialized");
62
62
+
return frameRef.getByRole("button", { name: "Step forward" });
58
63
}
59
64
60
65
async function doStep(): Promise<string | null> {
61
66
if (!frameRef || !pageRef) throw new Error("refs not initialized");
62
62
-
const btn = frameRef.locator(".control-btn").nth(2);
67
67
+
const btn = getStepButton();
63
68
if (await btn.isDisabled()) return null;
64
69
await btn.click();
65
70
await pageRef.waitForTimeout(50);
···
107
112
108
113
async function waitForStepButton(): Promise<void> {
109
114
if (!frameRef || !pageRef) throw new Error("refs not initialized");
110
110
-
const btn = frameRef.locator(".control-btn").nth(2);
115
115
+
const btn = getStepButton();
111
116
// Wait for button to be enabled
112
117
await expect
113
118
.poll(
···
176
181
177
182
async function stepInfo(): Promise<string> {
178
183
if (!frameRef) throw new Error("frameRef not initialized");
179
179
-
return (await frameRef.locator(".step-info").innerText()).trim();
184
184
+
return (await frameRef.getByTestId("step-info").innerText()).trim();
180
185
}
181
186
182
187
async function getRows(): Promise<RowData[]> {
183
188
if (!frameRef) throw new Error("frameRef not initialized");
184
184
-
return frameRef.locator(".flight-line").evaluateAll((els) =>
185
185
-
(els as HTMLElement[])
186
186
-
.map((el) => ({
187
187
-
text: el.textContent,
188
188
-
status: el.classList.contains("line-done")
189
189
-
? ("done" as const)
190
190
-
: el.classList.contains("line-next")
191
191
-
? ("next" as const)
192
192
-
: ("pending" as const),
193
193
-
}))
194
194
-
.filter(
195
195
-
({ text }) =>
196
196
-
text !== null &&
197
197
-
!text.startsWith(":N") &&
198
198
-
!/^\w+:D/.test(text) &&
199
199
-
!/^\w+:\{.*"name"/.test(text) &&
200
200
-
!/^\w+:\[\[/.test(text),
201
201
-
),
202
202
-
);
189
189
+
// Get all flight lines and determine status by aria-current and position
190
190
+
return frameRef.getByTestId("flight-line").evaluateAll((els) => {
191
191
+
const result: { text: string | null; status: "done" | "next" | "pending" }[] = [];
192
192
+
let foundCurrent = false;
193
193
+
194
194
+
for (const el of els as HTMLElement[]) {
195
195
+
const text = el.textContent;
196
196
+
const isCurrent = el.getAttribute("aria-current") === "step";
197
197
+
198
198
+
let status: "done" | "next" | "pending";
199
199
+
if (isCurrent) {
200
200
+
status = "next";
201
201
+
foundCurrent = true;
202
202
+
} else if (foundCurrent) {
203
203
+
status = "pending";
204
204
+
} else {
205
205
+
status = "done";
206
206
+
}
207
207
+
208
208
+
// Filter out certain protocol lines
209
209
+
if (
210
210
+
text !== null &&
211
211
+
!text.startsWith(":N") &&
212
212
+
!/^\w+:D/.test(text) &&
213
213
+
!/^\w+:\{.*"name"/.test(text) &&
214
214
+
!/^\w+:\[\[/.test(text)
215
215
+
) {
216
216
+
result.push({ text, status });
217
217
+
}
218
218
+
}
219
219
+
220
220
+
return result;
221
221
+
});
203
222
}
204
223
205
224
async function tree(): Promise<string | null> {
206
225
if (!frameRef) throw new Error("frameRef not initialized");
207
207
-
// Find the log entry containing the "next" line, or the last done entry
208
208
-
const treeText = await frameRef.locator(".log-entry").evaluateAll((entries) => {
209
209
-
const nextLine = document.querySelector(".line-next");
210
210
-
if (nextLine) {
211
211
-
const entry = nextLine.closest(".log-entry");
212
212
-
const tree = entry?.querySelector(".log-entry-tree") as HTMLElement | null;
226
226
+
// Find the tree in the entry containing the current line, or the last entry's tree
227
227
+
const treeText = await frameRef.getByTestId("flight-entry").evaluateAll((entries) => {
228
228
+
const currentLine = document.querySelector('[aria-current="step"]');
229
229
+
if (currentLine) {
230
230
+
const entry = currentLine.closest('[data-testid="flight-entry"]');
231
231
+
const tree = entry?.querySelector('[data-testid="flight-tree"]') as HTMLElement | null;
213
232
return tree?.innerText?.trim() || null;
214
233
}
215
215
-
// No next line - get the last entry's tree
234
234
+
// No current line - get the last entry's tree
216
235
if (entries.length === 0) return null;
217
236
const lastEntry = entries[entries.length - 1] as HTMLElement | undefined;
218
237
if (!lastEntry) return null;
219
219
-
const tree = lastEntry.querySelector(".log-entry-tree") as HTMLElement | null;
238
238
+
const tree = lastEntry.querySelector('[data-testid="flight-tree"]') as HTMLElement | null;
220
239
return tree?.innerText?.trim() || null;
221
240
});
222
241
return treeText;
···
229
248
230
249
// Consume remaining steps, but fail if tree or preview changes
231
250
while (true) {
232
232
-
const btn = frameRef.locator(".control-btn").nth(2);
251
251
+
const btn = getStepButton();
233
252
if (await btn.isDisabled()) break;
234
253
235
254
await btn.click();
+5
-3
tests/pagination.spec.ts
···
100
100
);
101
101
102
102
// First Load More
103
103
-
await h.frame().locator(".preview-container button").click();
103
103
+
await h.frame().getByTestId("preview-container").locator("button").click();
104
104
expect(await h.preview("Loading...")).toMatchInlineSnapshot(
105
105
`
106
106
"Pagination
···
179
179
);
180
180
181
181
// Second Load More
182
182
-
await h.frame().locator(".preview-container button").click();
182
182
+
await h.frame().getByTestId("preview-container").locator("button").click();
183
183
expect(await h.preview("Loading...")).toMatchInlineSnapshot(
184
184
`
185
185
"Pagination
···
272
272
);
273
273
274
274
// No more items - button should be gone
275
275
-
expect(await h.frame().locator(".preview-container button").isVisible()).toBe(false);
275
275
+
expect(await h.frame().getByTestId("preview-container").locator("button").isVisible()).toBe(
276
276
+
false,
277
277
+
);
276
278
});