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

more error handling

+225 -22
+20
src/client/runtime/steppable-stream.ts
··· 28 releasedCount = 0; 29 buffered = false; 30 closed = false; 31 release: (count: number) => void; 32 flightPromise: Thenable<unknown>; 33 bufferPromise: Promise<void>; ··· 83 84 partial += decoder.decode(); 85 if (partial.trim()) this.rows.push(partial); 86 } finally { 87 this.buffered = true; 88 } ··· 90 91 async waitForBuffer(): Promise<void> { 92 await this.bufferPromise; 93 } 94 }
··· 28 releasedCount = 0; 29 buffered = false; 30 closed = false; 31 + error: Error | null = null; 32 release: (count: number) => void; 33 flightPromise: Thenable<unknown>; 34 bufferPromise: Promise<void>; ··· 84 85 partial += decoder.decode(); 86 if (partial.trim()) this.rows.push(partial); 87 + } catch (err) { 88 + this.error = err instanceof Error ? err : new Error(String(err)); 89 } finally { 90 this.buffered = true; 91 } ··· 93 94 async waitForBuffer(): Promise<void> { 95 await this.bufferPromise; 96 + if (this.error) { 97 + throw this.error; 98 + } 99 + } 100 + 101 + static fromError(error: Error): SteppableStream { 102 + const emptyStream = new ReadableStream<Uint8Array>({ 103 + start(controller) { 104 + controller.close(); 105 + }, 106 + }); 107 + const stream = new SteppableStream(emptyStream); 108 + stream.error = error; 109 + stream.buffered = true; 110 + // Override flightPromise to reject so client transitions complete 111 + stream.flightPromise = Promise.reject(error); 112 + return stream; 113 } 114 }
+2
src/client/runtime/timeline.ts
··· 10 args?: string; 11 rows: string[]; 12 flightPromise: Thenable<unknown> | undefined; 13 chunkStart: number; 14 chunkCount: number; 15 canDelete: boolean; ··· 57 const base = { 58 rows: entry.stream.rows, 59 flightPromise: entry.stream.flightPromise, 60 chunkStart, 61 chunkCount, 62 canDelete: this.cursor <= chunkStart,
··· 10 args?: string; 11 rows: string[]; 12 flightPromise: Thenable<unknown> | undefined; 13 + error: Error | null; 14 chunkStart: number; 15 chunkCount: number; 16 canDelete: boolean; ··· 58 const base = { 59 rows: entry.stream.rows, 60 flightPromise: entry.stream.flightPromise, 61 + error: entry.stream.error, 62 chunkStart, 63 chunkCount, 64 canDelete: this.cursor <= chunkStart,
+37
src/client/samples.ts
··· 576 return String(v) 577 }`, 578 }, 579 cve: { 580 name: "CVE-2025-55182", 581 server: `import { Instructions } from './client'
··· 576 return String(v) 577 }`, 578 }, 579 + actionerror: { 580 + name: "Action Error", 581 + server: `import { Button } from './client' 582 + 583 + export default function App() { 584 + return ( 585 + <div> 586 + <h1>Action Error</h1> 587 + <Button failAction={failAction} /> 588 + </div> 589 + ) 590 + } 591 + 592 + async function failAction() { 593 + 'use server' 594 + throw new Error('Action failed intentionally') 595 + }`, 596 + client: `'use client' 597 + 598 + import { useTransition } from 'react' 599 + 600 + export function Button({ failAction }) { 601 + const [isPending, startTransition] = useTransition() 602 + 603 + const handleClick = () => { 604 + startTransition(async () => { 605 + await failAction() 606 + }) 607 + } 608 + 609 + return ( 610 + <button onClick={handleClick} disabled={isPending}> 611 + {isPending ? 'Running...' : 'Trigger Failing Action'} 612 + </button> 613 + ) 614 + }`, 615 + }, 616 cve: { 617 name: "CVE-2025-55182", 618 server: `import { Instructions } from './client'
+20
src/client/ui/FlightLog.css
··· 84 opacity: 0.4; 85 } 86 87 .FlightLog-entry-header { 88 display: flex; 89 align-items: center; ··· 143 color: #81c784; 144 white-space: pre-wrap; 145 word-break: break-all; 146 } 147 148 /* FlightLog-renderView */
··· 84 opacity: 0.4; 85 } 86 87 + .FlightLog-entry--error { 88 + border-left-color: #e57373; 89 + } 90 + 91 .FlightLog-entry-header { 92 display: flex; 93 align-items: center; ··· 147 color: #81c784; 148 white-space: pre-wrap; 149 word-break: break-all; 150 + } 151 + 152 + /* Error display */ 153 + 154 + .FlightLog-entry-error { 155 + padding: 8px 10px; 156 + } 157 + 158 + .FlightLog-entry-errorMessage { 159 + margin: 0; 160 + font-family: var(--font-mono); 161 + font-size: 11px; 162 + line-height: 1.4; 163 + color: #e57373; 164 + white-space: pre-wrap; 165 + word-break: break-word; 166 } 167 168 /* FlightLog-renderView */
+39 -15
src/client/ui/FlightLog.tsx
··· 1 - import React, { useState, useRef, useEffect } from "react"; 2 import { FlightTreeView } from "./TreeView.tsx"; 3 import { Select } from "./Select.tsx"; 4 import type { EntryView } from "../runtime/index.ts"; ··· 79 cursor, 80 onDelete, 81 }: FlightLogEntryProps): React.ReactElement { 82 - const modifierClass = entry.isActive 83 - ? "FlightLog-entry--active" 84 - : entry.isDone 85 - ? "FlightLog-entry--done" 86 - : "FlightLog-entry--pending"; 87 88 return ( 89 <div className={`FlightLog-entry ${modifierClass}`} data-testid="flight-entry"> ··· 109 <pre className="FlightLog-entry-requestArgs">{entry.args}</pre> 110 </div> 111 )} 112 - <RenderLogView entry={entry} cursor={cursor} /> 113 </div> 114 ); 115 } ··· 118 entries: EntryView[]; 119 cursor: number; 120 availableActions: string[]; 121 - onAddRawAction: (actionName: string, rawPayload: string) => void; 122 onDeleteEntry: (index: number) => void; 123 }; 124 ··· 133 const [showRawInput, setShowRawInput] = useState(false); 134 const [selectedAction, setSelectedAction] = useState(""); 135 const [rawPayload, setRawPayload] = useState(""); 136 137 const handleAddRaw = (): void => { 138 if (rawPayload.trim()) { 139 - onAddRawAction(selectedAction, rawPayload); 140 - setSelectedAction(availableActions[0] ?? ""); 141 - setRawPayload(""); 142 - setShowRawInput(false); 143 } 144 }; 145 ··· 164 {availableActions.length > 0 && 165 (showRawInput ? ( 166 <div className="FlightLog-rawForm"> 167 - <Select value={selectedAction} onChange={(e) => setSelectedAction(e.target.value)}> 168 {availableActions.map((action) => ( 169 <option key={action} value={action}> 170 {action} ··· 177 onChange={(e) => setRawPayload(e.target.value)} 178 className="FlightLog-rawForm-textarea" 179 rows={6} 180 /> 181 <div className="FlightLog-rawForm-buttons"> 182 <button 183 className="FlightLog-rawForm-submitBtn" 184 onClick={handleAddRaw} 185 - disabled={!rawPayload.trim()} 186 > 187 - Add 188 </button> 189 <button 190 className="FlightLog-rawForm-cancelBtn" 191 onClick={() => setShowRawInput(false)} 192 > 193 Cancel 194 </button>
··· 1 + import React, { useState, useRef, useEffect, useTransition } from "react"; 2 import { FlightTreeView } from "./TreeView.tsx"; 3 import { Select } from "./Select.tsx"; 4 import type { EntryView } from "../runtime/index.ts"; ··· 79 cursor, 80 onDelete, 81 }: FlightLogEntryProps): React.ReactElement { 82 + const hasError = entry.error !== null; 83 + const modifierClass = hasError 84 + ? "FlightLog-entry--error" 85 + : entry.isActive 86 + ? "FlightLog-entry--active" 87 + : entry.isDone 88 + ? "FlightLog-entry--done" 89 + : "FlightLog-entry--pending"; 90 91 return ( 92 <div className={`FlightLog-entry ${modifierClass}`} data-testid="flight-entry"> ··· 112 <pre className="FlightLog-entry-requestArgs">{entry.args}</pre> 113 </div> 114 )} 115 + {hasError ? ( 116 + <div className="FlightLog-entry-error" data-testid="flight-entry-error"> 117 + <pre className="FlightLog-entry-errorMessage">{entry.error!.message}</pre> 118 + </div> 119 + ) : ( 120 + <RenderLogView entry={entry} cursor={cursor} /> 121 + )} 122 </div> 123 ); 124 } ··· 127 entries: EntryView[]; 128 cursor: number; 129 availableActions: string[]; 130 + onAddRawAction: (actionName: string, rawPayload: string) => Promise<void>; 131 onDeleteEntry: (index: number) => void; 132 }; 133 ··· 142 const [showRawInput, setShowRawInput] = useState(false); 143 const [selectedAction, setSelectedAction] = useState(""); 144 const [rawPayload, setRawPayload] = useState(""); 145 + const [isPending, startTransition] = useTransition(); 146 147 const handleAddRaw = (): void => { 148 if (rawPayload.trim()) { 149 + startTransition(async () => { 150 + try { 151 + await onAddRawAction(selectedAction, rawPayload); 152 + } catch { 153 + // Error entry added to timeline 154 + } 155 + startTransition(() => { 156 + setSelectedAction(availableActions[0] ?? ""); 157 + setRawPayload(""); 158 + setShowRawInput(false); 159 + }); 160 + }); 161 } 162 }; 163 ··· 182 {availableActions.length > 0 && 183 (showRawInput ? ( 184 <div className="FlightLog-rawForm"> 185 + <Select 186 + value={selectedAction} 187 + onChange={(e) => setSelectedAction(e.target.value)} 188 + disabled={isPending} 189 + > 190 {availableActions.map((action) => ( 191 <option key={action} value={action}> 192 {action} ··· 199 onChange={(e) => setRawPayload(e.target.value)} 200 className="FlightLog-rawForm-textarea" 201 rows={6} 202 + disabled={isPending} 203 /> 204 <div className="FlightLog-rawForm-buttons"> 205 <button 206 className="FlightLog-rawForm-submitBtn" 207 onClick={handleAddRaw} 208 + disabled={!rawPayload.trim() || isPending} 209 > 210 + {isPending ? "Adding..." : "Add"} 211 </button> 212 <button 213 className="FlightLog-rawForm-cancelBtn" 214 onClick={() => setShowRawInput(false)} 215 + disabled={isPending} 216 > 217 Cancel 218 </button>
+20 -5
src/client/workspace-session.ts
··· 86 args: EncodedArgs, 87 argsDisplay: string, 88 ): Promise<SteppableStream> { 89 - const responseRaw = await this.worker.callAction(actionName, args); 90 - const stream = new SteppableStream(responseRaw, { 91 - callServer: this.callServer.bind(this), 92 - }); 93 - await stream.waitForBuffer(); 94 this.timeline.addAction(actionName, argsDisplay, stream); 95 return stream; 96 } 97
··· 86 args: EncodedArgs, 87 argsDisplay: string, 88 ): Promise<SteppableStream> { 89 + let stream: SteppableStream; 90 + try { 91 + const responseRaw = await this.worker.callAction(actionName, args); 92 + stream = new SteppableStream(responseRaw, { 93 + callServer: this.callServer.bind(this), 94 + }); 95 + await stream.waitForBuffer(); 96 + } catch (err) { 97 + let error = err instanceof Error ? err : new Error(String(err)); 98 + if (error.message === "Connection closed.") { 99 + error = new Error( 100 + "Connection closed.\n\nThis usually means React couldn't parse the request payload. " + 101 + "Try triggering a real action first and copying its payload format.", 102 + ); 103 + } 104 + stream = SteppableStream.fromError(error); 105 + } 106 this.timeline.addAction(actionName, argsDisplay, stream); 107 + if (stream.error) { 108 + throw stream.error; 109 + } 110 return stream; 111 } 112
+6 -2
src/server/worker-server.ts
··· 105 } 106 export type Deploy = typeof deploy; 107 108 function render(): ReadableStream<Uint8Array> { 109 if (!deployed) throw new Error("No code deployed"); 110 const App = deployed.module.default as React.ComponentType; 111 - return renderToReadableStream(React.createElement(App), deployed.manifest); 112 } 113 export type Render = typeof render; 114 ··· 136 const args = Array.isArray(decoded) ? decoded : [decoded]; 137 const result = await actionFn(...args); 138 139 - return renderToReadableStream(result, deployed.manifest); 140 } 141 export type CallAction = typeof callAction; 142
··· 105 } 106 export type Deploy = typeof deploy; 107 108 + const renderOptions = { 109 + onError: () => "Switch to dev mode (top right) to see the full error.", 110 + }; 111 + 112 function render(): ReadableStream<Uint8Array> { 113 if (!deployed) throw new Error("No code deployed"); 114 const App = deployed.module.default as React.ComponentType; 115 + return renderToReadableStream(React.createElement(App), deployed.manifest, renderOptions); 116 } 117 export type Render = typeof render; 118 ··· 140 const args = Array.isArray(decoded) ? decoded : [decoded]; 141 const result = await actionFn(...args); 142 143 + return renderToReadableStream(result, deployed.manifest, renderOptions); 144 } 145 export type CallAction = typeof callAction; 146
+81
tests/actionerror.spec.ts
···
··· 1 + import { test, expect, beforeAll, afterAll, afterEach } from "vitest"; 2 + import { createHelpers, launchBrowser, type TestHelpers } from "./helpers.ts"; 3 + import type { Browser, Page } from "playwright"; 4 + 5 + let browser: Browser; 6 + let page: Page; 7 + let h: TestHelpers; 8 + 9 + beforeAll(async () => { 10 + browser = await launchBrowser(); 11 + page = await browser.newPage(); 12 + h = createHelpers(page); 13 + }); 14 + 15 + afterAll(async () => { 16 + await browser.close(); 17 + }); 18 + 19 + afterEach(async () => { 20 + await h.checkNoRemainingSteps(); 21 + }); 22 + 23 + test("action error - throwing action shows error in entry and clears pending state", async () => { 24 + await h.load("actionerror"); 25 + 26 + // Render completes 27 + expect(await h.stepAll()).toMatchInlineSnapshot(` 28 + "<div> 29 + <h1>Action Error</h1> 30 + <Button failAction={[Function: failAction]} /> 31 + </div>" 32 + `); 33 + expect(await h.preview("Trigger Failing Action")).toMatchInlineSnapshot(` 34 + "Action Error 35 + Trigger Failing Action" 36 + `); 37 + 38 + // Click the button to trigger the failing action 39 + await h.frame().getByTestId("preview-container").locator("button").click(); 40 + 41 + // Wait for the error entry to appear (action fails quickly) 42 + const errorEntry = h.frame().getByTestId("flight-entry-error"); 43 + await expect.poll(() => errorEntry.count(), { timeout: 10000 }).toBeGreaterThan(0); 44 + 45 + // Verify error message is displayed in the FlightLog entry 46 + const errorText = await errorEntry.innerText(); 47 + expect(errorText).toContain("Action failed intentionally"); 48 + 49 + // The error propagates to the preview (no ErrorBoundary in the sample), 50 + // so the preview will show the error message instead of the button. 51 + // The key verification is that the error entry appears in the FlightLog. 52 + }); 53 + 54 + test("action error - raw action with invalid payload shows error", async () => { 55 + await h.load("form"); 56 + 57 + // Render completes 58 + expect(await h.stepAll()).toMatchInlineSnapshot(` 59 + "<div> 60 + <h1>Form Action</h1> 61 + <Form greetAction={[Function: greet]} /> 62 + </div>" 63 + `); 64 + 65 + // Click + to add raw action 66 + await h.frame().locator(".FlightLog-addButton").click(); 67 + 68 + // Enter invalid payload (not valid URLSearchParams format for decodeReply) 69 + await h.frame().locator(".FlightLog-rawForm-textarea").fill("invalid-payload-that-will-fail"); 70 + 71 + // Submit 72 + await h.frame().locator(".FlightLog-rawForm-submitBtn").click(); 73 + 74 + // Wait for the error entry to appear 75 + const errorEntry = h.frame().getByTestId("flight-entry-error"); 76 + await expect.poll(() => errorEntry.count(), { timeout: 10000 }).toBeGreaterThan(0); 77 + 78 + // Verify error message includes our helpful hint about payload format 79 + const errorText = await errorEntry.innerText(); 80 + expect(errorText).toContain("couldn't parse the request payload"); 81 + });