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