A tool for people curious about the React Server Components protocol rscexplorer.dev/
rsc react

tweak styles and fix tests

+149 -97
+3 -1
src/client/ui/App.css
··· 218 218 border-radius: 4px; 219 219 font-size: 12px; 220 220 cursor: pointer; 221 - transition: all 0.15s; 221 + transition: 222 + background-color 0.15s, 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 - transition: all 0.15s; 31 + transition: 32 + background-color 0.15s, 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 - transition: all 0.15s ease; 57 + transition: 58 + border-color 0.15s ease, 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 - transition: all 0.15s ease; 180 + transition: 181 + background-color 0.15s ease, 182 + color 0.15s ease, 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 - transition: all 0.15s; 309 + transition: 310 + background-color 0.15s, 311 + border-color 0.15s, 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 - {rows.map((line, i) => ( 46 - <span 47 - key={i} 48 - ref={i === nextLineIndex ? activeRef : null} 49 - className={`FlightLog-line ${getLineClass(i)}`} 50 - > 51 - {escapeHtml(line)} 52 - </span> 53 - ))} 45 + {rows.map((line, i) => { 46 + const isCurrent = i === nextLineIndex; 47 + return ( 48 + <span 49 + key={i} 50 + ref={isCurrent ? activeRef : null} 51 + className={`FlightLog-line ${getLineClass(i)}`} 52 + data-testid="flight-line" 53 + aria-current={isCurrent ? "step" : undefined} 54 + > 55 + {escapeHtml(line)} 56 + </span> 57 + ); 58 + })} 54 59 </pre> 55 60 </div> 56 - <div className="FlightLog-tree"> 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 - <div className={`FlightLog-entry ${modifierClass}`}> 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 - width: 30px; 23 - height: 30px; 24 - border-radius: 4px; 22 + width: 44px; 23 + height: 44px; 24 + border-radius: 6px; 25 25 cursor: pointer; 26 26 display: flex; 27 27 align-items: center; 28 28 justify-content: center; 29 - transition: all 0.1s; 29 + transition: 30 + background-color 0.1s, 31 + color 0.1s; 30 32 } 31 33 32 34 .LivePreview-controlBtn svg { 33 - width: 16px; 34 - height: 16px; 35 + width: 20px; 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 + white-space: nowrap; 120 123 } 121 124 122 125 .LivePreview-container { ··· 177 180 /* Responsive */ 178 181 179 182 @media (max-width: 768px) { 180 - .LivePreview-playback { 181 - padding: 6px 8px; 182 - gap: 6px; 183 - } 184 - 185 - .LivePreview-controls { 186 - gap: 2px; 187 - } 188 - 189 - .LivePreview-controlBtn { 190 - width: 26px; 191 - height: 26px; 183 + .LivePreview-slider { 184 + display: none; 192 185 } 193 186 194 187 .LivePreview-stepInfo { 195 - min-width: 50px; 196 - font-size: 10px; 188 + display: none; 197 189 } 198 190 } 199 191 200 - @media (max-width: 480px) { 201 - .LivePreview-stepInfo { 202 - display: none; 192 + @media (max-width: 400px) { 193 + .LivePreview-playback { 194 + padding: 6px; 203 195 } 204 196 205 - .LivePreview-playback { 206 - padding: 4px 6px; 207 - gap: 4px; 197 + .LivePreview-controls { 198 + flex: 1; 199 + justify-content: space-between; 200 + gap: 0; 208 201 } 209 202 210 203 .LivePreview-controlBtn { 211 - width: 24px; 212 - height: 24px; 213 - } 214 - 215 - .LivePreview-controlBtn svg { 216 - width: 14px; 217 - height: 14px; 204 + flex: 1; 205 + width: auto; 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 - <div className="LivePreview-controls"> 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 + aria-label="Reset" 122 123 title="Reset" 123 124 > 124 - <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> 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 + aria-label={isPlaying ? "Pause" : "Play"} 132 134 title={isPlaying ? "Pause" : "Play"} 133 135 > 134 136 {isPlaying ? ( 135 - <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> 137 + <svg 138 + width="16" 139 + height="16" 140 + viewBox="0 0 24 24" 141 + fill="currentColor" 142 + aria-hidden="true" 143 + > 136 144 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 137 145 </svg> 138 146 ) : ( 139 - <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> 147 + <svg 148 + width="16" 149 + height="16" 150 + viewBox="0 0 24 24" 151 + fill="currentColor" 152 + aria-hidden="true" 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 + 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 + 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 + aria-label="Skip to end" 165 182 title="Skip to end" 166 183 > 167 - <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> 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 + aria-label="Playback progress" 180 198 /> 181 - <span className="LivePreview-stepInfo">{statusText}</span> 199 + <span 200 + className="LivePreview-stepInfo" 201 + data-testid="step-info" 202 + role="status" 203 + aria-live="polite" 204 + > 205 + {statusText} 206 + </span> 182 207 </div> 183 - <div className="LivePreview-container"> 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 - const inputs = h.frame().locator(".preview-container input"); 76 - const buttons = h.frame().locator(".preview-container button"); 75 + const inputs = h.frame().getByTestId("preview-container").locator("input"); 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 - await h.frame().locator(".preview-container button").last().click(); 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 - await h.frame().locator('.preview-container input[name="name"]').fill("World"); 39 - await h.frame().locator(".preview-container button").click(); 38 + await h.frame().getByTestId("preview-container").locator('input[name="name"]').fill("World"); 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 - await iframe.locator(".log-entry").first().waitFor({ timeout: 10000 }); 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 - return (await frameRef.locator(".preview-container").innerText()).trim(); 57 + return (await frameRef.getByTestId("preview-container").innerText()).trim(); 58 + } 59 + 60 + function getStepButton() { 61 + if (!frameRef) throw new Error("frameRef not initialized"); 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 - const btn = frameRef.locator(".control-btn").nth(2); 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 - const btn = frameRef.locator(".control-btn").nth(2); 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 - return (await frameRef.locator(".step-info").innerText()).trim(); 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 - return frameRef.locator(".flight-line").evaluateAll((els) => 185 - (els as HTMLElement[]) 186 - .map((el) => ({ 187 - text: el.textContent, 188 - status: el.classList.contains("line-done") 189 - ? ("done" as const) 190 - : el.classList.contains("line-next") 191 - ? ("next" as const) 192 - : ("pending" as const), 193 - })) 194 - .filter( 195 - ({ text }) => 196 - text !== null && 197 - !text.startsWith(":N") && 198 - !/^\w+:D/.test(text) && 199 - !/^\w+:\{.*"name"/.test(text) && 200 - !/^\w+:\[\[/.test(text), 201 - ), 202 - ); 189 + // Get all flight lines and determine status by aria-current and position 190 + return frameRef.getByTestId("flight-line").evaluateAll((els) => { 191 + const result: { text: string | null; status: "done" | "next" | "pending" }[] = []; 192 + let foundCurrent = false; 193 + 194 + for (const el of els as HTMLElement[]) { 195 + const text = el.textContent; 196 + const isCurrent = el.getAttribute("aria-current") === "step"; 197 + 198 + let status: "done" | "next" | "pending"; 199 + if (isCurrent) { 200 + status = "next"; 201 + foundCurrent = true; 202 + } else if (foundCurrent) { 203 + status = "pending"; 204 + } else { 205 + status = "done"; 206 + } 207 + 208 + // Filter out certain protocol lines 209 + if ( 210 + text !== null && 211 + !text.startsWith(":N") && 212 + !/^\w+:D/.test(text) && 213 + !/^\w+:\{.*"name"/.test(text) && 214 + !/^\w+:\[\[/.test(text) 215 + ) { 216 + result.push({ text, status }); 217 + } 218 + } 219 + 220 + return result; 221 + }); 203 222 } 204 223 205 224 async function tree(): Promise<string | null> { 206 225 if (!frameRef) throw new Error("frameRef not initialized"); 207 - // Find the log entry containing the "next" line, or the last done entry 208 - const treeText = await frameRef.locator(".log-entry").evaluateAll((entries) => { 209 - const nextLine = document.querySelector(".line-next"); 210 - if (nextLine) { 211 - const entry = nextLine.closest(".log-entry"); 212 - const tree = entry?.querySelector(".log-entry-tree") as HTMLElement | null; 226 + // Find the tree in the entry containing the current line, or the last entry's tree 227 + const treeText = await frameRef.getByTestId("flight-entry").evaluateAll((entries) => { 228 + const currentLine = document.querySelector('[aria-current="step"]'); 229 + if (currentLine) { 230 + const entry = currentLine.closest('[data-testid="flight-entry"]'); 231 + const tree = entry?.querySelector('[data-testid="flight-tree"]') as HTMLElement | null; 213 232 return tree?.innerText?.trim() || null; 214 233 } 215 - // No next line - get the last entry's tree 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 - const tree = lastEntry.querySelector(".log-entry-tree") as HTMLElement | null; 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 - const btn = frameRef.locator(".control-btn").nth(2); 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 - await h.frame().locator(".preview-container button").click(); 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 - await h.frame().locator(".preview-container button").click(); 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 - expect(await h.frame().locator(".preview-container button").isVisible()).toBe(false); 275 + expect(await h.frame().getByTestId("preview-container").locator("button").isVisible()).toBe( 276 + false, 277 + ); 276 278 });